diff --git a/bun.lockb b/bun.lockb index f3ec79e..0b00622 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index dfcc004..2459968 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ }, "dependencies": { "nanoid": "^5.0.7", - "pocketbase": "^0.21.5" + "pocketbase": "^0.21.5", + "svelte-sonner": "^0.3.28" } } \ No newline at end of file diff --git a/pocketbase/pb_migrations/1730306469_updated_urls.js b/pocketbase/pb_migrations/1730306469_updated_urls.js new file mode 100644 index 0000000..94a8af6 --- /dev/null +++ b/pocketbase/pb_migrations/1730306469_updated_urls.js @@ -0,0 +1,16 @@ +/// +migrate((db) => { + const dao = new Dao(db) + const collection = dao.findCollectionByNameOrId("yq7y9q93v9mlxmq") + + collection.createRule = "@request.data.id != \"\" && @request.auth.id = @collection.users.id" + + return dao.saveCollection(collection) +}, (db) => { + const dao = new Dao(db) + const collection = dao.findCollectionByNameOrId("yq7y9q93v9mlxmq") + + collection.createRule = "" + + return dao.saveCollection(collection) +}) diff --git a/pocketbase/pb_migrations/1730306609_updated_urls.js b/pocketbase/pb_migrations/1730306609_updated_urls.js new file mode 100644 index 0000000..6591090 --- /dev/null +++ b/pocketbase/pb_migrations/1730306609_updated_urls.js @@ -0,0 +1,33 @@ +/// +migrate((db) => { + const dao = new Dao(db) + const collection = dao.findCollectionByNameOrId("yq7y9q93v9mlxmq") + + // add + collection.schema.addField(new SchemaField({ + "system": false, + "id": "oqmxf691", + "name": "created_by", + "type": "relation", + "required": false, + "presentable": false, + "unique": false, + "options": { + "collectionId": "_pb_users_auth_", + "cascadeDelete": false, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + })) + + return dao.saveCollection(collection) +}, (db) => { + const dao = new Dao(db) + const collection = dao.findCollectionByNameOrId("yq7y9q93v9mlxmq") + + // remove + collection.schema.removeField("oqmxf691") + + return dao.saveCollection(collection) +}) diff --git a/pocketbase/pb_migrations/1730306650_updated_urls.js b/pocketbase/pb_migrations/1730306650_updated_urls.js new file mode 100644 index 0000000..79819c2 --- /dev/null +++ b/pocketbase/pb_migrations/1730306650_updated_urls.js @@ -0,0 +1,22 @@ +/// +migrate((db) => { + const dao = new Dao(db) + const collection = dao.findCollectionByNameOrId("yq7y9q93v9mlxmq") + + collection.listRule = "@request.auth.id = @collection.urls.created_by" + collection.viewRule = "@request.auth.id = @collection.urls.created_by" + collection.updateRule = "@request.auth.id = @collection.urls.created_by" + collection.deleteRule = "@request.auth.id = @collection.urls.created_by" + + return dao.saveCollection(collection) +}, (db) => { + const dao = new Dao(db) + const collection = dao.findCollectionByNameOrId("yq7y9q93v9mlxmq") + + collection.listRule = "" + collection.viewRule = "" + collection.updateRule = "" + collection.deleteRule = "" + + return dao.saveCollection(collection) +}) diff --git a/pocketbase/pb_migrations/1730307172_updated_urls.js b/pocketbase/pb_migrations/1730307172_updated_urls.js new file mode 100644 index 0000000..0f7187e --- /dev/null +++ b/pocketbase/pb_migrations/1730307172_updated_urls.js @@ -0,0 +1,16 @@ +/// +migrate((db) => { + const dao = new Dao(db) + const collection = dao.findCollectionByNameOrId("yq7y9q93v9mlxmq") + + collection.createRule = "@request.data.id != \"\"" + + return dao.saveCollection(collection) +}, (db) => { + const dao = new Dao(db) + const collection = dao.findCollectionByNameOrId("yq7y9q93v9mlxmq") + + collection.createRule = "@request.data.id != \"\" && @request.auth.id = @collection.users.id" + + return dao.saveCollection(collection) +}) diff --git a/pocketbase/pb_migrations/1730307346_updated_urls.js b/pocketbase/pb_migrations/1730307346_updated_urls.js new file mode 100644 index 0000000..9f46a05 --- /dev/null +++ b/pocketbase/pb_migrations/1730307346_updated_urls.js @@ -0,0 +1,16 @@ +/// +migrate((db) => { + const dao = new Dao(db) + const collection = dao.findCollectionByNameOrId("yq7y9q93v9mlxmq") + + collection.createRule = "" + + return dao.saveCollection(collection) +}, (db) => { + const dao = new Dao(db) + const collection = dao.findCollectionByNameOrId("yq7y9q93v9mlxmq") + + collection.createRule = "@request.data.id != \"\"" + + return dao.saveCollection(collection) +}) diff --git a/pocketbase/pb_migrations/1730307378_updated_urls.js b/pocketbase/pb_migrations/1730307378_updated_urls.js new file mode 100644 index 0000000..6c4a31b --- /dev/null +++ b/pocketbase/pb_migrations/1730307378_updated_urls.js @@ -0,0 +1,16 @@ +/// +migrate((db) => { + const dao = new Dao(db) + const collection = dao.findCollectionByNameOrId("yq7y9q93v9mlxmq") + + collection.createRule = "@request.auth.id != ''" + + return dao.saveCollection(collection) +}, (db) => { + const dao = new Dao(db) + const collection = dao.findCollectionByNameOrId("yq7y9q93v9mlxmq") + + collection.createRule = "" + + return dao.saveCollection(collection) +}) diff --git a/pocketbase/pb_migrations/1730307635_updated_urls.js b/pocketbase/pb_migrations/1730307635_updated_urls.js new file mode 100644 index 0000000..5f28fd0 --- /dev/null +++ b/pocketbase/pb_migrations/1730307635_updated_urls.js @@ -0,0 +1,22 @@ +/// +migrate((db) => { + const dao = new Dao(db) + const collection = dao.findCollectionByNameOrId("yq7y9q93v9mlxmq") + + collection.listRule = "@request.auth.id != ''" + collection.viewRule = "@request.auth.id != ''" + collection.updateRule = "@request.auth.id != ''" + collection.deleteRule = "@request.auth.id != ''" + + return dao.saveCollection(collection) +}, (db) => { + const dao = new Dao(db) + const collection = dao.findCollectionByNameOrId("yq7y9q93v9mlxmq") + + collection.listRule = "@request.auth.id = @collection.urls.created_by" + collection.viewRule = "@request.auth.id = @collection.urls.created_by" + collection.updateRule = "@request.auth.id = @collection.urls.created_by" + collection.deleteRule = "@request.auth.id = @collection.urls.created_by" + + return dao.saveCollection(collection) +}) diff --git a/pocketbase/pb_migrations/1730307708_updated_urls.js b/pocketbase/pb_migrations/1730307708_updated_urls.js new file mode 100644 index 0000000..e3c2883 --- /dev/null +++ b/pocketbase/pb_migrations/1730307708_updated_urls.js @@ -0,0 +1,22 @@ +/// +migrate((db) => { + const dao = new Dao(db) + const collection = dao.findCollectionByNameOrId("yq7y9q93v9mlxmq") + + collection.listRule = "" + collection.viewRule = "" + collection.updateRule = "" + collection.deleteRule = "" + + return dao.saveCollection(collection) +}, (db) => { + const dao = new Dao(db) + const collection = dao.findCollectionByNameOrId("yq7y9q93v9mlxmq") + + collection.listRule = "@request.auth.id != ''" + collection.viewRule = "@request.auth.id != ''" + collection.updateRule = "@request.auth.id != ''" + collection.deleteRule = "@request.auth.id != ''" + + return dao.saveCollection(collection) +}) diff --git a/src/app.css b/src/app.css index 0208287..e155a7e 100644 --- a/src/app.css +++ b/src/app.css @@ -3,12 +3,50 @@ @import "tailwindcss/utilities"; :root { - --background: 210 40% 98%; /* Cool light background */ - --card: 0 0% 100%; /* Pure white */ - --ring: 215 25% 92%; /* Subtle blue-gray ring */ - --border: 215 20% 92%; /* Subtle blue-gray border */ + --background: 0 0% 98%; /* Almost white */ + --foreground: 0 0% 12%; /* Dark gray for text */ + --card: 0 0% 100%; /* Pure white */ + --card-foreground: 0 0% 12%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 12%; + --primary: 0 0% 12%; /* Dark gray */ + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96%; /* Light gray */ + --secondary-foreground: 0 0% 12%; + --muted: 0 0% 96%; + --muted-foreground: 0 0% 45%; + --accent: 0 0% 96%; + --accent-foreground: 0 0% 12%; + --destructive: 0 84% 60%; /* Red for destructive actions */ + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89%; /* Gray border */ + --input: 0 0% 89%; + --ring: 0 0% 82%; +} + +.dark { + --background: 0 0% 5%; /* Almost black */ + --foreground: 0 0% 98%; /* Almost white */ + --card: 0 0% 8%; /* Dark gray */ + --card-foreground: 0 0% 98%; + --popover: 0 0% 8%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; /* Almost white */ + --primary-foreground: 0 0% 5%; + --secondary: 0 0% 12%; /* Darker gray */ + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 12%; + --muted-foreground: 0 0% 63%; + --accent: 0 0% 12%; + --accent-foreground: 0 0% 98%; + --destructive: 0 84% 60%; /* Keep red for destructive actions */ + --destructive-foreground: 0 0% 98%; + --border: 0 0% 15%; + --input: 0 0% 15%; + --ring: 0 0% 22%; } body { - background-color: #F8FAFC; /* Matches the background variable */ + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); } diff --git a/src/lib/types/generated-types.ts b/src/lib/types/generated-types.ts index e5a42e3..a1938fd 100644 --- a/src/lib/types/generated-types.ts +++ b/src/lib/types/generated-types.ts @@ -1,134 +1,71 @@ /** - * This file was @generated using pocketbase-typegen - */ +* This file was @generated using pocketbase-typegen +*/ -import type PocketBase from 'pocketbase'; -import type { RecordService } from 'pocketbase'; +import type PocketBase from 'pocketbase' +import type { RecordService } from 'pocketbase' export enum Collections { - Issues = 'issues', - Projects = 'projects', - Users = 'users' + Urls = "urls", + Users = "users", } // Alias types for improved usability -export type IsoDateString = string; -export type RecordIdString = string; -export type HTMLString = string; +export type IsoDateString = string +export type RecordIdString = string +export type HTMLString = string // System fields export type BaseSystemFields = { - id: RecordIdString; - created: IsoDateString; - updated: IsoDateString; - collectionId: string; - collectionName: Collections; - expand?: T; -}; + id: RecordIdString + created: IsoDateString + updated: IsoDateString + collectionId: string + collectionName: Collections + expand?: T +} export type AuthSystemFields = { - email: string; - emailVisibility: boolean; - username: string; - verified: boolean; -} & BaseSystemFields; + email: string + emailVisibility: boolean + username: string + verified: boolean +} & BaseSystemFields // Record types for each collection -export enum IssuesStatusOptions { - 'Backlog' = 'Backlog', - 'Todo' = 'Todo', - 'In Progress' = 'In Progress', - 'Done' = 'Done', - 'Cancelled' = 'Cancelled' -} - -export enum IssuesPriorityOptions { - 'None' = 'None', - 'Low' = 'Low', - 'Medium' = 'Medium', - 'High' = 'High', - 'Urgent' = 'Urgent' -} - -export enum IssuesLabelOptions { - 'Bug' = 'Bug', - 'Feature' = 'Feature', - 'Documentation' = 'Documentation', - 'Question' = 'Question', - 'Task' = 'Task' -} -export type IssuesRecord = { - created_by?: RecordIdString; - description?: string; - due_date?: IsoDateString; - identifier: string; - label?: IssuesLabelOptions[]; - priority?: IssuesPriorityOptions[]; - status?: IssuesStatusOptions; - title: string; -}; - -export enum ProjectsColourOptions { - 'Red' = 'Red', - 'Green' = 'Green', - 'Blue' = 'Blue', - 'Pink' = 'Pink', - 'Light Blue' = 'Light Blue', - 'Orange' = 'Orange', - 'Purple' = 'Purple', - 'Turquoise' = 'Turquoise', - 'Lime' = 'Lime' +export type UrlsRecord = { + clicks?: number + created_by?: RecordIdString + slug: string + url: string } -export enum ProjectsStatusOptions { - 'Backlog' = 'Backlog', - 'Todo' = 'Todo', - 'In Progress' = 'In Progress', - 'Done' = 'Done', - 'Cancelled' = 'Cancelled' -} -export type ProjectsRecord = { - colour?: ProjectsColourOptions; - created_by?: RecordIdString; - description?: string; - end_date?: IsoDateString; - identifier?: string; - start_date?: IsoDateString; - status?: ProjectsStatusOptions; - title?: string; -}; - export type UsersRecord = { - avatar?: string; - name?: string; -}; + avatar?: string + name?: string +} // Response types include system fields and match responses from the PocketBase API -export type IssuesResponse = Required & BaseSystemFields; -export type ProjectsResponse = Required & - BaseSystemFields; -export type UsersResponse = Required & AuthSystemFields; +export type UrlsResponse = Required & BaseSystemFields +export type UsersResponse = Required & AuthSystemFields // Types containing all Records and Responses, useful for creating typing helper functions export type CollectionRecords = { - issues: IssuesRecord; - projects: ProjectsRecord; - users: UsersRecord; -}; + urls: UrlsRecord + users: UsersRecord +} export type CollectionResponses = { - issues: IssuesResponse; - projects: ProjectsResponse; - users: UsersResponse; -}; + urls: UrlsResponse + users: UsersResponse +} // Type for usage with type asserted PocketBase instance // https://github.com/pocketbase/js-sdk#specify-typescript-definitions export type TypedPocketBase = PocketBase & { - collection(idOrName: 'issues'): RecordService; - collection(idOrName: 'projects'): RecordService; - collection(idOrName: 'users'): RecordService; -}; + collection(idOrName: 'urls'): RecordService + collection(idOrName: 'users'): RecordService +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 49dc2ed..2da226e 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,8 +1,10 @@ +
{@render children()}
diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index f70de71..ce2c7fb 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -8,17 +8,22 @@ export const load: PageServerLoad = async ({ locals }) => { throw redirect(302, "/login"); } const urls = await locals.pb.collection("urls").getFullList(); - return { urls }; + return { urls, user: locals.pb.authStore.model }; }; export const actions: Actions = { shorten: async ({ request, locals }) => { + if (!locals.pb.authStore.isValid) { + throw redirect(302, "/login"); + } + const formData = await request.formData(); const url = formData.get("url") as string; let slug = formData.get("slug") as string; + const created_by = formData.get("created_by") as string; - if (!url) { - return fail(400, { message: "URL is required" }); + if (!url || !created_by) { + return fail(400, { message: "URL and created_by are required" }); } // If no custom slug provided, generate one @@ -46,7 +51,12 @@ export const actions: Actions = { }); } - await locals.pb.collection("urls").create({ url, slug, clicks: 0 }); + await locals.pb.collection("urls").create({ + url, + slug, + clicks: 0, + created_by, + }); return { type: "success", @@ -62,16 +72,21 @@ export const actions: Actions = { }, update: async ({ request, locals }) => { + if (!locals.pb.authStore.isValid) { + throw redirect(302, "/login"); + } + const formData = await request.formData(); const id = formData.get("id") as string; const url = formData.get("url") as string; const slug = formData.get("slug") as string; + const created_by = formData.get("created_by") as string; - if (!id || !url || !slug) { + if (!id || !url || !slug || !created_by) { return fail(400, { message: "ID, URL, and slug are required" }); } - await locals.pb.collection("urls").update(id, { url, slug }); + await locals.pb.collection("urls").update(id, { url, slug, created_by }); return { type: "success", @@ -81,13 +96,25 @@ export const actions: Actions = { }, delete: async ({ request, locals }) => { + if (!locals.pb.authStore.isValid) { + throw redirect(302, "/login"); + } + const formData = await request.formData(); const id = formData.get("id") as string; + const created_by = formData.get("created_by") as string; - if (!id) { + if (!id || !created_by) { return fail(400, { message: "ID is required" }); } + // check if the user is the owner of the URL + const url = await locals.pb.collection("urls").getOne(id); + if (url.created_by !== created_by) { + return fail(403, { message: "You are not the owner of this URL" }); + } + + // delete the URL await locals.pb.collection("urls").delete(id); return { diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 83e88a2..2b44e54 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -5,9 +5,12 @@ import { generateSlug } from "$lib"; import { onMount, onDestroy } from "svelte"; import { env } from "$env/dynamic/public"; - import { pb } from "$lib/pocketbase"; // Ensure this import exists + import { pb } from "$lib/pocketbase"; + import { toast } from "svelte-sonner"; + import type { UrlsResponse, UsersResponse } from "$lib/types"; - let { data } = $props(); + let { data }: { data: { urls: UrlsResponse[]; user: UsersResponse } } = + $props(); let longUrl = $state(""); let customSlug = $state(""); let shortUrl = $state(""); @@ -18,9 +21,6 @@ let searchQuery = $state(""); let showShortcutsDialog = $state(false); - // Add new state for theme - let isDark = $state(false); - // Add editSlug state let editSlug = $state(""); @@ -106,8 +106,10 @@ event.preventDefault(); if (deletingId === hoveredUrl) { // If already confirming, submit the delete - const form = document.querySelector(`form[data-delete-id="${hoveredUrl}"]`); - form?.dispatchEvent(new Event('submit', { cancelable: true })); + const form = document.querySelector( + `form[data-delete-id="${hoveredUrl}"]`, + ); + form?.dispatchEvent(new Event("submit", { cancelable: true })); deletingId = null; } else { // Start confirmation @@ -121,18 +123,18 @@ onMount(async () => { try { // Await the subscription setup - unsubscribe = await pb.collection('urls').subscribe('*', (e) => { + unsubscribe = await pb.collection("urls").subscribe("*", (e) => { switch (e.action) { - case 'create': - urls = [...urls, e.record]; + case "create": + urls = [...urls, e.record as UrlsResponse]; break; - case 'update': - urls = urls.map(url => - url.id === e.record.id ? e.record : url + case "update": + urls = urls.map((url) => + url.id === e.record.id ? (e.record as UrlsResponse) : url, ); break; - case 'delete': - urls = urls.filter(url => url.id !== e.record.id); + case "delete": + urls = urls.filter((url) => url.id !== e.record.id); break; } }); @@ -143,7 +145,7 @@ window.removeEventListener("keydown", handleKeyboard); }; } catch (error) { - console.error('Failed to setup realtime subscription:', error); + console.error("Failed to setup realtime subscription:", error); } }); @@ -174,15 +176,14 @@
-
+

Blink

@@ -193,7 +194,7 @@
@@ -267,11 +276,14 @@ longUrl = ""; customSlug = ""; showAddForm = false; + + toast.success("URL shortened successfully!"); } }; }} - class="overflow-hidden rounded-2xl bg-white/80 shadow-xl shadow-slate-200/50 ring-1 ring-slate-200/50 backdrop-blur-sm dark:bg-slate-800/80 dark:shadow-slate-900/50 dark:ring-slate-700/50" + class="overflow-hidden rounded-2xl bg-white/80 shadow-xl ring-1 ring-gray-200 backdrop-blur-sm dark:bg-gray-800/80 dark:ring-gray-700" > +
@@ -336,7 +348,7 @@ @@ -349,12 +361,14 @@
-

+

+

+ URL shortened successfully +

+
+ + {shortUrl} +
+
{/if} - {#if data.urls.length > 0 && !isLoading} +
+

+ Your URLs +

+
+ { + if (e.key === "Escape") { + e.currentTarget.blur(); + } + }} + class="w-full rounded-full border border-slate-200 bg-white/80 py-2 pl-9 pr-16 text-sm backdrop-blur-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-100 dark:border-slate-700 dark:bg-slate-800/80 dark:text-slate-200 dark:placeholder:text-slate-500 dark:focus:border-indigo-400 dark:focus:ring-indigo-900" + /> + + + + +
+
+ + {#if urls.length === 0}
-

- Your URLs -

-
- { - if (e.key === "Escape") { - e.currentTarget.blur(); - } - }} - class="w-full rounded-lg border border-slate-200 bg-white/80 py-2 pl-9 pr-16 text-sm backdrop-blur-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-100 dark:border-slate-700 dark:bg-slate-800/80 dark:text-slate-200 dark:placeholder:text-slate-500 dark:focus:border-indigo-400 dark:focus:ring-indigo-900" - /> - +
-
-
- {#if filteredUrls.length > 0} -
- {#each filteredUrls as url (url.id)} -
(hoveredUrl = url.id)} - onmouseleave={() => (hoveredUrl = null)} - aria-label={`${url.url} (${url.clicks} clicks)`} - role="article" +

+ Start Shortening URLs +

+ +

+ Create your first shortened URL and make sharing links easier than + ever. +

+ + +
+
+ {:else if filteredUrls.length > 0} +
+ {#each filteredUrls as url (url.id)} +
(hoveredUrl = url.id)} + onmouseleave={() => (hoveredUrl = null)} + aria-label={`${url.url} (${url.clicks} clicks)`} + role="article" + > + {#if editingId === url.id} + { + return async ({ result, update }) => { + await update(); + if (result.type === "success") { + editingId = null; + editUrl = ""; + editSlug = ""; + toast.success("URL updated successfully"); + } + }; + }} + > + + + + +
+ + +
- -
-
+ {/each} +
+ {:else} +
+
+ + + + +

+ No URLs match your search +

+ +

+ Try adjusting your search terms or clear the search to see all + your URLs.

- {/if} -
- {/if} +
+ {/if} +
@@ -891,4 +1008,3 @@ background-color: #0056b3; } - diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index 382489a..c95ada1 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -9,39 +9,36 @@
-
+
-
+

- Welcome Back + {isLogin ? "Welcome Back" : "Join Blink"}

-

- {isLogin ? "Sign in to your account" : "Create your account"} -