Add company links page with CRUD and Links nav tab
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
|
||||
const tabs = $derived([
|
||||
{ href: `/companies/${data.company.id}`, label: 'Overview' },
|
||||
{ href: `/companies/${data.company.id}/links`, label: 'Links' },
|
||||
{ href: `/companies/${data.company.id}/projects`, label: 'Projects' },
|
||||
{ href: `/companies/${data.company.id}/expenses`, label: 'Expenses' },
|
||||
{ href: `/companies/${data.company.id}/budget`, label: 'Budget' },
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { companyLinks, userCompanyLinks, users } from '$lib/server/db/schema.js';
|
||||
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
|
||||
import { logCompanyEvent } from '$lib/server/audit.js';
|
||||
import { validateLinkUrl, InvalidLinkUrlError } from '$lib/server/links/validate.js';
|
||||
import { and, asc, eq, isNull, sql } from 'drizzle-orm';
|
||||
|
||||
const CATEGORIES = [
|
||||
'internal_tool',
|
||||
'communication',
|
||||
'social_media',
|
||||
'analytics',
|
||||
'banking',
|
||||
'government',
|
||||
'storage',
|
||||
'marketing',
|
||||
'development',
|
||||
'website',
|
||||
'other'
|
||||
] as const;
|
||||
|
||||
type Category = (typeof CATEGORIES)[number];
|
||||
|
||||
function trimOrNull(v: FormDataEntryValue | null): string | null {
|
||||
const s = v?.toString().trim();
|
||||
return s ? s : null;
|
||||
}
|
||||
|
||||
function parseCategory(v: FormDataEntryValue | null): Category | null {
|
||||
const s = v?.toString();
|
||||
if (!s) return null;
|
||||
return (CATEGORIES as readonly string[]).includes(s) ? (s as Category) : null;
|
||||
}
|
||||
|
||||
function extractFields(fd: FormData): {
|
||||
title: string | null;
|
||||
url: string | null;
|
||||
category: Category | null;
|
||||
customLabel: string | null;
|
||||
description: string | null;
|
||||
} {
|
||||
return {
|
||||
title: trimOrNull(fd.get('title')),
|
||||
url: trimOrNull(fd.get('url')),
|
||||
category: parseCategory(fd.get('category')),
|
||||
customLabel: trimOrNull(fd.get('customLabel')),
|
||||
description: trimOrNull(fd.get('description'))
|
||||
};
|
||||
}
|
||||
|
||||
type OrderPayload = { id: string; sortOrder: number };
|
||||
|
||||
function parseOrderPayload(raw: FormDataEntryValue | null): OrderPayload[] | null {
|
||||
if (!raw) return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
const out: OrderPayload[] = [];
|
||||
for (const row of parsed) {
|
||||
if (!row || typeof row !== 'object') return null;
|
||||
const r = row as Record<string, unknown>;
|
||||
if (typeof r.id !== 'string' || typeof r.sortOrder !== 'number') return null;
|
||||
out.push({ id: r.id, sortOrder: r.sortOrder });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params, parent }) => {
|
||||
const { roles } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin',
|
||||
'manager',
|
||||
'user',
|
||||
'viewer',
|
||||
'hr',
|
||||
'accountant'
|
||||
]);
|
||||
await parent();
|
||||
|
||||
const companyRows = await db
|
||||
.select({
|
||||
id: companyLinks.id,
|
||||
category: companyLinks.category,
|
||||
customLabel: companyLinks.customLabel,
|
||||
title: companyLinks.title,
|
||||
url: companyLinks.url,
|
||||
description: companyLinks.description,
|
||||
faviconUrl: companyLinks.faviconUrl,
|
||||
faviconFetchedAt: companyLinks.faviconFetchedAt,
|
||||
sortOrder: companyLinks.sortOrder,
|
||||
createdAt: companyLinks.createdAt,
|
||||
updatedAt: companyLinks.updatedAt,
|
||||
createdByName: users.displayName,
|
||||
createdByEmail: users.email
|
||||
})
|
||||
.from(companyLinks)
|
||||
.leftJoin(users, eq(companyLinks.createdBy, users.id))
|
||||
.where(and(eq(companyLinks.companyId, params.companyId), isNull(companyLinks.deletedAt)))
|
||||
.orderBy(asc(companyLinks.category), asc(companyLinks.sortOrder), asc(companyLinks.createdAt));
|
||||
|
||||
const userId = locals.user?.id;
|
||||
const personalRows = userId
|
||||
? await db
|
||||
.select()
|
||||
.from(userCompanyLinks)
|
||||
.where(
|
||||
and(eq(userCompanyLinks.userId, userId), eq(userCompanyLinks.companyId, params.companyId))
|
||||
)
|
||||
.orderBy(
|
||||
asc(userCompanyLinks.category),
|
||||
asc(userCompanyLinks.sortOrder),
|
||||
asc(userCompanyLinks.createdAt)
|
||||
)
|
||||
: [];
|
||||
|
||||
const canManage = roles.some((r) => r === 'admin' || r === 'manager');
|
||||
|
||||
return {
|
||||
companyLinks: companyRows,
|
||||
personalLinks: personalRows,
|
||||
canManage
|
||||
};
|
||||
};
|
||||
|
||||
async function nextSortOrder(
|
||||
table: typeof companyLinks | typeof userCompanyLinks,
|
||||
scope: Record<string, unknown>
|
||||
): Promise<number> {
|
||||
const conditions = Object.entries(scope).map(([k, v]) =>
|
||||
eq(table[k as keyof typeof table] as never, v as never)
|
||||
);
|
||||
const [row] = await db
|
||||
.select({ max: sql<number>`coalesce(max(${table.sortOrder}), -1)::int` })
|
||||
.from(table)
|
||||
.where(and(...conditions));
|
||||
return (row?.max ?? -1) + 1;
|
||||
}
|
||||
|
||||
export const actions: Actions = {
|
||||
addCompanyLink: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']);
|
||||
const fd = await request.formData();
|
||||
const { title, url, category, customLabel, description } = extractFields(fd);
|
||||
|
||||
if (!title) return fail(400, { action: 'addCompanyLink', error: 'Title is required' });
|
||||
if (!category) return fail(400, { action: 'addCompanyLink', error: 'Invalid category' });
|
||||
if (!url) return fail(400, { action: 'addCompanyLink', 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: 'addCompanyLink', error: msg });
|
||||
}
|
||||
|
||||
const sortOrder = await nextSortOrder(companyLinks, {
|
||||
companyId: params.companyId,
|
||||
category
|
||||
});
|
||||
|
||||
const [inserted] = await db
|
||||
.insert(companyLinks)
|
||||
.values({
|
||||
companyId: params.companyId,
|
||||
category,
|
||||
customLabel,
|
||||
title,
|
||||
url: validUrl,
|
||||
description,
|
||||
sortOrder,
|
||||
createdBy: user.id
|
||||
})
|
||||
.returning({ id: companyLinks.id });
|
||||
|
||||
await logCompanyEvent(
|
||||
params.companyId,
|
||||
user.id,
|
||||
'link_added',
|
||||
`Link "${title}" added`,
|
||||
{ linkId: inserted.id, category, url: validUrl }
|
||||
);
|
||||
|
||||
return { success: true, action: 'addCompanyLink' };
|
||||
},
|
||||
|
||||
updateCompanyLink: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']);
|
||||
const fd = await request.formData();
|
||||
const id = trimOrNull(fd.get('id'));
|
||||
if (!id) return fail(400, { action: 'updateCompanyLink', error: 'Link id is required' });
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(companyLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(companyLinks.id, id),
|
||||
eq(companyLinks.companyId, params.companyId),
|
||||
isNull(companyLinks.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing) error(404, 'Link not found');
|
||||
|
||||
const { title, url, category, customLabel, description } = extractFields(fd);
|
||||
if (!title) return fail(400, { action: 'updateCompanyLink', error: 'Title is required' });
|
||||
if (!category) return fail(400, { action: 'updateCompanyLink', error: 'Invalid category' });
|
||||
if (!url) return fail(400, { action: 'updateCompanyLink', 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: 'updateCompanyLink', error: msg });
|
||||
}
|
||||
|
||||
const urlChanged = validUrl !== existing.url;
|
||||
await db
|
||||
.update(companyLinks)
|
||||
.set({
|
||||
title,
|
||||
url: validUrl,
|
||||
category,
|
||||
customLabel,
|
||||
description,
|
||||
faviconUrl: urlChanged ? null : existing.faviconUrl,
|
||||
faviconFetchedAt: urlChanged ? null : existing.faviconFetchedAt,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(companyLinks.id, id));
|
||||
|
||||
await logCompanyEvent(
|
||||
params.companyId,
|
||||
user.id,
|
||||
'link_updated',
|
||||
`Link "${title}" updated`,
|
||||
{ linkId: id, category }
|
||||
);
|
||||
|
||||
return { success: true, action: 'updateCompanyLink' };
|
||||
},
|
||||
|
||||
deleteCompanyLink: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']);
|
||||
const fd = await request.formData();
|
||||
const id = trimOrNull(fd.get('id'));
|
||||
if (!id) return fail(400, { action: 'deleteCompanyLink', error: 'Link id is required' });
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: companyLinks.id, title: companyLinks.title })
|
||||
.from(companyLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(companyLinks.id, id),
|
||||
eq(companyLinks.companyId, params.companyId),
|
||||
isNull(companyLinks.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing) error(404, 'Link not found');
|
||||
|
||||
await db
|
||||
.update(companyLinks)
|
||||
.set({ deletedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(companyLinks.id, id));
|
||||
|
||||
await logCompanyEvent(
|
||||
params.companyId,
|
||||
user.id,
|
||||
'link_deleted',
|
||||
`Link "${existing.title}" deleted`,
|
||||
{ linkId: id }
|
||||
);
|
||||
|
||||
return { success: true, action: 'deleteCompanyLink' };
|
||||
},
|
||||
|
||||
reorderCompanyLinks: async ({ request, locals, params }) => {
|
||||
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']);
|
||||
const fd = await request.formData();
|
||||
const payload = parseOrderPayload(fd.get('orders'));
|
||||
if (!payload) return fail(400, { action: 'reorderCompanyLinks', error: 'Invalid order payload' });
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const { id, sortOrder } of payload) {
|
||||
await tx
|
||||
.update(companyLinks)
|
||||
.set({ sortOrder, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(companyLinks.id, id),
|
||||
eq(companyLinks.companyId, params.companyId),
|
||||
isNull(companyLinks.deletedAt)
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true, action: 'reorderCompanyLinks' };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,494 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
type Tab = 'company' | 'personal';
|
||||
let activeTab = $state<Tab>('company');
|
||||
let activeCategory = $state<string>('all');
|
||||
let showAddForm = $state(false);
|
||||
let editingId = $state<string | null>(null);
|
||||
let confirmDeleteId = $state<string | null>(null);
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
internal_tool: 'Internal Tools',
|
||||
communication: 'Communication',
|
||||
social_media: 'Social Media',
|
||||
analytics: 'Analytics',
|
||||
banking: 'Banking',
|
||||
government: 'Government',
|
||||
storage: 'Storage',
|
||||
marketing: 'Marketing',
|
||||
development: 'Development',
|
||||
website: 'Website',
|
||||
other: 'Other'
|
||||
};
|
||||
|
||||
const CATEGORY_BADGE: Record<string, string> = {
|
||||
internal_tool: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
communication: 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300',
|
||||
social_media: 'bg-pink-100 text-pink-700 dark:bg-pink-900/40 dark:text-pink-300',
|
||||
analytics: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
||||
banking: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||
government: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
storage: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300',
|
||||
marketing: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300',
|
||||
development: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300',
|
||||
website: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-300',
|
||||
other: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||
};
|
||||
|
||||
const CATEGORY_CHIP: Record<string, string> = {
|
||||
internal_tool: 'bg-blue-500',
|
||||
communication: 'bg-sky-500',
|
||||
social_media: 'bg-pink-500',
|
||||
analytics: 'bg-purple-500',
|
||||
banking: 'bg-emerald-500',
|
||||
government: 'bg-amber-500',
|
||||
storage: 'bg-teal-500',
|
||||
marketing: 'bg-orange-500',
|
||||
development: 'bg-indigo-500',
|
||||
website: 'bg-cyan-500',
|
||||
other: 'bg-gray-500'
|
||||
};
|
||||
|
||||
const ALL_CATEGORIES = Object.keys(CATEGORY_LABELS);
|
||||
|
||||
const currentLinks = $derived(
|
||||
activeTab === 'company' ? data.companyLinks : data.personalLinks
|
||||
);
|
||||
|
||||
const filteredLinks = $derived(
|
||||
activeCategory === 'all'
|
||||
? currentLinks
|
||||
: currentLinks.filter((l) => l.category === activeCategory)
|
||||
);
|
||||
|
||||
const categoriesPresent = $derived([...new Set(currentLinks.map((l) => l.category))]);
|
||||
|
||||
function firstLetter(title: string): string {
|
||||
const t = title.trim();
|
||||
return (t[0] || '?').toUpperCase();
|
||||
}
|
||||
|
||||
function hostnameOf(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
confirmDeleteId = null;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId = null;
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
showAddForm = true;
|
||||
editingId = null;
|
||||
confirmDeleteId = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Links - {data.company.name}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<header>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Links</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Internal tools, dashboards, banking portals, social handles, and anything else the team needs
|
||||
quick access to. Company links are curated by admin/manager; personal bookmarks are private to
|
||||
you.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if form?.error}
|
||||
<div
|
||||
class="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300"
|
||||
>
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-1 border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
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 ===
|
||||
'company'
|
||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-gray-600 dark:hover:text-gray-300'}"
|
||||
>
|
||||
Company Links ({data.companyLinks.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
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 ===
|
||||
'personal'
|
||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-gray-600 dark:hover:text-gray-300'}"
|
||||
>
|
||||
My Bookmarks ({data.personalLinks.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Category filter pills -->
|
||||
{#if currentLinks.length > 0}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (activeCategory = 'all')}
|
||||
class="rounded-full px-3 py-1 text-xs font-medium {activeCategory === 'all'
|
||||
? 'bg-gray-900 text-white dark:bg-white dark:text-gray-900'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{#each categoriesPresent as cat (cat)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (activeCategory = cat)}
|
||||
class="rounded-full px-3 py-1 text-xs font-medium {activeCategory === cat
|
||||
? 'bg-gray-900 text-white dark:bg-white dark:text-gray-900'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}"
|
||||
>
|
||||
{CATEGORY_LABELS[cat] ?? cat}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if activeTab === 'company'}
|
||||
<!-- Company Links -->
|
||||
{#if data.canManage}
|
||||
<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}
|
||||
<!-- My Bookmarks stub -->
|
||||
<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">Personal bookmarks are coming soon.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user