Add personal bookmarks CRUD on links page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -304,5 +304,166 @@ export const actions: Actions = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return { success: true, action: 'reorderCompanyLinks' };
|
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' };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -55,7 +55,24 @@
|
|||||||
|
|
||||||
const ALL_CATEGORIES = Object.keys(CATEGORY_LABELS);
|
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<LinkRow[]>(
|
||||||
activeTab === 'company' ? data.companyLinks : data.personalLinks
|
activeTab === 'company' ? data.companyLinks : data.personalLinks
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -67,6 +84,32 @@
|
|||||||
|
|
||||||
const categoriesPresent = $derived([...new Set(currentLinks.map((l) => l.category))]);
|
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 {
|
function firstLetter(title: string): string {
|
||||||
const t = title.trim();
|
const t = title.trim();
|
||||||
return (t[0] || '?').toUpperCase();
|
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) {
|
function startEdit(id: string) {
|
||||||
editingId = id;
|
editingId = id;
|
||||||
showAddForm = false;
|
showAddForm = false;
|
||||||
@@ -95,16 +134,323 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openAdd() {
|
function openAdd() {
|
||||||
showAddForm = true;
|
showAddForm = !showAddForm;
|
||||||
editingId = null;
|
editingId = null;
|
||||||
confirmDeleteId = 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';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Links - {data.company.name}</title>
|
<title>Links - {data.company.name}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
{#snippet addSection(opts: { canShow: boolean; action: string; title: string })}
|
||||||
|
{#if opts.canShow}
|
||||||
|
<section
|
||||||
|
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="font-semibold text-gray-900 dark:text-white">{opts.title}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={openAdd}
|
||||||
|
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
{showAddForm ? 'Cancel' : '+ New Link'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showAddForm}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action={opts.action}
|
||||||
|
use:enhance={() => 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"
|
||||||
|
>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label for="add-title" class={labelCls}
|
||||||
|
>Title <span class="text-red-500">*</span></label
|
||||||
|
>
|
||||||
|
<input id="add-title" name="title" type="text" required class={inputCls} />
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label for="add-url" class={labelCls}>URL <span class="text-red-500">*</span></label>
|
||||||
|
<input
|
||||||
|
id="add-url"
|
||||||
|
name="url"
|
||||||
|
type="url"
|
||||||
|
required
|
||||||
|
placeholder="https://example.com"
|
||||||
|
class={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="add-category" class={labelCls}
|
||||||
|
>Category <span class="text-red-500">*</span></label
|
||||||
|
>
|
||||||
|
<select id="add-category" name="category" required class={inputCls}>
|
||||||
|
{#each ALL_CATEGORIES as cat (cat)}
|
||||||
|
<option value={cat}>{CATEGORY_LABELS[cat]}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="add-customLabel" class={labelCls}>Custom Label</label>
|
||||||
|
<input
|
||||||
|
id="add-customLabel"
|
||||||
|
name="customLabel"
|
||||||
|
type="text"
|
||||||
|
placeholder="Optional sub-label"
|
||||||
|
class={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label for="add-description" class={labelCls}>Description</label>
|
||||||
|
<textarea id="add-description" name="description" rows="2" class={inputCls}></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (showAddForm = false)}
|
||||||
|
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Add Link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet linkCard(link: LinkRow, opts: GridOpts)}
|
||||||
|
<div
|
||||||
|
class="group relative flex flex-col gap-2 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
{#if link.faviconUrl}
|
||||||
|
<img
|
||||||
|
src={link.faviconUrl}
|
||||||
|
alt=""
|
||||||
|
class="mt-0.5 h-8 w-8 flex-shrink-0 rounded border border-gray-200 bg-white object-contain p-0.5 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded text-sm font-bold text-white {CATEGORY_CHIP[
|
||||||
|
link.category
|
||||||
|
] ?? 'bg-gray-500'}"
|
||||||
|
>
|
||||||
|
{firstLetter(link.title)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<a
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="block truncate text-sm font-semibold text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400"
|
||||||
|
>
|
||||||
|
{link.title}
|
||||||
|
</a>
|
||||||
|
<p class="truncate text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{hostnameOf(link.url)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="rounded-full px-2 py-0.5 text-xs font-medium {CATEGORY_BADGE[link.category] ??
|
||||||
|
'bg-gray-100 text-gray-700'}"
|
||||||
|
>
|
||||||
|
{CATEGORY_LABELS[link.category] ?? link.category}
|
||||||
|
</span>
|
||||||
|
{#if link.customLabel}
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">· {link.customLabel}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if link.description}
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-300">{link.description}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if opts.canEdit}
|
||||||
|
<div
|
||||||
|
class="mt-auto flex justify-end gap-2 border-t border-gray-100 pt-2 dark:border-gray-700"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => startEdit(link.id)}
|
||||||
|
class="text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (confirmDeleteId = confirmDeleteId === link.id ? null : link.id)}
|
||||||
|
class="text-xs font-medium text-red-600 hover:text-red-700 dark:text-red-400"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if confirmDeleteId === link.id}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action={opts.deleteAction}
|
||||||
|
use:enhance={() => 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"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={link.id} />
|
||||||
|
<p class="mb-2 text-red-700 dark:text-red-300">Delete "{link.title}"?</p>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (confirmDeleteId = null)}
|
||||||
|
class="rounded border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded bg-red-600 px-2 py-1 text-xs font-medium text-white hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if editingId === link.id}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action={opts.editAction}
|
||||||
|
use:enhance={() => 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"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={link.id} />
|
||||||
|
<div>
|
||||||
|
<label for="edit-title-{link.id}" class={labelCls}>Title</label>
|
||||||
|
<input
|
||||||
|
id="edit-title-{link.id}"
|
||||||
|
name="title"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={link.title}
|
||||||
|
class={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="edit-url-{link.id}" class={labelCls}>URL</label>
|
||||||
|
<input
|
||||||
|
id="edit-url-{link.id}"
|
||||||
|
name="url"
|
||||||
|
type="url"
|
||||||
|
required
|
||||||
|
value={link.url}
|
||||||
|
class={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="edit-cat-{link.id}" class={labelCls}>Category</label>
|
||||||
|
<select
|
||||||
|
id="edit-cat-{link.id}"
|
||||||
|
name="category"
|
||||||
|
required
|
||||||
|
value={link.category}
|
||||||
|
class={inputCls}
|
||||||
|
>
|
||||||
|
{#each ALL_CATEGORIES as cat (cat)}
|
||||||
|
<option value={cat}>{CATEGORY_LABELS[cat]}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="edit-custom-{link.id}" class={labelCls}>Custom Label</label>
|
||||||
|
<input
|
||||||
|
id="edit-custom-{link.id}"
|
||||||
|
name="customLabel"
|
||||||
|
type="text"
|
||||||
|
value={link.customLabel ?? ''}
|
||||||
|
class={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="edit-desc-{link.id}" class={labelCls}>Description</label>
|
||||||
|
<textarea
|
||||||
|
id="edit-desc-{link.id}"
|
||||||
|
name="description"
|
||||||
|
rows="2"
|
||||||
|
class={inputCls}>{link.description ?? ''}</textarea
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={cancelEdit}
|
||||||
|
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet linkGrid(opts: GridOpts)}
|
||||||
|
{#if filteredLinks.length === 0}
|
||||||
|
<div
|
||||||
|
class="rounded-lg border border-dashed border-gray-300 bg-white p-10 text-center dark:border-gray-700 dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{opts.emptyMessage}</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{#each filteredLinks as link (link.id)}
|
||||||
|
{@render linkCard(link, opts)}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<header>
|
<header>
|
||||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Links</h1>
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Links</h1>
|
||||||
@@ -127,12 +473,7 @@
|
|||||||
<div class="flex gap-1 border-b border-gray-200 dark:border-gray-700">
|
<div class="flex gap-1 border-b border-gray-200 dark:border-gray-700">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => {
|
onclick={() => switchTab('company')}
|
||||||
activeTab = 'company';
|
|
||||||
activeCategory = 'all';
|
|
||||||
showAddForm = false;
|
|
||||||
editingId = null;
|
|
||||||
}}
|
|
||||||
class="whitespace-nowrap border-b-2 px-4 py-2 text-sm font-medium transition-colors {activeTab ===
|
class="whitespace-nowrap border-b-2 px-4 py-2 text-sm font-medium transition-colors {activeTab ===
|
||||||
'company'
|
'company'
|
||||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||||
@@ -142,12 +483,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => {
|
onclick={() => switchTab('personal')}
|
||||||
activeTab = 'personal';
|
|
||||||
activeCategory = 'all';
|
|
||||||
showAddForm = false;
|
|
||||||
editingId = null;
|
|
||||||
}}
|
|
||||||
class="whitespace-nowrap border-b-2 px-4 py-2 text-sm font-medium transition-colors {activeTab ===
|
class="whitespace-nowrap border-b-2 px-4 py-2 text-sm font-medium transition-colors {activeTab ===
|
||||||
'personal'
|
'personal'
|
||||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||||
@@ -184,311 +520,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if activeTab === 'company'}
|
{#if activeTab === 'company'}
|
||||||
<!-- Company Links -->
|
{@render addSection(companyAddOpts)}
|
||||||
{#if data.canManage}
|
{@render linkGrid(companyGridOpts)}
|
||||||
<section
|
|
||||||
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h2 class="font-semibold text-gray-900 dark:text-white">Add Company Link</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={openAdd}
|
|
||||||
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
{showAddForm ? 'Cancel' : '+ New Link'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if showAddForm}
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/addCompanyLink"
|
|
||||||
use:enhance={() => 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"
|
|
||||||
>
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<label for="add-title" class={labelCls}>Title <span class="text-red-500">*</span></label>
|
|
||||||
<input id="add-title" name="title" type="text" required class={inputCls} />
|
|
||||||
</div>
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<label for="add-url" class={labelCls}>URL <span class="text-red-500">*</span></label>
|
|
||||||
<input
|
|
||||||
id="add-url"
|
|
||||||
name="url"
|
|
||||||
type="url"
|
|
||||||
required
|
|
||||||
placeholder="https://example.com"
|
|
||||||
class={inputCls}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="add-category" class={labelCls}
|
|
||||||
>Category <span class="text-red-500">*</span></label
|
|
||||||
>
|
|
||||||
<select id="add-category" name="category" required class={inputCls}>
|
|
||||||
{#each ALL_CATEGORIES as cat (cat)}
|
|
||||||
<option value={cat}>{CATEGORY_LABELS[cat]}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="add-customLabel" class={labelCls}>Custom Label</label>
|
|
||||||
<input
|
|
||||||
id="add-customLabel"
|
|
||||||
name="customLabel"
|
|
||||||
type="text"
|
|
||||||
placeholder="Optional sub-label"
|
|
||||||
class={inputCls}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<label for="add-description" class={labelCls}>Description</label>
|
|
||||||
<textarea
|
|
||||||
id="add-description"
|
|
||||||
name="description"
|
|
||||||
rows="2"
|
|
||||||
class={inputCls}
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="md:col-span-2 flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => (showAddForm = false)}
|
|
||||||
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Add Link
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if filteredLinks.length === 0}
|
|
||||||
<div
|
|
||||||
class="rounded-lg border border-dashed border-gray-300 bg-white p-10 text-center dark:border-gray-700 dark:bg-gray-800"
|
|
||||||
>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{#if data.canManage}
|
|
||||||
No company links yet. Click "+ New Link" to add the first one.
|
|
||||||
{:else}
|
|
||||||
No company links have been added yet.
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{#each filteredLinks as link (link.id)}
|
|
||||||
<div
|
|
||||||
class="group relative flex flex-col gap-2 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800"
|
|
||||||
>
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
{#if link.faviconUrl}
|
|
||||||
<img
|
|
||||||
src={link.faviconUrl}
|
|
||||||
alt=""
|
|
||||||
class="mt-0.5 h-8 w-8 flex-shrink-0 rounded border border-gray-200 bg-white object-contain p-0.5 dark:border-gray-600"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div
|
|
||||||
class="mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded text-sm font-bold text-white {CATEGORY_CHIP[
|
|
||||||
link.category
|
|
||||||
] ?? 'bg-gray-500'}"
|
|
||||||
>
|
|
||||||
{firstLetter(link.title)}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<a
|
|
||||||
href={link.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="block truncate text-sm font-semibold text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400"
|
|
||||||
>
|
|
||||||
{link.title}
|
|
||||||
</a>
|
|
||||||
<p class="truncate text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{hostnameOf(link.url)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<span
|
|
||||||
class="rounded-full px-2 py-0.5 text-xs font-medium {CATEGORY_BADGE[link.category] ??
|
|
||||||
'bg-gray-100 text-gray-700'}"
|
|
||||||
>
|
|
||||||
{CATEGORY_LABELS[link.category] ?? link.category}
|
|
||||||
</span>
|
|
||||||
{#if link.customLabel}
|
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400">· {link.customLabel}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if link.description}
|
|
||||||
<p class="text-xs text-gray-600 dark:text-gray-300">{link.description}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if data.canManage}
|
|
||||||
<div
|
|
||||||
class="mt-auto flex justify-end gap-2 border-t border-gray-100 pt-2 dark:border-gray-700"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => startEdit(link.id)}
|
|
||||||
class="text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => (confirmDeleteId = confirmDeleteId === link.id ? null : link.id)}
|
|
||||||
class="text-xs font-medium text-red-600 hover:text-red-700 dark:text-red-400"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if confirmDeleteId === link.id}
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/deleteCompanyLink"
|
|
||||||
use:enhance={() => 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"
|
|
||||||
>
|
|
||||||
<input type="hidden" name="id" value={link.id} />
|
|
||||||
<p class="mb-2 text-red-700 dark:text-red-300">Delete "{link.title}"?</p>
|
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => (confirmDeleteId = null)}
|
|
||||||
class="rounded border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="rounded bg-red-600 px-2 py-1 text-xs font-medium text-white hover:bg-red-700"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if editingId === link.id}
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/updateCompanyLink"
|
|
||||||
use:enhance={() => 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"
|
|
||||||
>
|
|
||||||
<input type="hidden" name="id" value={link.id} />
|
|
||||||
<div>
|
|
||||||
<label for="edit-title-{link.id}" class={labelCls}>Title</label>
|
|
||||||
<input
|
|
||||||
id="edit-title-{link.id}"
|
|
||||||
name="title"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={link.title}
|
|
||||||
class={inputCls}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="edit-url-{link.id}" class={labelCls}>URL</label>
|
|
||||||
<input
|
|
||||||
id="edit-url-{link.id}"
|
|
||||||
name="url"
|
|
||||||
type="url"
|
|
||||||
required
|
|
||||||
value={link.url}
|
|
||||||
class={inputCls}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="edit-cat-{link.id}" class={labelCls}>Category</label>
|
|
||||||
<select
|
|
||||||
id="edit-cat-{link.id}"
|
|
||||||
name="category"
|
|
||||||
required
|
|
||||||
value={link.category}
|
|
||||||
class={inputCls}
|
|
||||||
>
|
|
||||||
{#each ALL_CATEGORIES as cat (cat)}
|
|
||||||
<option value={cat}>{CATEGORY_LABELS[cat]}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="edit-custom-{link.id}" class={labelCls}>Custom Label</label>
|
|
||||||
<input
|
|
||||||
id="edit-custom-{link.id}"
|
|
||||||
name="customLabel"
|
|
||||||
type="text"
|
|
||||||
value={link.customLabel ?? ''}
|
|
||||||
class={inputCls}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="edit-desc-{link.id}" class={labelCls}>Description</label>
|
|
||||||
<textarea
|
|
||||||
id="edit-desc-{link.id}"
|
|
||||||
name="description"
|
|
||||||
rows="2"
|
|
||||||
class={inputCls}>{link.description ?? ''}</textarea
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={cancelEdit}
|
|
||||||
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-600"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
{:else}
|
||||||
<!-- My Bookmarks stub -->
|
{@render addSection(personalAddOpts)}
|
||||||
<div
|
{@render linkGrid(personalGridOpts)}
|
||||||
class="rounded-lg border border-dashed border-gray-300 bg-white p-10 text-center dark:border-gray-700 dark:bg-gray-800"
|
|
||||||
>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">Personal bookmarks are coming soon.</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user