diff --git a/src/routes/(app)/companies/[companyId]/links/+page.server.ts b/src/routes/(app)/companies/[companyId]/links/+page.server.ts index d1b4a93..6e32398 100644 --- a/src/routes/(app)/companies/[companyId]/links/+page.server.ts +++ b/src/routes/(app)/companies/[companyId]/links/+page.server.ts @@ -304,5 +304,166 @@ export const actions: Actions = { }); return { success: true, action: 'reorderCompanyLinks' }; + }, + + addPersonalLink: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', + 'manager', + 'user', + 'viewer', + 'hr', + 'accountant' + ]); + const fd = await request.formData(); + const { title, url, category, customLabel, description } = extractFields(fd); + + if (!title) return fail(400, { action: 'addPersonalLink', error: 'Title is required' }); + if (!category) return fail(400, { action: 'addPersonalLink', error: 'Invalid category' }); + if (!url) return fail(400, { action: 'addPersonalLink', error: 'URL is required' }); + + let validUrl: string; + try { + validUrl = validateLinkUrl(url); + } catch (err) { + const msg = err instanceof InvalidLinkUrlError ? err.message : 'Invalid URL'; + return fail(400, { action: 'addPersonalLink', error: msg }); + } + + const sortOrder = await nextSortOrder(userCompanyLinks, { + userId: user.id, + companyId: params.companyId, + category + }); + + await db.insert(userCompanyLinks).values({ + userId: user.id, + companyId: params.companyId, + category, + customLabel, + title, + url: validUrl, + description, + sortOrder + }); + + return { success: true, action: 'addPersonalLink' }; + }, + + updatePersonalLink: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', + 'manager', + 'user', + 'viewer', + 'hr', + 'accountant' + ]); + const fd = await request.formData(); + const id = trimOrNull(fd.get('id')); + if (!id) return fail(400, { action: 'updatePersonalLink', error: 'Link id is required' }); + + const [existing] = await db + .select() + .from(userCompanyLinks) + .where( + and( + eq(userCompanyLinks.id, id), + eq(userCompanyLinks.userId, user.id), + eq(userCompanyLinks.companyId, params.companyId) + ) + ) + .limit(1); + if (!existing) error(404, 'Link not found'); + + const { title, url, category, customLabel, description } = extractFields(fd); + if (!title) return fail(400, { action: 'updatePersonalLink', error: 'Title is required' }); + if (!category) return fail(400, { action: 'updatePersonalLink', error: 'Invalid category' }); + if (!url) return fail(400, { action: 'updatePersonalLink', error: 'URL is required' }); + + let validUrl: string; + try { + validUrl = validateLinkUrl(url); + } catch (err) { + const msg = err instanceof InvalidLinkUrlError ? err.message : 'Invalid URL'; + return fail(400, { action: 'updatePersonalLink', error: msg }); + } + + const urlChanged = validUrl !== existing.url; + await db + .update(userCompanyLinks) + .set({ + title, + url: validUrl, + category, + customLabel, + description, + faviconUrl: urlChanged ? null : existing.faviconUrl, + faviconFetchedAt: urlChanged ? null : existing.faviconFetchedAt, + updatedAt: new Date() + }) + .where(eq(userCompanyLinks.id, id)); + + return { success: true, action: 'updatePersonalLink' }; + }, + + deletePersonalLink: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', + 'manager', + 'user', + 'viewer', + 'hr', + 'accountant' + ]); + const fd = await request.formData(); + const id = trimOrNull(fd.get('id')); + if (!id) return fail(400, { action: 'deletePersonalLink', error: 'Link id is required' }); + + const result = await db + .delete(userCompanyLinks) + .where( + and( + eq(userCompanyLinks.id, id), + eq(userCompanyLinks.userId, user.id), + eq(userCompanyLinks.companyId, params.companyId) + ) + ) + .returning({ id: userCompanyLinks.id }); + + if (result.length === 0) error(404, 'Link not found'); + + return { success: true, action: 'deletePersonalLink' }; + }, + + reorderPersonalLinks: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', + 'manager', + 'user', + 'viewer', + 'hr', + 'accountant' + ]); + const fd = await request.formData(); + const payload = parseOrderPayload(fd.get('orders')); + if (!payload) return fail(400, { action: 'reorderPersonalLinks', error: 'Invalid order payload' }); + + await db.transaction(async (tx) => { + for (const { id, sortOrder } of payload) { + await tx + .update(userCompanyLinks) + .set({ sortOrder, updatedAt: new Date() }) + .where( + and( + eq(userCompanyLinks.id, id), + eq(userCompanyLinks.userId, user.id), + eq(userCompanyLinks.companyId, params.companyId) + ) + ); + } + }); + + return { success: true, action: 'reorderPersonalLinks' }; } }; diff --git a/src/routes/(app)/companies/[companyId]/links/+page.svelte b/src/routes/(app)/companies/[companyId]/links/+page.svelte index b33a55e..3eea4cb 100644 --- a/src/routes/(app)/companies/[companyId]/links/+page.svelte +++ b/src/routes/(app)/companies/[companyId]/links/+page.svelte @@ -55,7 +55,24 @@ const ALL_CATEGORIES = Object.keys(CATEGORY_LABELS); - const currentLinks = $derived( + type LinkRow = { + id: string; + category: string; + customLabel: string | null; + title: string; + url: string; + description: string | null; + faviconUrl: string | null; + }; + + type GridOpts = { + canEdit: boolean; + editAction: string; + deleteAction: string; + emptyMessage: string; + }; + + const currentLinks = $derived( activeTab === 'company' ? data.companyLinks : data.personalLinks ); @@ -67,6 +84,32 @@ const categoriesPresent = $derived([...new Set(currentLinks.map((l) => l.category))]); + const companyAddOpts = $derived({ + canShow: data.canManage, + action: '?/addCompanyLink', + title: 'Add Company Link' + }); + const personalAddOpts = { + canShow: true, + action: '?/addPersonalLink', + title: 'Add Personal Bookmark' + }; + const companyGridOpts: GridOpts = $derived({ + canEdit: data.canManage, + editAction: '?/updateCompanyLink', + deleteAction: '?/deleteCompanyLink', + emptyMessage: data.canManage + ? 'No company links yet. Click "+ New Link" to add the first one.' + : 'No company links have been added yet.' + }); + const personalGridOpts: GridOpts = { + canEdit: true, + editAction: '?/updatePersonalLink', + deleteAction: '?/deletePersonalLink', + emptyMessage: + 'No personal bookmarks yet. Bookmarks added here are private to you and scoped to this company.' + }; + function firstLetter(title: string): string { const t = title.trim(); return (t[0] || '?').toUpperCase(); @@ -80,10 +123,6 @@ } } - const inputCls = - 'w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white'; - const labelCls = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300'; - function startEdit(id: string) { editingId = id; showAddForm = false; @@ -95,16 +134,323 @@ } function openAdd() { - showAddForm = true; + showAddForm = !showAddForm; editingId = null; confirmDeleteId = null; } + + function switchTab(tab: Tab) { + activeTab = tab; + activeCategory = 'all'; + showAddForm = false; + editingId = null; + confirmDeleteId = null; + } + + const inputCls = + 'w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white'; + const labelCls = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300'; Links - {data.company.name} +{#snippet addSection(opts: { canShow: boolean; action: string; title: string })} + {#if opts.canShow} +
+
+

{opts.title}

+ +
+ + {#if showAddForm} +
async ({ result, update, formElement }) => { + await update({ reset: false }); + if (result.type === 'success') { + showAddForm = false; + formElement.reset(); + } + }} + class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2" + > +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ {/if} +
+ {/if} +{/snippet} + +{#snippet linkCard(link: LinkRow, opts: GridOpts)} +
+
+ {#if link.faviconUrl} + + {:else} +
+ {firstLetter(link.title)} +
+ {/if} +
+ + {link.title} + +

+ {hostnameOf(link.url)} +

+
+
+ +
+ + {CATEGORY_LABELS[link.category] ?? link.category} + + {#if link.customLabel} + · {link.customLabel} + {/if} +
+ + {#if link.description} +

{link.description}

+ {/if} + + {#if opts.canEdit} +
+ + +
+ + {#if confirmDeleteId === link.id} +
async ({ update }) => { + await update({ reset: false }); + confirmDeleteId = null; + }} + class="mt-2 rounded-md bg-red-50 p-2 text-xs dark:bg-red-900/30" + > + +

Delete "{link.title}"?

+
+ + +
+
+ {/if} + + {#if editingId === link.id} +
async ({ result, update }) => { + await update({ reset: false }); + if (result.type === 'success') editingId = null; + }} + class="mt-2 grid grid-cols-1 gap-3 rounded-md bg-gray-50 p-3 dark:bg-gray-700/50" + > + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ {/if} + {/if} +
+{/snippet} + +{#snippet linkGrid(opts: GridOpts)} + {#if filteredLinks.length === 0} +
+

{opts.emptyMessage}

+
+ {:else} +
+ {#each filteredLinks as link (link.id)} + {@render linkCard(link, opts)} + {/each} +
+ {/if} +{/snippet} +

Links

@@ -127,12 +473,7 @@
-
- - {#if showAddForm} -
async ({ result, update }) => { - await update({ reset: false }); - if (result.type === 'success') { - showAddForm = false; - const formEl = document.querySelector( - 'form[action="?/addCompanyLink"]' - ) as HTMLFormElement | null; - formEl?.reset(); - } - }} - class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2" - > -
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- {/if} - - {/if} - - {#if filteredLinks.length === 0} -
-

- {#if data.canManage} - No company links yet. Click "+ New Link" to add the first one. - {:else} - No company links have been added yet. - {/if} -

-
- {:else} -
- {#each filteredLinks as link (link.id)} -
-
- {#if link.faviconUrl} - - {:else} -
- {firstLetter(link.title)} -
- {/if} -
- - {link.title} - -

- {hostnameOf(link.url)} -

-
-
- -
- - {CATEGORY_LABELS[link.category] ?? link.category} - - {#if link.customLabel} - · {link.customLabel} - {/if} -
- - {#if link.description} -

{link.description}

- {/if} - - {#if data.canManage} -
- - -
- - {#if confirmDeleteId === link.id} -
async ({ update }) => { - await update({ reset: false }); - confirmDeleteId = null; - }} - class="mt-2 rounded-md bg-red-50 p-2 text-xs dark:bg-red-900/30" - > - -

Delete "{link.title}"?

-
- - -
-
- {/if} - - {#if editingId === link.id} -
async ({ result, update }) => { - await update({ reset: false }); - if (result.type === 'success') editingId = null; - }} - class="mt-2 grid grid-cols-1 gap-3 rounded-md bg-gray-50 p-3 dark:bg-gray-700/50" - > - -
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- {/if} - {/if} -
- {/each} -
- {/if} + {@render addSection(companyAddOpts)} + {@render linkGrid(companyGridOpts)} {:else} - -
-

Personal bookmarks are coming soon.

-
+ {@render addSection(personalAddOpts)} + {@render linkGrid(personalGridOpts)} {/if}