- {comment.user.name}
-
+ {comment.user.name}
+
{formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })}
@@ -127,7 +124,7 @@
{:else}
-
+
{#if ready}
{@html renderMarkdown(comment.content)}
{:else}
@@ -156,7 +153,7 @@
{#if comment.userId === currentUserId}
startEdit(comment)}
- class="text-xs text-gray-400 hover:text-gray-600"
+ class="text-xs text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
>
Edit
@@ -164,7 +161,7 @@
{#if comment.userId === currentUserId || canEdit}
deleteComment(comment.id)}
- class="text-xs text-gray-400 hover:text-red-500"
+ class="text-xs text-gray-400 dark:text-gray-500 hover:text-red-500"
>
Delete
@@ -177,6 +174,6 @@
{#if localComments.length === 0}
-
No comments yet
+
No comments yet
{/if}
diff --git a/src/lib/components/DueDatePicker.svelte b/src/lib/components/DueDatePicker.svelte
index 12cc640..76f3264 100644
--- a/src/lib/components/DueDatePicker.svelte
+++ b/src/lib/components/DueDatePicker.svelte
@@ -1,5 +1,6 @@
-
Due Date
+
Due Date
{#if picking && canEdit}
setDate(e.currentTarget.value)}
- class="rounded border border-gray-300 px-2 py-1 text-sm"
+ class="rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-2 py-1 text-sm"
disabled={saving}
/>
(picking = false)}
- class="text-xs text-gray-500 hover:text-gray-700"
+ class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
>
Cancel
@@ -66,8 +66,9 @@
{#if canEdit}
(picking = true)}
- class="text-xs text-gray-400 hover:text-gray-600"
+ class="text-xs text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
title="Change date"
+ aria-label="Change due date"
>
diff --git a/src/lib/components/KeyboardShortcuts.svelte b/src/lib/components/KeyboardShortcuts.svelte
new file mode 100644
index 0000000..2338add
--- /dev/null
+++ b/src/lib/components/KeyboardShortcuts.svelte
@@ -0,0 +1,89 @@
+
+
+
+
+{#if showHelp}
+
+
{ if (e.target === e.currentTarget) showHelp = false; }}
+ >
+
+
+
Keyboard Shortcuts
+
(showHelp = false)}
+ class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition p-1"
+ aria-label="Close"
+ >
+
+
+
+
+ {#each shortcuts as s}
+
+ {s.desc}
+ {s.key}
+
+ {/each}
+
+
+
+{/if}
diff --git a/src/lib/components/MarkdownEditor.svelte b/src/lib/components/MarkdownEditor.svelte
index fd6a876..0252fde 100644
--- a/src/lib/components/MarkdownEditor.svelte
+++ b/src/lib/components/MarkdownEditor.svelte
@@ -42,31 +42,31 @@
(previewing = false)}
>
Write
(previewing = true)}
>
Preview
{#if previewing}
-
+
{#if ready}
{@html renderMarkdown(draft)}
{:else}
-
{draft}
+
{draft}
{/if}
{:else}
{/if}
@@ -79,7 +79,7 @@
Cancel
@@ -88,7 +88,7 @@
{:else}
{ if (!readonly) editing = true; }}
>
{#if value}
@@ -96,13 +96,13 @@
{#if ready}
{@html renderMarkdown(value)}
{:else}
-
{value}
+
{value}
{/if}
{:else if !readonly}
-
Add a more detailed description...
+
Add a more detailed description...
{:else}
-
No description
+
No description
{/if}
{/if}
diff --git a/src/lib/components/NotificationBell.svelte b/src/lib/components/NotificationBell.svelte
index e9db29d..3629124 100644
--- a/src/lib/components/NotificationBell.svelte
+++ b/src/lib/components/NotificationBell.svelte
@@ -1,6 +1,7 @@
+
+
diff --git a/src/lib/components/TagPicker.svelte b/src/lib/components/TagPicker.svelte
index 2e6555f..07a27fc 100644
--- a/src/lib/components/TagPicker.svelte
+++ b/src/lib/components/TagPicker.svelte
@@ -1,4 +1,6 @@
-
Labels
+
Labels
{#each currentLabels as label}
+
@@ -127,15 +122,15 @@
class="fixed inset-0 z-10"
onclick={() => (showDropdown = false)}
>
-
+
{#if loading}
-
Loading...
+
Loading...
{:else}
{#each allTags as tag}
toggleTag(tag)}
- class="w-full flex items-center gap-2 rounded px-2 py-1 text-sm hover:bg-gray-50 {isApplied(tag.id) ? 'bg-blue-50' : ''}"
+ class="w-full flex items-center gap-2 rounded px-2 py-1 text-sm hover:bg-gray-50 dark:hover:bg-gray-700 {isApplied(tag.id) ? 'bg-blue-50 dark:bg-blue-900/30' : ''}"
>
{tag.name}
@@ -148,7 +143,7 @@
{/each}
-
Create tag
+
Create tag
{#each presetColors as color}
+ import { fly } from 'svelte/transition';
+ import { getToasts, removeToast, type ToastType } from '$lib/stores/toasts.js';
+
+ const toasts = $derived(getToasts());
+
+ function typeClasses(type: ToastType): string {
+ switch (type) {
+ case 'success':
+ return 'bg-green-600 text-white';
+ case 'error':
+ return 'bg-red-600 text-white';
+ case 'warning':
+ return 'bg-yellow-500 text-white';
+ case 'info':
+ return 'bg-blue-600 text-white';
+ }
+ }
+
+ function typeIcon(type: ToastType): string {
+ switch (type) {
+ case 'success':
+ return 'M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z';
+ case 'error':
+ return 'M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z';
+ case 'warning':
+ return 'M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z';
+ case 'info':
+ return 'M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z';
+ }
+ }
+
+
+
+ {#each toasts as t (t.id)}
+
+
+
{t.message}
+
removeToast(t.id)}
+ class="flex-shrink-0 opacity-70 hover:opacity-100 transition"
+ aria-label="Dismiss"
+ >
+
+
+
+ {/each}
+
diff --git a/src/lib/stores/theme.ts b/src/lib/stores/theme.ts
new file mode 100644
index 0000000..059edd4
--- /dev/null
+++ b/src/lib/stores/theme.ts
@@ -0,0 +1,28 @@
+import { browser } from '$app/environment';
+
+let currentTheme = $state<'light' | 'dark'>('light');
+
+export function getTheme(): 'light' | 'dark' {
+ return currentTheme;
+}
+
+export function setTheme(theme: 'light' | 'dark') {
+ currentTheme = theme;
+ if (browser) {
+ document.documentElement.classList.toggle('dark', theme === 'dark');
+ localStorage.setItem('theme', theme);
+ document.cookie = `theme=${theme};path=/;max-age=31536000;SameSite=Lax`;
+ }
+}
+
+export function toggleTheme() {
+ setTheme(currentTheme === 'dark' ? 'light' : 'dark');
+}
+
+export function initTheme(serverTheme?: string) {
+ const theme = serverTheme || (browser ? localStorage.getItem('theme') : null) || 'light';
+ currentTheme = theme === 'dark' ? 'dark' : 'light';
+ if (browser) {
+ document.documentElement.classList.toggle('dark', currentTheme === 'dark');
+ }
+}
diff --git a/src/lib/stores/toasts.ts b/src/lib/stores/toasts.ts
new file mode 100644
index 0000000..2153618
--- /dev/null
+++ b/src/lib/stores/toasts.ts
@@ -0,0 +1,35 @@
+let nextId = 0;
+
+export type ToastType = 'success' | 'error' | 'warning' | 'info';
+
+export type Toast = {
+ id: number;
+ type: ToastType;
+ message: string;
+};
+
+let toasts = $state([]);
+
+export function getToasts(): Toast[] {
+ return toasts;
+}
+
+export function addToast(type: ToastType, message: string, duration = 4000): number {
+ const id = nextId++;
+ toasts.push({ id, type, message });
+ if (duration > 0) {
+ setTimeout(() => removeToast(id), duration);
+ }
+ return id;
+}
+
+export function removeToast(id: number) {
+ toasts = toasts.filter((t) => t.id !== id);
+}
+
+export const toast = {
+ success: (msg: string, duration?: number) => addToast('success', msg, duration),
+ error: (msg: string, duration?: number) => addToast('error', msg, duration ?? 6000),
+ warning: (msg: string, duration?: number) => addToast('warning', msg, duration),
+ info: (msg: string, duration?: number) => addToast('info', msg, duration)
+};
diff --git a/src/lib/utils/api.ts b/src/lib/utils/api.ts
new file mode 100644
index 0000000..6df6bce
--- /dev/null
+++ b/src/lib/utils/api.ts
@@ -0,0 +1,47 @@
+import { toast } from '$lib/stores/toasts.js';
+import { getSocketId } from '$lib/stores/socket.js';
+
+export type ApiResult = {
+ ok: boolean;
+ data: T;
+ status: number;
+};
+
+export async function api(
+ url: string,
+ opts: RequestInit = {}
+): Promise> {
+ const headers = new Headers(opts.headers);
+
+ // Auto-set JSON content type for non-FormData bodies
+ if (opts.body && !(opts.body instanceof FormData) && !headers.has('Content-Type')) {
+ headers.set('Content-Type', 'application/json');
+ }
+
+ // Pass socket ID for self-dedup
+ const sid = getSocketId();
+ if (sid) {
+ headers.set('X-Socket-ID', sid);
+ }
+
+ try {
+ const res = await fetch(url, { ...opts, headers });
+ let data: any = null;
+
+ const contentType = res.headers.get('content-type');
+ if (contentType?.includes('application/json')) {
+ data = await res.json();
+ }
+
+ if (!res.ok) {
+ const msg = data?.error || data?.message || `Request failed (${res.status})`;
+ toast.error(msg);
+ return { ok: false, data, status: res.status };
+ }
+
+ return { ok: true, data, status: res.status };
+ } catch {
+ toast.error('Network error. Please try again.');
+ return { ok: false, data: null as any, status: 0 };
+ }
+}
diff --git a/src/lib/utils/focus-trap.ts b/src/lib/utils/focus-trap.ts
new file mode 100644
index 0000000..5ef5e55
--- /dev/null
+++ b/src/lib/utils/focus-trap.ts
@@ -0,0 +1,42 @@
+const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
+
+export function trapFocus(node: HTMLElement) {
+ // Focus first focusable element
+ const focusFirst = () => {
+ const first = node.querySelector(FOCUSABLE);
+ first?.focus();
+ };
+
+ // Small delay to allow DOM to settle
+ requestAnimationFrame(focusFirst);
+
+ function handleKeydown(e: KeyboardEvent) {
+ if (e.key !== 'Tab') return;
+
+ const focusable = Array.from(node.querySelectorAll(FOCUSABLE));
+ if (focusable.length === 0) return;
+
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+
+ if (e.shiftKey) {
+ if (document.activeElement === first || !node.contains(document.activeElement)) {
+ e.preventDefault();
+ last.focus();
+ }
+ } else {
+ if (document.activeElement === last || !node.contains(document.activeElement)) {
+ e.preventDefault();
+ first.focus();
+ }
+ }
+ }
+
+ node.addEventListener('keydown', handleKeydown);
+
+ return {
+ destroy() {
+ node.removeEventListener('keydown', handleKeydown);
+ }
+ };
+}
diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte
new file mode 100644
index 0000000..64ef41a
--- /dev/null
+++ b/src/routes/+error.svelte
@@ -0,0 +1,38 @@
+
+
+
+ Error {$page.status}
+
+
+
+
+
{$page.status}
+
+ {#if $page.status === 404}
+ The page you're looking for doesn't exist.
+ {:else if $page.status === 403}
+ You don't have permission to access this page.
+ {:else if $page.status === 500}
+ Something went wrong on our end.
+ {:else}
+ {$page.error?.message || 'An unexpected error occurred.'}
+ {/if}
+
+
+
+ Go home
+
+
history.back()}
+ class="rounded-lg border border-gray-300 dark:border-gray-600 px-6 py-3 text-gray-700 dark:text-gray-300 font-medium hover:bg-gray-100 dark:hover:bg-gray-800 transition"
+ >
+ Go back
+
+
+
+
diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts
index 90f9fb2..4b94f2b 100644
--- a/src/routes/+layout.server.ts
+++ b/src/routes/+layout.server.ts
@@ -3,6 +3,7 @@ import type { LayoutServerLoad } from './$types.js';
export const load: LayoutServerLoad = async ({ locals }) => {
return {
user: locals.user,
- tenant: locals.tenant
+ tenant: locals.tenant,
+ theme: locals.theme
};
};
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 407f86f..3de3d46 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -3,12 +3,19 @@
import type { Snippet } from 'svelte';
import { onMount } from 'svelte';
import { connectSocket, disconnectSocket } from '$lib/stores/socket.js';
+ import { initTheme, toggleTheme, getTheme } from '$lib/stores/theme.js';
import NotificationBell from '$lib/components/NotificationBell.svelte';
import SearchBar from '$lib/components/SearchBar.svelte';
+ import Toast from '$lib/components/Toast.svelte';
+ import KeyboardShortcuts from '$lib/components/KeyboardShortcuts.svelte';
- let { children, data }: { children: Snippet; data: { user: any; tenant: any } } = $props();
+ let { children, data }: { children: Snippet; data: { user: any; tenant: any; theme?: string } } = $props();
+
+ let mobileMenuOpen = $state(false);
+ const theme = $derived(getTheme());
onMount(() => {
+ initTheme(data.theme);
if (data.user) {
connectSocket();
return () => disconnectSocket();
@@ -16,7 +23,7 @@
});
-
+