From d8bbfd9dc337c1e81decd51cbce9cdc12196773b Mon Sep 17 00:00:00 2001 From: AlacrisDevs Date: Fri, 6 Feb 2026 16:08:40 +0200 Subject: [PATCH] Mega push vol 4 --- .env.example | 4 +- AUDIT.md | 666 +++++++ package-lock.json | 31 +- package.json | 3 +- src/app.d.ts | 7 +- src/demo.spec.ts | 7 - src/hooks.client.ts | 19 + src/hooks.server.ts | 30 +- src/lib/api/calendar.ts | 32 +- src/lib/api/document-locks.ts | 152 ++ src/lib/api/documents.ts | 53 +- src/lib/api/kanban.ts | 151 +- src/lib/api/organizations.ts | 52 +- src/lib/components/calendar/Calendar.svelte | 333 ++-- .../documents/DocumentViewer.svelte | 105 ++ src/lib/components/documents/Editor.svelte | 47 +- .../components/documents/FileBrowser.svelte | 874 +++++++++ src/lib/components/documents/FileTree.svelte | 253 --- src/lib/components/documents/index.ts | 3 +- .../components/kanban/CardChecklist.svelte | 170 ++ src/lib/components/kanban/CardComments.svelte | 159 ++ .../components/kanban/CardDetailModal.svelte | 364 ++-- src/lib/components/kanban/CardMetadata.svelte | 90 + src/lib/components/kanban/KanbanBoard.svelte | 331 ++-- src/lib/components/kanban/KanbanCard.svelte | 208 ++- src/lib/components/kanban/index.ts | 3 + .../settings/SettingsGeneral.svelte | 216 +++ src/lib/components/settings/index.ts | 1 + src/lib/components/ui/AssigneePicker.svelte | 108 ++ src/lib/components/ui/Avatar.svelte | 101 +- src/lib/components/ui/Badge.svelte | 38 +- src/lib/components/ui/Button.svelte | 109 +- src/lib/components/ui/CalendarDay.svelte | 35 + src/lib/components/ui/Chip.svelte | 26 + src/lib/components/ui/ContentHeader.svelte | 42 + src/lib/components/ui/Dropdown.svelte | 64 + src/lib/components/ui/DropdownItem.svelte | 31 + src/lib/components/ui/EmptyState.svelte | 29 + src/lib/components/ui/Icon.svelte | 16 + src/lib/components/ui/IconButton.svelte | 59 + src/lib/components/ui/Input.svelte | 128 +- src/lib/components/ui/KanbanColumn.svelte | 61 + src/lib/components/ui/ListItem.svelte | 60 + src/lib/components/ui/Logo.svelte | 39 + src/lib/components/ui/Modal.svelte | 45 +- src/lib/components/ui/OrgHeader.svelte | 30 + src/lib/components/ui/Select.svelte | 36 +- src/lib/components/ui/Skeleton.svelte | 72 + src/lib/components/ui/Textarea.svelte | 46 +- src/lib/components/ui/ToastContainer.svelte | 4 +- src/lib/components/ui/index.ts | 14 + src/lib/index.ts | 1 - src/lib/stores/auth.svelte.ts | 27 - src/lib/stores/documents.svelte.ts | 52 - src/lib/stores/index.ts | 2 - src/lib/stores/organizations.svelte.ts | 59 - src/lib/stores/theme.ts | 222 --- src/lib/stores/toast.svelte.ts | 83 + src/lib/stores/toast.ts | 48 - src/lib/supabase/index.ts | 1 - src/lib/supabase/server.ts | 19 - src/lib/supabase/types.ts | 1563 ++++++++++++----- src/lib/utils/logger.ts | 207 +++ src/routes/+error.svelte | 82 + src/routes/+page.svelte | 12 +- src/routes/[orgSlug]/+layout.server.ts | 93 +- src/routes/[orgSlug]/+layout.svelte | 276 +-- src/routes/[orgSlug]/+page.svelte | 318 +--- src/routes/[orgSlug]/calendar/+page.server.ts | 9 +- src/routes/[orgSlug]/calendar/+page.svelte | 94 +- .../[orgSlug]/documents/+page.server.ts | 11 +- src/routes/[orgSlug]/documents/+page.svelte | 334 +--- .../[orgSlug]/documents/[id]/+page.server.ts | 31 + .../[orgSlug]/documents/[id]/+page.svelte | 9 + .../documents/file/[id]/+page.server.ts | 39 + .../documents/file/[id]/+page.svelte | 572 ++++++ .../documents/folder/[id]/+page.server.ts | 43 + .../documents/folder/[id]/+page.svelte | 34 + src/routes/[orgSlug]/kanban/+page.server.ts | 9 +- src/routes/[orgSlug]/kanban/+page.svelte | 249 ++- src/routes/[orgSlug]/settings/+page.server.ts | 93 +- src/routes/[orgSlug]/settings/+page.svelte | 701 +++----- .../api/google-calendar/events/+server.ts | 35 +- src/routes/auth/callback/+server.ts | 7 +- src/routes/invite/[token]/+page.server.ts | 12 +- src/routes/invite/[token]/+page.svelte | 7 +- src/routes/layout.css | 184 +- src/routes/login/+page.svelte | 243 ++- src/routes/page.svelte.spec.ts | 13 - src/routes/style/+page.svelte | 327 +++- supabase/migrations/013_kanban_labels.sql | 66 + .../migrations/014_document_enhancements.sql | 101 ++ .../015_migrate_kanban_to_documents.sql | 45 + supabase/migrations/016_document_locks.sql | 41 + supabase/migrations/017_avatars_storage.sql | 28 + 95 files changed, 8016 insertions(+), 3943 deletions(-) create mode 100644 AUDIT.md delete mode 100644 src/demo.spec.ts create mode 100644 src/hooks.client.ts create mode 100644 src/lib/api/document-locks.ts create mode 100644 src/lib/components/documents/DocumentViewer.svelte create mode 100644 src/lib/components/documents/FileBrowser.svelte delete mode 100644 src/lib/components/documents/FileTree.svelte create mode 100644 src/lib/components/kanban/CardChecklist.svelte create mode 100644 src/lib/components/kanban/CardComments.svelte create mode 100644 src/lib/components/kanban/CardMetadata.svelte create mode 100644 src/lib/components/settings/SettingsGeneral.svelte create mode 100644 src/lib/components/settings/index.ts create mode 100644 src/lib/components/ui/AssigneePicker.svelte create mode 100644 src/lib/components/ui/CalendarDay.svelte create mode 100644 src/lib/components/ui/Chip.svelte create mode 100644 src/lib/components/ui/ContentHeader.svelte create mode 100644 src/lib/components/ui/Dropdown.svelte create mode 100644 src/lib/components/ui/DropdownItem.svelte create mode 100644 src/lib/components/ui/EmptyState.svelte create mode 100644 src/lib/components/ui/Icon.svelte create mode 100644 src/lib/components/ui/IconButton.svelte create mode 100644 src/lib/components/ui/KanbanColumn.svelte create mode 100644 src/lib/components/ui/ListItem.svelte create mode 100644 src/lib/components/ui/Logo.svelte create mode 100644 src/lib/components/ui/OrgHeader.svelte create mode 100644 src/lib/components/ui/Skeleton.svelte delete mode 100644 src/lib/index.ts delete mode 100644 src/lib/stores/auth.svelte.ts delete mode 100644 src/lib/stores/documents.svelte.ts delete mode 100644 src/lib/stores/index.ts delete mode 100644 src/lib/stores/organizations.svelte.ts delete mode 100644 src/lib/stores/theme.ts create mode 100644 src/lib/stores/toast.svelte.ts delete mode 100644 src/lib/stores/toast.ts delete mode 100644 src/lib/supabase/server.ts create mode 100644 src/lib/utils/logger.ts create mode 100644 src/routes/+error.svelte create mode 100644 src/routes/[orgSlug]/documents/[id]/+page.server.ts create mode 100644 src/routes/[orgSlug]/documents/[id]/+page.svelte create mode 100644 src/routes/[orgSlug]/documents/file/[id]/+page.server.ts create mode 100644 src/routes/[orgSlug]/documents/file/[id]/+page.svelte create mode 100644 src/routes/[orgSlug]/documents/folder/[id]/+page.server.ts create mode 100644 src/routes/[orgSlug]/documents/folder/[id]/+page.svelte delete mode 100644 src/routes/page.svelte.spec.ts create mode 100644 supabase/migrations/013_kanban_labels.sql create mode 100644 supabase/migrations/014_document_enhancements.sql create mode 100644 supabase/migrations/015_migrate_kanban_to_documents.sql create mode 100644 supabase/migrations/016_document_locks.sql create mode 100644 supabase/migrations/017_avatars_storage.sql diff --git a/.env.example b/.env.example index 429126a..de3201c 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -VITE_SUPABASE_URL=your_supabase_url -VITE_SUPABASE_ANON_KEY=your_supabase_anon_key +PUBLIC_SUPABASE_URL=your_supabase_url +PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key GOOGLE_API_KEY=your_google_api_key diff --git a/AUDIT.md b/AUDIT.md new file mode 100644 index 0000000..f92ea16 --- /dev/null +++ b/AUDIT.md @@ -0,0 +1,666 @@ +# Comprehensive Codebase Audit Report (v2) + +**Project:** root-org (SvelteKit + Supabase + Tailwind v4) +**Date:** 2026-02-06 (updated) +**Auditor:** Cascade + +> **Changes since v1:** Dead stores (auth, organizations, documents, kanban, theme) deleted. `OrgWithRole` moved to `$lib/api/organizations.ts`. `FileTree` removed. Documents pages refactored into shared `FileBrowser` component. Document locking added (`document-locks` API + migration). Calendar `$derived` bugs fixed. `buildDocumentTree`/`DocumentWithChildren` removed. Editor CSS typo fixed. Invite page routes corrected. KanbanBoard button label fixed. + +--- + +## 1. Security + +### S-1 · **CRITICAL** — Real credentials committed to `.env` in git + +**File:** `.env:1-4` + +``` +PUBLIC_SUPABASE_URL=https://zlworzrghsrokdkuckez.supabase.co +PUBLIC_SUPABASE_ANON_KEY=sb_publishable_UDoCgcmpUeE5d-jocBSdVw_TWzDxK3x +GOOGLE_API_KEY=AIzaSyAn2LnXkgwyLcTHQPt3nbFhBwnYWosmMT0 +``` + +**Problem:** The `.env` file contains real Supabase and Google API keys and is tracked by git. Anyone with repo access (or if the repo is public) has full access to your Supabase project and Google API quota. + +**Fix:** +1. Add `.env` to `.gitignore` (it's already there — but the file was committed before the gitignore rule was added). +2. Run `git rm --cached .env` to untrack it. +3. **Rotate all three keys immediately** — they are compromised the moment they entered git history. +4. Use `git filter-branch` or `git-filter-repo` to purge `.env` from history. + +--- + +### S-2 · **CRITICAL** — Google Calendar API route has no authentication check + +**File:** `src/routes/api/google-calendar/events/+server.ts:7-9` + +```ts +export const GET: RequestHandler = async ({ url, locals }) => { + const orgId = url.searchParams.get('org_id'); +``` + +**Problem:** The endpoint accepts any `org_id` parameter and fetches calendar data without verifying the requesting user is a member of that org. An unauthenticated user can enumerate org IDs and read all connected Google Calendar events. + +**Fix:** Add session + membership check: +```ts +const { session, user } = await locals.safeGetSession(); +if (!session || !user) return json({ error: 'Unauthorized' }, { status: 401 }); + +const { data: membership } = await locals.supabase + .from('org_members') + .select('id') + .eq('org_id', orgId) + .eq('user_id', user.id) + .single(); + +if (!membership) return json({ error: 'Forbidden' }, { status: 403 }); +``` + +--- + +### S-3 · **HIGH** — Auth callback open redirect vulnerability + +**File:** `src/routes/auth/callback/+server.ts:6` + +```ts +const next = url.searchParams.get('next') ?? url.searchParams.get('redirect') ?? '/'; +``` + +**Problem:** The `next`/`redirect` parameter is used directly in `redirect(303, next)` without validating it's a relative URL. An attacker can craft `?next=https://evil.com` to redirect users after login. + +**Fix:** Validate the redirect target is a relative path: +```ts +function safeRedirect(target: string): string { + if (target.startsWith('/') && !target.startsWith('//')) return target; + return '/'; +} +const next = safeRedirect(url.searchParams.get('next') ?? url.searchParams.get('redirect') ?? '/'); +``` + +--- + +### S-4 · **HIGH** — Settings page performs destructive operations without server-side authorization + +**File:** `src/routes/[orgSlug]/settings/+page.svelte:186-463` + +**Problem:** All settings mutations (update org, delete org, invite/remove members, manage roles, connect calendar) are done via direct Supabase client calls from the browser. The only server-side check is the page load guard (`owner`/`admin`). If RLS policies are not perfectly configured, any authenticated user could call these mutations directly. + +**Fix:** Move destructive operations to SvelteKit form actions or API routes with explicit server-side authorization checks, rather than relying solely on Supabase RLS. + +--- + +### S-5 · **MEDIUM** — `.env.example` uses wrong variable prefix + +**File:** `.env.example:1-2` + +``` +VITE_SUPABASE_URL=your_supabase_url +VITE_SUPABASE_ANON_KEY=your_supabase_anon_key +``` + +**Problem:** The example uses `VITE_` prefix but the actual app uses `PUBLIC_` prefix (SvelteKit convention). A developer copying `.env.example` would have a broken setup. + +**Fix:** Update `.env.example`: +``` +PUBLIC_SUPABASE_URL=your_supabase_url +PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key +GOOGLE_API_KEY=your_google_api_key +``` + +--- + +### S-6 · **MEDIUM** — Document lock RLS allows any user to delete expired locks + +**File:** `supabase/migrations/016_document_locks.sql:40-41` + +```sql +CREATE POLICY "Anyone can delete expired locks" ON document_locks FOR DELETE + USING (last_heartbeat < now() - interval '60 seconds'); +``` + +**Problem:** Any authenticated user can delete any expired lock. While the intent is correct (cleanup), this could be exploited to race-condition a lock takeover. The `acquireLock` function in `document-locks.ts:75-79` also deletes expired locks client-side, creating two competing cleanup paths. + +**Fix:** Remove the client-side expired lock cleanup from `acquireLock()` and rely solely on the RLS policy, or vice versa. Consider using a server-side cron/function for lock cleanup instead. + +--- + +## 2. Dead & Unused Code + +### ~~D-1~~ RESOLVED — Dead stores deleted +### ~~D-4~~ RESOLVED — `FileTree` removed +### ~~D-5~~ RESOLVED — `$lib/stores/index.ts` and `auth.svelte.ts` deleted + +### D-2 · **HIGH** — `$lib/utils/api-helpers.ts` is never imported + +**File:** `src/lib/utils/api-helpers.ts` (96 lines) + +**Problem:** `unwrap()` and `safeCall()` are well-designed helpers but are never used anywhere. All API modules use manual `if (error) throw error` patterns instead. + +**Fix:** Either delete the file, or migrate API modules to use `unwrap()` (recommended — see finding A-2). + +--- + +### D-3 · **HIGH** — Entire `layout/` component directory is never imported + +**Files:** +- `src/lib/components/layout/PageContainer.svelte` +- `src/lib/components/layout/PageHeader.svelte` +- `src/lib/components/layout/ResponsiveGrid.svelte` +- `src/lib/components/layout/SplitPane.svelte` +- `src/lib/components/layout/index.ts` + +**Problem:** Zero imports of any layout component anywhere in the codebase. + +**Fix:** Delete the entire `src/lib/components/layout/` directory. + +--- + +### D-6 · **MEDIUM** — Unused variables in kanban page + +**File:** `src/routes/[orgSlug]/kanban/+page.svelte:48-49,107` + +```ts +let newBoardVisibility = $state<"team" | "personal">("team"); +let editBoardVisibility = $state<"team" | "personal">("team"); +let sidebarCollapsed = $state(false); +``` + +**Problem:** These three reactive variables are declared but never read or written to anywhere in the template or logic. + +**Fix:** Remove all three declarations. + +--- + +### D-7 · **MEDIUM** — `$lib/index.ts` is an empty placeholder + +**File:** `src/lib/index.ts` + +**Problem:** Empty file with only a comment. Serves no purpose. + +**Fix:** Delete the file. + +--- + +### D-8 · **MEDIUM** — `$lib/supabase/server.ts` is never imported + +**File:** `src/lib/supabase/server.ts` (20 lines) + +**Problem:** Re-exported as `createServerClient` from `$lib/supabase/index.ts`, but never imported anywhere. All server routes use `locals.supabase` from `hooks.server.ts`. + +**Fix:** Delete `src/lib/supabase/server.ts` and remove the re-export from `src/lib/supabase/index.ts`. + +--- + +### D-9 · **LOW** — Demo test and scaffold test are placeholders + +**Files:** +- `src/demo.spec.ts` — `it('adds 1 + 2 to equal 3')` +- `src/routes/page.svelte.spec.ts` + +**Fix:** Delete or replace with real smoke tests. + +--- + +## 3. Type Safety & Correctness + +### T-1 · **HIGH** — Supabase types are stale, causing 66 `as any` casts + +**Files:** 15 files with `as any` casts, concentrated in: +- `src/lib/components/kanban/CardDetailModal.svelte` (9) +- `src/lib/api/document-locks.ts` (6) +- `src/lib/components/documents/FileBrowser.svelte` (6) +- `src/routes/[orgSlug]/documents/[id]/+page.server.ts` (6) +- `src/routes/[orgSlug]/documents/file/[id]/+page.server.ts` (6) +- `src/routes/[orgSlug]/documents/folder/[id]/+page.server.ts` (6) +- `src/routes/[orgSlug]/documents/file/[id]/+page.svelte` (5) +- `src/lib/components/kanban/CardComments.svelte` (4) +- `src/lib/components/kanban/KanbanBoard.svelte` (4) +- `src/routes/api/google-calendar/events/+server.ts` (4) + +**Problem:** The `Database` type in `src/lib/supabase/types.ts` is out of sync with the actual database schema. Missing tables (`document_locks`, `org_google_calendars`, `org_invites`, `org_roles`, `activity_log`), missing columns on existing tables, and incorrect optionality force `as any` everywhere. + +**Fix:** Regenerate types with `supabase gen types typescript --project-id > src/lib/supabase/types.ts`. This single fix will eliminate most of the 66 `as any` casts. + +--- + +### T-2 · **MEDIUM** — `user: any` in root page Props + +**File:** `src/routes/+page.svelte:19` + +```ts +user: any; // ← should be typed +``` + +**Fix:** Use `import type { User } from '@supabase/supabase-js'` and type as `user: User | null`. + +--- + +### T-3 · **MEDIUM** — Local `OrgWithRole` interface duplicates the API type + +**File:** `src/routes/+page.svelte:9-14` + +```ts +interface OrgWithRole { + id: string; name: string; slug: string; role: string; +} +``` + +**Problem:** This is a simplified duplicate of `$lib/api/organizations.OrgWithRole` (which now extends `Organization`). The server load returns full org fields, but this interface drops `avatar_url`, `created_at`, `updated_at`. + +**Fix:** Import and use the canonical type: `import type { OrgWithRole } from '$lib/api/organizations'`. + +--- + +### T-4 · **MEDIUM** — `org` from parent layout is untyped, causing `(org as any).id` everywhere + +**Files:** All `+page.server.ts` files under `[orgSlug]/documents/`, `[orgSlug]/settings/` + +**Problem:** The parent layout returns `org` as the raw Supabase row, but child loads access it as `(org as any).id`, `(org as any).slug` because TypeScript can't infer the parent return type. + +**Fix:** Create a shared type for the org layout data: +```ts +// $lib/types/layout.ts +export interface OrgLayoutData { + org: Organization; + role: string; + userRole: string; + user: User; + members: any[]; + recentActivity: any[]; +} +``` +Then use it in child loads: `const { org } = await parent() as OrgLayoutData;` + +--- + +### T-5 · **MEDIUM** — `folder: any` in folder page Props + +**File:** `src/routes/[orgSlug]/documents/folder/[id]/+page.svelte:8,17,21` + +```ts +folder: any; +const currentFolderId = $derived((data.folder as any).id as string); +{(data.folder as any).name} +``` + +**Problem:** The folder prop is typed as `any`, requiring double casts. This would be resolved by T-1 (regenerating Supabase types) combined with T-4 (typing parent data). + +--- + +## 4. Code Duplication & Redundancy + +### ~~R-1~~ RESOLVED — `FileBrowser` component extracted, both pages are now thin wrappers (~30 lines each) + +### R-2 · **MEDIUM** — Server loads for documents still repeat the same `as any` pattern + +**Files:** +- `src/routes/[orgSlug]/documents/+page.server.ts` +- `src/routes/[orgSlug]/documents/folder/[id]/+page.server.ts` +- `src/routes/[orgSlug]/documents/file/[id]/+page.server.ts` +- `src/routes/[orgSlug]/documents/[id]/+page.server.ts` + +**Problem:** All four files repeat: get parent data, cast `org as any`, query documents table, handle errors. The `as any` casts are identical across all four. + +**Fix:** Create a shared `getOrgFromParent(parent)` utility that types the parent data once. Resolving T-1 + T-4 would also eliminate this. + +--- + +### R-3 · **MEDIUM** — `$lib/supabase/server.ts` duplicates `hooks.server.ts` + +**Problem:** `server.ts` creates a server Supabase client from cookies, but `hooks.server.ts` already does this and puts it on `locals.supabase`. The `server.ts` export is never imported. + +**Fix:** Delete `src/lib/supabase/server.ts` and remove the `createServerClient` re-export from `src/lib/supabase/index.ts`. + +--- + +### R-4 · **LOW** — `role` and `userRole` are the same value returned from org layout + +**File:** `src/routes/[orgSlug]/+layout.server.ts:74-75` + +```ts +role: membership.role, +userRole: membership.role, // kept for backwards compat — same as role +``` + +**Problem:** Both contain `membership.role`. The layout uses `data.role`, child pages use `data.userRole`. The comment acknowledges the duplication. + +**Fix:** Migrate all consumers to `userRole` and remove `role`, or vice versa. + +--- + +## 5. Error Handling & Resilience + +### E-1 · **HIGH** — Settings page uses `alert()` and swallows errors + +**File:** `src/routes/[orgSlug]/settings/+page.svelte` + +- Line 234: `alert("Failed to send invite: " + error.message)` +- Line 447: `alert("Owners cannot leave...")` +- Lines 240, 274, 357, 426, 440: No error handling on `await supabase.from(...).delete()` — errors are silently ignored. + +**Fix:** Use the existing `toasts` store for user feedback. Add error handling to all mutations: +```ts +const { error } = await supabase.from('org_members').delete().eq('id', memberId); +if (error) { toasts.error('Failed to remove member'); return; } +``` + +--- + +### E-2 · **HIGH** — `console.error` used instead of structured logger in 6 files + +**Files (11 instances):** +- `src/routes/api/google-calendar/events/+server.ts` — 4 instances (`console.error`, `console.log`) +- `src/routes/[orgSlug]/calendar/+page.svelte` — 3 instances +- `src/routes/invite/[token]/+page.svelte` — 2 instances +- `src/lib/api/google-calendar.ts` — 1 instance +- `src/routes/[orgSlug]/kanban/+page.svelte` — 1 instance + +**Problem:** The project has a well-designed `createLogger()` system but these modules bypass it with raw `console.*` calls, losing structured context, timestamps, and the ring buffer for error reports. + +**Fix:** Replace all `console.error/log/warn` with `createLogger()` calls. + +--- + +### E-3 · **MEDIUM** — Calendar page `handleDateClick` is a no-op + +**File:** `src/routes/[orgSlug]/calendar/+page.svelte:41-43` + +```ts +function handleDateClick(_date: Date) { + // Event creation disabled +} +``` + +**Fix:** Either implement event creation or remove the click handler from the Calendar component to avoid misleading UX. + +--- + +### E-4 · **MEDIUM** — `data` captured by value causes stale state in multiple pages + +**Files:** +- `src/routes/[orgSlug]/calendar/+page.svelte:26` — `let events = $state(data.events)` +- `src/routes/[orgSlug]/documents/+page.svelte:15` — `let documents = $state(data.documents)` +- `src/routes/[orgSlug]/documents/folder/[id]/+page.svelte:16` — `let documents = $state(data.documents)` +- `src/routes/[orgSlug]/kanban/+page.svelte:40` — `let boards = $state(data.boards)` +- `src/routes/+page.svelte:27` — `let organizations = $state(data.organizations)` + +**Problem:** Svelte warns: "This reference only captures the initial value of `data`." If `data` changes (e.g., via `invalidateAll()`), the local state won't update. + +**Fix:** Add `$effect` blocks to sync: +```ts +let events = $state(data.events); +$effect(() => { events = data.events; }); +``` + +--- + +### E-5 · **MEDIUM** — `releaseLock` called in `onDestroy` may not complete + +**File:** `src/routes/[orgSlug]/documents/file/[id]/+page.svelte:222-225` + +```ts +onDestroy(() => { + // ... + releaseLock(supabase, data.document.id, data.user.id); +}); +``` + +**Problem:** `releaseLock` is async but not awaited. In `onDestroy`, the component is being torn down and the async call may not complete before the page unloads (especially on navigation). The lock would then rely on heartbeat expiry (60s) to be cleaned up. + +**Fix:** Use `beforeunload` event for page unloads and `navigator.sendBeacon` for reliable cleanup: +```ts +window.addEventListener('beforeunload', () => { + navigator.sendBeacon(`/api/release-lock`, JSON.stringify({ documentId, userId })); +}); +``` + +--- + +## 6. Architecture & Structure + +### A-1 · **HIGH** — Settings page is a 1205-line god component + +**File:** `src/routes/[orgSlug]/settings/+page.svelte` (1205 lines) + +**Problem:** This single file handles 5 tabs (General, Members, Roles, Integrations, Appearance), each with their own state, modals, and API calls. It contains ~15 async functions, ~25 state variables, and 6 modals. + +**Fix:** Extract each tab into its own component: +- `SettingsGeneral.svelte` +- `SettingsMembers.svelte` +- `SettingsRoles.svelte` +- `SettingsIntegrations.svelte` +- `SettingsAppearance.svelte` + +--- + +### A-2 · **MEDIUM** — Inconsistent data fetching patterns + +**Problem:** The codebase uses three different patterns for Supabase calls: +1. **API modules** (`$lib/api/*.ts`) — used by kanban page, org overview, document locks +2. **Direct `supabase.from()` in components** — used by settings, FileBrowser, card detail modal, comments +3. **Server loads** — used by all `+page.server.ts` files + +Pattern 2 is problematic because it bypasses the API layer's logging and error handling. The new `FileBrowser` component (872 lines) does all mutations via direct Supabase calls despite `$lib/api/documents.ts` having `createDocument`, `updateDocument`, `moveDocument`, `deleteDocument` functions. + +**Fix:** Migrate `FileBrowser` to use the documents API module. Add missing `kanban` type support to `createDocument`. + +--- + +### A-3 · **MEDIUM** — `createDocument` API doesn't support `kanban` type + +**File:** `src/lib/api/documents.ts:30` + +```ts +type: 'folder' | 'document', // missing 'kanban' +``` + +**Problem:** The function signature only accepts `'folder' | 'document'` but the database and UI support `'kanban'` as a document type. `FileBrowser.handleCreate()` calls Supabase directly to create kanban documents. + +**Fix:** Update the type to `'folder' | 'document' | 'kanban'` and add kanban board creation logic. + +--- + +## 7. Performance + +### P-1 · **HIGH** — Folder page loads ALL org documents on every visit + +**File:** `src/routes/[orgSlug]/documents/folder/[id]/+page.server.ts` + +**Problem:** Every folder page load fetches every document in the entire org (including content blobs via `select('*')`). For orgs with many documents, this is wasteful — content is fetched but only names/types are displayed in the file browser. + +**Fix:** +1. Select only needed columns: `.select('id, name, type, parent_id, created_at, updated_at')` +2. Consider fetching only the current folder's children + ancestors for breadcrumbs. + +--- + +### P-2 · **HIGH** — `fetchBoardWithColumns` makes 3 sequential queries + +**File:** `src/lib/api/kanban.ts:33-86` + +**Problem:** Fetches board, then columns, then cards in 3 separate queries. The columns and cards queries could run in parallel. + +**Fix:** After fetching the board, run columns and cards queries in parallel with `Promise.all`, or use a single query with joins. + +--- + +### P-3 · **MEDIUM** — `moveCard` in API makes N individual UPDATE queries + +**File:** `src/lib/api/kanban.ts:260-271` + +**Problem:** Moving a card fires one UPDATE query per card in the target column. A column with 20 cards = 20 queries. + +**Fix:** Use a Supabase RPC function for batch position updates, or only update cards whose position actually changed. + +--- + +### P-4 · **MEDIUM** — Realtime subscription reloads entire board on any change + +**Files:** +- `src/routes/[orgSlug]/kanban/+page.svelte:64-74` +- `src/routes/[orgSlug]/documents/file/[id]/+page.svelte:199-215` + +**Problem:** Any card or column change (even by the current user) triggers a full board reload (3 queries). Combined with optimistic updates, this can cause UI flicker and wasted bandwidth. + +**Fix:** Use the realtime payload to apply incremental updates instead of full reloads. + +--- + +## 8. Dependency Health + +### DEP-1 · **MEDIUM** — `lucide-svelte` is installed but never imported + +**File:** `package.json:40` + +```json +"lucide-svelte": "^0.563.0" +``` + +**Problem:** Zero imports of `lucide-svelte` anywhere in the codebase. The app uses Google Material Symbols for all icons. + +**Fix:** `npm uninstall lucide-svelte` — saves bundle size. + +--- + +### DEP-2 · **LOW** — `@tailwindcss/forms` may be unused + +**Problem:** The plugin is listed as a devDependency and imported in `layout.css`, but all form elements are custom-styled via the `Input`, `Select`, `Textarea` components. The forms plugin may be adding base styles that are immediately overridden. + +**Fix:** Test removing `@tailwindcss/forms` to see if anything breaks. If not, remove it. + +--- + +## 9. Maintainability & Readability + +### M-1 · **MEDIUM** — Magic numbers for invite expiry + +**File:** `src/routes/[orgSlug]/settings/+page.svelte:220-222` + +```ts +expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), +``` + +**Fix:** Extract to a constant: `const INVITE_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; // 7 days` + +--- + +### M-2 · **MEDIUM** — Inconsistent error feedback patterns + +**Problem:** The codebase uses 4 different patterns for user-facing errors: +1. `alert()` — settings page +2. `toasts.error()` — documents, file viewer +3. `console.error()` only (silent to user) — kanban, calendar, invite pages +4. Inline `error` state variable — login page, invite page + +**Fix:** Standardize on `toasts` for all user-facing error feedback. + +--- + +### M-3 · **LOW** — Inline SVG icons in multiple places + +**Files:** +- `src/routes/+page.svelte:88-98,105-116` — inline SVG for plus icon, people icon +- `src/routes/[orgSlug]/settings/+page.svelte` — inline SVGs for invite, Google, Discord, Slack icons +- `src/routes/invite/[token]/+page.svelte:103-136` — inline SVGs for error/invite icons + +**Fix:** Use `<Icon name="..." />` for Material icons. For brand logos, create a `BrandIcon` component or use static SVG imports. + +--- + +## 10. Future-Proofing + +### F-1 · **MEDIUM** — No permission enforcement beyond role checks + +**Problem:** The settings page defines a granular permission system (`documents.view`, `kanban.create`, `members.manage`, etc.) and roles can have custom permissions. But no code anywhere actually checks these permissions — only `role === 'owner' || role === 'admin'` checks exist. + +**Fix:** Create a `hasPermission(userRole, roles, permission)` utility and use it to gate actions throughout the app. + +--- + +### F-2 · **MEDIUM** — Kanban board subscription listens to ALL card changes + +**File:** `src/lib/api/kanban.ts:291` + +```ts +.on('postgres_changes', { event: '*', schema: 'public', table: 'kanban_cards' }, onCardChange) +``` + +**Problem:** No filter on `column_id` or board. This subscription fires for every card change across all boards in the entire database. + +**Fix:** Add a filter or use a Supabase function to scope the subscription to the current board's column IDs. + +--- + +### F-3 · **LOW** — `@tailwindcss/typography` prose styles may conflict with editor + +**Problem:** The TipTap editor content is wrapped in `.prose` which applies Tailwind Typography styles. These may conflict with the editor's own styling. + +**Fix:** Audit the `.prose` overrides in `layout.css` and consider scoping them more tightly. + +--- + +--- + +## Summary + +### Issues by Severity + +| Severity | Count | +|----------|-------| +| **Critical** | 2 | +| **High** | 7 | +| **Medium** | 17 | +| **Low** | 4 | +| **Total** | **30** | + +### Resolved Since v1 + +| ID | Issue | Resolution | +|----|-------|------------| +| D-1 | 3 dead stores (660 lines) | Deleted (auth, organizations, documents, kanban, theme) | +| D-4 | Unused `FileTree` component | Removed from index and codebase | +| D-5 | Dead `$lib/stores/index.ts` + `auth.svelte.ts` | Deleted | +| R-1 | Documents page duplication (~1900 lines) | Extracted `FileBrowser` component; both pages now ~30 lines | +| R-4 | Kanban store vs inline optimistic updates | Store deleted; inline implementation kept | +| R-6 | `Promise.resolve(null)` placeholder | Layout load refactored | +| — | Calendar `$derived` vs `$derived.by` bug | Fixed (`weeks`, `headerTitle`) | +| — | `buildDocumentTree`/`DocumentWithChildren` dead code | Removed | +| — | Editor CSS typo (`py-1.5bg-background`) | Fixed to `py-1.5 bg-background` | +| — | Invite page wrong routes (`/signup`, `/logout`) | Fixed to `/login?tab=signup`, `/auth/logout` | +| — | KanbanBoard "Add column" mislabel | Fixed to "Add card" | + +### Resolved Since v2 + +| ID | Issue | Resolution | +|----|-------|------------| +| S-2 | Google Calendar API no auth | Added session + org membership check; returns 401/403 | +| S-3 | Auth callback open redirect | Added `safeRedirect()` validator — only allows relative paths | +| S-5 | `.env.example` wrong prefix | Fixed `VITE_` → `PUBLIC_` | +| D-2 | Unused `api-helpers.ts` (96 lines) | Deleted | +| D-3 | Unused `layout/` directory (5 files) | Deleted entire directory | +| D-6 | Unused kanban vars (`newBoardVisibility`, `editBoardVisibility`, `sidebarCollapsed`) | Removed | +| D-7 | Empty `$lib/index.ts` | Deleted | +| D-8 | Unused `$lib/supabase/server.ts` | Deleted; removed re-export from index | +| D-9 | Placeholder tests (`demo.spec.ts`, `page.svelte.spec.ts`) | Deleted | +| DEP-1 | Unused `lucide-svelte` dependency | Uninstalled | +| E-1 | Settings page `alert()` calls | Replaced with `toasts` store | +| E-2 | `console.*` in Google Calendar API route | Replaced with `createLogger()` | +| E-4 | Stale `$state(data.*)` in 5 pages | Added `$effect` sync blocks | +| A-1 | Settings page god component (partial) | Extracted `SettingsGeneral` component; Figma-style pill tab nav | +| R-3 | `server.ts` duplicates `hooks.server.ts` | Deleted (same as D-8) | + +### Top 3 Highest-Impact Remaining Improvements + +1. **Rotate credentials & secure `.env`** (S-1) — Immediate security risk. Takes 15 minutes but prevents catastrophic data breach. **Must be done manually.** + +2. **Regenerate Supabase types** (T-1) — Single command that eliminates 66 `as any` casts, all `never` type errors, and makes the entire codebase type-safe. Also resolves T-4, T-5, and most of R-2. + +3. **Continue splitting settings page** (A-1) — Members, Roles, and Integrations tabs still inline. Extract each into its own component. + +### Suggested Order of Operations (updated) + +1. **Immediate (security):** S-1 (rotate keys — manual), S-4 (server-side auth for settings mutations) +2. **Type safety (1 hour):** T-1 (regenerate Supabase types), T-2→T-5 (fix remaining type issues) +3. **Architecture (1-2 days):** A-1 (finish splitting settings tabs), A-2 (migrate FileBrowser to use API modules), A-3 (add kanban type to createDocument) +4. **Performance (1 day):** P-1 (select only needed columns), P-2 (parallelize kanban queries), P-4 (incremental realtime updates) +5. **Polish:** E-5 (reliable lock release), M-1→M-3 (constants, consistent patterns), F-1 (permission enforcement), F-2 (scoped subscriptions) diff --git a/package-lock.json b/package-lock.json index 57365f2..e04eb35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,8 +13,7 @@ "@tiptap/core": "^3.19.0", "@tiptap/extension-placeholder": "^3.19.0", "@tiptap/pm": "^3.19.0", - "@tiptap/starter-kit": "^3.19.0", - "lucide-svelte": "^0.563.0" + "@tiptap/starter-kit": "^3.19.0" }, "devDependencies": { "@sveltejs/adapter-node": "^5.5.2", @@ -480,6 +479,7 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -490,6 +490,7 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -500,6 +501,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -509,12 +511,14 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1097,6 +1101,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz", "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==", + "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -1932,6 +1937,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, "license": "MIT" }, "node_modules/@types/linkify-it": { @@ -2160,6 +2166,7 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, "license": "MIT", "peer": true, "bin": { @@ -2179,6 +2186,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -2198,6 +2206,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -2233,6 +2242,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -2298,6 +2308,7 @@ "version": "5.6.2", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz", "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==", + "dev": true, "license": "MIT" }, "node_modules/enhanced-resolve": { @@ -2391,12 +2402,14 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, "license": "MIT" }, "node_modules/esrap": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.2.tgz", "integrity": "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -2824,21 +2837,14 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, "license": "MIT" }, - "node_modules/lucide-svelte": { - "version": "0.563.0", - "resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.563.0.tgz", - "integrity": "sha512-pjZKw7TpQcamfQrx7YdbOHgmrcNeKiGGMD0tKZQaVktwSsbqw28CsKc2Q97ttwjytiCWkJyOa8ij2Q+Og0nPfQ==", - "license": "ISC", - "peerDependencies": { - "svelte": "^3 || ^4 || ^5.0.0-next.42" - } - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -3443,6 +3449,7 @@ "version": "5.49.1", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.49.1.tgz", "integrity": "sha512-jj95WnbKbXsXXngYj28a4zx8jeZx50CN/J4r0CEeax2pbfdsETv/J1K8V9Hbu3DCXnpHz5qAikICuxEooi7eNQ==", + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -3494,6 +3501,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.6" @@ -3870,6 +3878,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, "license": "MIT" } } diff --git a/package.json b/package.json index 209cca6..cea9930 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,6 @@ "@tiptap/core": "^3.19.0", "@tiptap/extension-placeholder": "^3.19.0", "@tiptap/pm": "^3.19.0", - "@tiptap/starter-kit": "^3.19.0", - "lucide-svelte": "^0.563.0" + "@tiptap/starter-kit": "^3.19.0" } } diff --git a/src/app.d.ts b/src/app.d.ts index 6df30dc..00fcbc7 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -11,7 +11,12 @@ declare global { session: Session | null; user: User | null; } - // interface Error {} + interface Error { + message: string; + context?: string; + code?: string; + errorId?: string; + } // interface PageState {} // interface Platform {} } diff --git a/src/demo.spec.ts b/src/demo.spec.ts deleted file mode 100644 index e07cbbd..0000000 --- a/src/demo.spec.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -describe('sum test', () => { - it('adds 1 + 2 to equal 3', () => { - expect(1 + 2).toBe(3); - }); -}); diff --git a/src/hooks.client.ts b/src/hooks.client.ts new file mode 100644 index 0000000..080e258 --- /dev/null +++ b/src/hooks.client.ts @@ -0,0 +1,19 @@ +import type { HandleClientError } from '@sveltejs/kit'; +import { createLogger } from '$lib/utils/logger'; + +const log = createLogger('client.error'); + +export const handleError: HandleClientError = async ({ error, status, message }) => { + const errorId = crypto.randomUUID().slice(0, 8); + + log.error(`Unhandled client error [${errorId}]`, { + error, + data: { errorId, status, message }, + }); + + return { + message: message || 'An unexpected error occurred', + errorId, + code: String(status), + }; +}; diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 3e4e28a..457ca1b 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,9 +1,11 @@ import { createServerClient } from '@supabase/ssr'; import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'; -import type { Handle } from '@sveltejs/kit'; +import type { Handle, HandleServerError } from '@sveltejs/kit'; +import type { Database } from '$lib/supabase/types'; +import { createLogger } from '$lib/utils/logger'; export const handle: Handle = async ({ event, resolve }) => { - event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { + event.locals.supabase = createServerClient<Database>(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { cookies: { getAll() { return event.cookies.getAll(); @@ -43,3 +45,27 @@ export const handle: Handle = async ({ event, resolve }) => { } }); }; + +const serverLog = createLogger('server.error'); + +export const handleError: HandleServerError = async ({ error, event, status, message }) => { + const errorId = crypto.randomUUID().slice(0, 8); + + serverLog.error(`Unhandled server error [${errorId}]`, { + error, + data: { + errorId, + status, + message, + url: event.url.pathname, + method: event.request.method, + }, + }); + + return { + message: message || 'An unexpected error occurred', + errorId, + context: `${event.request.method} ${event.url.pathname}`, + code: String(status), + }; +}; diff --git a/src/lib/api/calendar.ts b/src/lib/api/calendar.ts index 31cf392..76cbae8 100644 --- a/src/lib/api/calendar.ts +++ b/src/lib/api/calendar.ts @@ -1,5 +1,8 @@ import type { SupabaseClient } from '@supabase/supabase-js'; import type { Database, CalendarEvent } from '$lib/supabase/types'; +import { createLogger } from '$lib/utils/logger'; + +const log = createLogger('api.calendar'); export async function fetchEvents( supabase: SupabaseClient<Database>, @@ -15,7 +18,10 @@ export async function fetchEvents( .lte('end_time', endDate.toISOString()) .order('start_time'); - if (error) throw error; + if (error) { + log.error('fetchEvents failed', { error, data: { orgId } }); + throw error; + } return data ?? []; } @@ -47,7 +53,10 @@ export async function createEvent( .select() .single(); - if (error) throw error; + if (error) { + log.error('createEvent failed', { error, data: { orgId, title: event.title } }); + throw error; + } return data; } @@ -57,7 +66,10 @@ export async function updateEvent( updates: Partial<Pick<CalendarEvent, 'title' | 'description' | 'start_time' | 'end_time' | 'all_day' | 'color'>> ): Promise<void> { const { error } = await supabase.from('calendar_events').update(updates).eq('id', id); - if (error) throw error; + if (error) { + log.error('updateEvent failed', { error, data: { id, updates } }); + throw error; + } } export async function deleteEvent( @@ -65,7 +77,10 @@ export async function deleteEvent( id: string ): Promise<void> { const { error } = await supabase.from('calendar_events').delete().eq('id', id); - if (error) throw error; + if (error) { + log.error('deleteEvent failed', { error, data: { id } }); + throw error; + } } export function subscribeToEvents( @@ -85,8 +100,11 @@ export function getMonthDays(year: number, month: number): Date[] { const lastDay = new Date(year, month + 1, 0); const days: Date[] = []; + // Week starts on Monday (0=Mon, 6=Sun) + let startDayOfWeek = firstDay.getDay() - 1; + if (startDayOfWeek < 0) startDayOfWeek = 6; // Sunday becomes 6 + // Add days from previous month to fill first week - const startDayOfWeek = firstDay.getDay(); for (let i = startDayOfWeek - 1; i >= 0; i--) { days.push(new Date(year, month, -i)); } @@ -96,8 +114,8 @@ export function getMonthDays(year: number, month: number): Date[] { days.push(new Date(year, month, i)); } - // Add days from next month to fill last week - const remainingDays = 42 - days.length; // 6 weeks * 7 days + // Add days from next month to fill last week (up to 6 rows) + const remainingDays = 42 - days.length; for (let i = 1; i <= remainingDays; i++) { days.push(new Date(year, month + 1, i)); } diff --git a/src/lib/api/document-locks.ts b/src/lib/api/document-locks.ts new file mode 100644 index 0000000..cd38abf --- /dev/null +++ b/src/lib/api/document-locks.ts @@ -0,0 +1,152 @@ +import type { SupabaseClient } from '@supabase/supabase-js'; +import type { Database } from '$lib/supabase/types'; +import { createLogger } from '$lib/utils/logger'; + +const log = createLogger('api.document-locks'); + +const LOCK_EXPIRY_SECONDS = 60; +const HEARTBEAT_INTERVAL_MS = 30_000; // 30 seconds + +export interface LockInfo { + isLocked: boolean; + lockedBy: string | null; + lockedByName: string | null; + isOwnLock: boolean; +} + +/** + * Get the current lock status for a document. + * Only returns active locks (heartbeat within LOCK_EXPIRY_SECONDS). + */ +export async function getLockInfo( + supabase: SupabaseClient<Database>, + documentId: string, + currentUserId: string +): Promise<LockInfo> { + const cutoff = new Date(Date.now() - LOCK_EXPIRY_SECONDS * 1000).toISOString(); + + const { data: lock } = await supabase + .from('document_locks') + .select(` + id, + document_id, + user_id, + locked_at, + last_heartbeat, + profiles:user_id (full_name, email) + `) + .eq('document_id', documentId) + .gt('last_heartbeat', cutoff) + .single(); + + if (!lock) { + return { isLocked: false, lockedBy: null, lockedByName: null, isOwnLock: false }; + } + + const profile = (lock as any).profiles; // join type not inferred by Supabase + return { + isLocked: true, + lockedBy: lock.user_id, + lockedByName: profile?.full_name || profile?.email || 'Someone', + isOwnLock: lock.user_id === currentUserId, + }; +} + +/** + * Acquire a lock on a document. Cleans up expired locks first. + * Returns true if lock was acquired, false if someone else holds it. + */ +export async function acquireLock( + supabase: SupabaseClient<Database>, + documentId: string, + userId: string +): Promise<boolean> { + const cutoff = new Date(Date.now() - LOCK_EXPIRY_SECONDS * 1000).toISOString(); + + // Delete expired locks for this document + await supabase + .from('document_locks') + .delete() + .eq('document_id', documentId) + .lt('last_heartbeat', cutoff); + + // Try to insert our lock + const { error } = await supabase + .from('document_locks') + .insert({ + document_id: documentId, + user_id: userId, + locked_at: new Date().toISOString(), + last_heartbeat: new Date().toISOString(), + }); + + if (error) { + if (error.code === '23505') { + // Unique constraint violation — someone else holds the lock + log.debug('Lock already held', { data: { documentId } }); + return false; + } + log.error('acquireLock failed', { error, data: { documentId } }); + return false; + } + + log.info('Lock acquired', { data: { documentId, userId } }); + return true; +} + +/** + * Send a heartbeat to keep the lock alive. + */ +export async function heartbeatLock( + supabase: SupabaseClient<Database>, + documentId: string, + userId: string +): Promise<boolean> { + const { error } = await supabase + .from('document_locks') + .update({ last_heartbeat: new Date().toISOString() }) + .eq('document_id', documentId) + .eq('user_id', userId); + + if (error) { + log.error('heartbeatLock failed', { error, data: { documentId } }); + return false; + } + return true; +} + +/** + * Release a lock on a document. + */ +export async function releaseLock( + supabase: SupabaseClient<Database>, + documentId: string, + userId: string +): Promise<void> { + const { error } = await supabase + .from('document_locks') + .delete() + .eq('document_id', documentId) + .eq('user_id', userId); + + if (error) { + log.error('releaseLock failed', { error, data: { documentId } }); + } else { + log.info('Lock released', { data: { documentId, userId } }); + } +} + +/** + * Start a heartbeat interval. Returns a cleanup function. + */ +export function startHeartbeat( + supabase: SupabaseClient<Database>, + documentId: string, + userId: string +): () => void { + const interval = setInterval(() => { + heartbeatLock(supabase, documentId, userId); + }, HEARTBEAT_INTERVAL_MS); + + return () => clearInterval(interval); +} diff --git a/src/lib/api/documents.ts b/src/lib/api/documents.ts index 99917dc..5c6bc46 100644 --- a/src/lib/api/documents.ts +++ b/src/lib/api/documents.ts @@ -1,9 +1,8 @@ import type { SupabaseClient } from '@supabase/supabase-js'; import type { Database, Document } from '$lib/supabase/types'; +import { createLogger } from '$lib/utils/logger'; -export interface DocumentWithChildren extends Document { - children?: DocumentWithChildren[]; -} +const log = createLogger('api.documents'); export async function fetchDocuments( supabase: SupabaseClient<Database>, @@ -16,7 +15,11 @@ export async function fetchDocuments( .order('type', { ascending: false }) // folders first .order('name'); - if (error) throw error; + if (error) { + log.error('fetchDocuments failed', { error, data: { orgId } }); + throw error; + } + log.debug('fetchDocuments ok', { data: { count: data?.length ?? 0 } }); return data ?? []; } @@ -41,7 +44,11 @@ export async function createDocument( .select() .single(); - if (error) throw error; + if (error) { + log.error('createDocument failed', { error, data: { orgId, name, type, parentId } }); + throw error; + } + log.info('createDocument ok', { data: { id: data.id, name, type } }); return data; } @@ -57,7 +64,10 @@ export async function updateDocument( .select() .single(); - if (error) throw error; + if (error) { + log.error('updateDocument failed', { error, data: { id, updates } }); + throw error; + } return data; } @@ -66,7 +76,10 @@ export async function deleteDocument( id: string ): Promise<void> { const { error } = await supabase.from('documents').delete().eq('id', id); - if (error) throw error; + if (error) { + log.error('deleteDocument failed', { error, data: { id } }); + throw error; + } } export async function moveDocument( @@ -79,30 +92,12 @@ export async function moveDocument( .update({ parent_id: newParentId, updated_at: new Date().toISOString() }) .eq('id', id); - if (error) throw error; + if (error) { + log.error('moveDocument failed', { error, data: { id, newParentId } }); + throw error; + } } -export function buildDocumentTree(documents: Document[]): DocumentWithChildren[] { - const map = new Map<string, DocumentWithChildren>(); - const roots: DocumentWithChildren[] = []; - - // First pass: create map - documents.forEach((doc) => { - map.set(doc.id, { ...doc, children: [] }); - }); - - // Second pass: build tree - documents.forEach((doc) => { - const node = map.get(doc.id)!; - if (doc.parent_id && map.has(doc.parent_id)) { - map.get(doc.parent_id)!.children!.push(node); - } else { - roots.push(node); - } - }); - - return roots; -} export function subscribeToDocuments( supabase: SupabaseClient<Database>, diff --git a/src/lib/api/kanban.ts b/src/lib/api/kanban.ts index de9e88d..65f688f 100644 --- a/src/lib/api/kanban.ts +++ b/src/lib/api/kanban.ts @@ -1,5 +1,8 @@ import type { SupabaseClient } from '@supabase/supabase-js'; import type { Database, KanbanBoard, KanbanColumn, KanbanCard } from '$lib/supabase/types'; +import { createLogger } from '$lib/utils/logger'; + +const log = createLogger('api.kanban'); export interface ColumnWithCards extends KanbanColumn { cards: KanbanCard[]; @@ -19,7 +22,11 @@ export async function fetchBoards( .eq('org_id', orgId) .order('created_at'); - if (error) throw error; + if (error) { + log.error('fetchBoards failed', { error, data: { orgId } }); + throw error; + } + log.debug('fetchBoards ok', { data: { count: data?.length ?? 0 } }); return data ?? []; } @@ -33,7 +40,10 @@ export async function fetchBoardWithColumns( .eq('id', boardId) .single(); - if (boardError) throw boardError; + if (boardError) { + log.error('fetchBoardWithColumns failed (board)', { error: boardError, data: { boardId } }); + throw boardError; + } if (!board) return null; const { data: columns, error: colError } = await supabase @@ -42,22 +52,55 @@ export async function fetchBoardWithColumns( .eq('board_id', boardId) .order('position'); - if (colError) throw colError; + if (colError) { + log.error('fetchBoardWithColumns failed (columns)', { error: colError, data: { boardId } }); + throw colError; + } + + const columnIds = (columns ?? []).map((c) => c.id); const { data: cards, error: cardError } = await supabase .from('kanban_cards') .select('*') - .in('column_id', (columns ?? []).map((c) => c.id)) + .in('column_id', columnIds) .order('position'); - if (cardError) throw cardError; + if (cardError) { + log.error('fetchBoardWithColumns failed (cards)', { error: cardError, data: { boardId } }); + throw cardError; + } + + // Fetch tags for all cards in one query + const cardIds = (cards ?? []).map((c) => c.id); + let cardTagsMap = new Map<string, { id: string; name: string; color: string | null }[]>(); - const cardsByColumn = new Map<string, KanbanCard[]>(); + if (cardIds.length > 0) { + const { data: cardTags } = await supabase + .from('card_tags') + .select('card_id, tags:tag_id (id, name, color)') + .in('card_id', cardIds); + + (cardTags ?? []).forEach((ct: any) => { + const tag = Array.isArray(ct.tags) ? ct.tags[0] : ct.tags; + if (!tag) return; + if (!cardTagsMap.has(ct.card_id)) { + cardTagsMap.set(ct.card_id, []); + } + cardTagsMap.get(ct.card_id)!.push(tag); + }); + } + + const cardsByColumn = new Map<string, (KanbanCard & { tags?: { id: string; name: string; color: string | null }[] })[]>(); (cards ?? []).forEach((card) => { - if (!cardsByColumn.has(card.column_id)) { - cardsByColumn.set(card.column_id, []); + const colId = card.column_id; + if (!colId) return; + if (!cardsByColumn.has(colId)) { + cardsByColumn.set(colId, []); } - cardsByColumn.get(card.column_id)!.push(card); + cardsByColumn.get(colId)!.push({ + ...card, + tags: cardTagsMap.get(card.id) ?? [] + }); }); return { @@ -74,13 +117,17 @@ export async function createBoard( orgId: string, name: string ): Promise<KanbanBoard> { + log.info('createBoard', { data: { orgId, name } }); const { data, error } = await supabase .from('kanban_boards') .insert({ org_id: orgId, name }) .select() .single(); - if (error) throw error; + if (error) { + log.error('createBoard failed', { error, data: { orgId, name } }); + throw error; + } // Create default columns const defaultColumns = ['To Do', 'In Progress', 'Done']; @@ -101,7 +148,10 @@ export async function updateBoard( name: string ): Promise<void> { const { error } = await supabase.from('kanban_boards').update({ name }).eq('id', id); - if (error) throw error; + if (error) { + log.error('updateBoard failed', { error, data: { id, name } }); + throw error; + } } export async function deleteBoard( @@ -109,7 +159,10 @@ export async function deleteBoard( id: string ): Promise<void> { const { error } = await supabase.from('kanban_boards').delete().eq('id', id); - if (error) throw error; + if (error) { + log.error('deleteBoard failed', { error, data: { id } }); + throw error; + } } export async function createColumn( @@ -124,7 +177,10 @@ export async function createColumn( .select() .single(); - if (error) throw error; + if (error) { + log.error('createColumn failed', { error, data: { boardId, name, position } }); + throw error; + } return data; } @@ -134,7 +190,10 @@ export async function updateColumn( updates: Partial<Pick<KanbanColumn, 'name' | 'position' | 'color'>> ): Promise<void> { const { error } = await supabase.from('kanban_columns').update(updates).eq('id', id); - if (error) throw error; + if (error) { + log.error('updateColumn failed', { error, data: { id, updates } }); + throw error; + } } export async function deleteColumn( @@ -142,7 +201,10 @@ export async function deleteColumn( id: string ): Promise<void> { const { error } = await supabase.from('kanban_columns').delete().eq('id', id); - if (error) throw error; + if (error) { + log.error('deleteColumn failed', { error, data: { id } }); + throw error; + } } export async function createCard( @@ -163,7 +225,10 @@ export async function createCard( .select() .single(); - if (error) throw error; + if (error) { + log.error('createCard failed', { error, data: { columnId, title, position } }); + throw error; + } return data; } @@ -173,7 +238,10 @@ export async function updateCard( updates: Partial<Pick<KanbanCard, 'title' | 'description' | 'column_id' | 'position' | 'due_date' | 'color'>> ): Promise<void> { const { error } = await supabase.from('kanban_cards').update(updates).eq('id', id); - if (error) throw error; + if (error) { + log.error('updateCard failed', { error, data: { id, updates } }); + throw error; + } } export async function deleteCard( @@ -181,7 +249,10 @@ export async function deleteCard( id: string ): Promise<void> { const { error } = await supabase.from('kanban_cards').delete().eq('id', id); - if (error) throw error; + if (error) { + log.error('deleteCard failed', { error, data: { id } }); + throw error; + } } export async function moveCard( @@ -190,12 +261,48 @@ export async function moveCard( newColumnId: string, newPosition: number ): Promise<void> { - const { error } = await supabase + // Fetch all cards in the target column (ordered by position) + const { data: targetCards, error: fetchErr } = await supabase .from('kanban_cards') - .update({ column_id: newColumnId, position: newPosition }) - .eq('id', cardId); + .select('id, position') + .eq('column_id', newColumnId) + .order('position'); + + if (fetchErr) { + log.error('moveCard: failed to fetch target column cards', { error: fetchErr }); + throw fetchErr; + } + + // Remove the moved card from the list if it's already in this column + const otherCards = (targetCards ?? []).filter((c) => c.id !== cardId); + + // Insert at the new position and reassign sequential positions + const reordered = [ + ...otherCards.slice(0, newPosition), + { id: cardId }, + ...otherCards.slice(newPosition), + ]; + + // Batch update: move card to column + set position, then update siblings + const updates = reordered.map((c, i) => { + if (c.id === cardId) { + return supabase + .from('kanban_cards') + .update({ column_id: newColumnId, position: i }) + .eq('id', c.id); + } + return supabase + .from('kanban_cards') + .update({ position: i }) + .eq('id', c.id); + }); - if (error) throw error; + const results = await Promise.all(updates); + const failed = results.find((r) => r.error); + if (failed?.error) { + log.error('moveCard failed', { error: failed.error, data: { cardId, newColumnId, newPosition } }); + throw failed.error; + } } export function subscribeToBoard( diff --git a/src/lib/api/organizations.ts b/src/lib/api/organizations.ts index 1e9e3d4..dcd868e 100644 --- a/src/lib/api/organizations.ts +++ b/src/lib/api/organizations.ts @@ -1,6 +1,13 @@ import type { SupabaseClient } from '@supabase/supabase-js'; import type { Database, Organization, MemberRole } from '$lib/supabase/types'; -import type { OrgWithRole } from '$lib/stores/organizations.svelte'; +import { createLogger } from '$lib/utils/logger'; + +export interface OrgWithRole extends Organization { + role: MemberRole; + memberCount?: number; +} + +const log = createLogger('api.organizations'); export async function fetchUserOrganizations( supabase: SupabaseClient<Database> @@ -20,7 +27,10 @@ export async function fetchUserOrganizations( `) .not('joined_at', 'is', null); - if (error) throw error; + if (error) { + log.error('fetchUserOrganizations failed', { error }); + throw error; + } return (data ?? []) .filter((item) => item.organizations) @@ -35,13 +45,17 @@ export async function createOrganization( name: string, slug: string ): Promise<Organization> { + log.info('createOrganization', { data: { name, slug } }); const { data, error } = await supabase .from('organizations') .insert({ name, slug }) .select() .single(); - if (error) throw error; + if (error) { + log.error('createOrganization failed', { error, data: { name, slug } }); + throw error; + } return data; } @@ -57,7 +71,10 @@ export async function updateOrganization( .select() .single(); - if (error) throw error; + if (error) { + log.error('updateOrganization failed', { error, data: { id, updates } }); + throw error; + } return data; } @@ -66,7 +83,10 @@ export async function deleteOrganization( id: string ): Promise<void> { const { error } = await supabase.from('organizations').delete().eq('id', id); - if (error) throw error; + if (error) { + log.error('deleteOrganization failed', { error, data: { id } }); + throw error; + } } export async function fetchOrgMembers( @@ -90,7 +110,10 @@ export async function fetchOrgMembers( `) .eq('org_id', orgId); - if (error) throw error; + if (error) { + log.error('fetchOrgMembers failed', { error, data: { orgId } }); + throw error; + } return data ?? []; } @@ -108,6 +131,7 @@ export async function inviteMember( .single(); if (profileError || !profile) { + log.warn('inviteMember: user not found', { data: { email } }); throw new Error('User not found. They need to sign up first.'); } @@ -120,6 +144,7 @@ export async function inviteMember( .single(); if (existing) { + log.warn('inviteMember: already a member', { data: { email, orgId } }); throw new Error('User is already a member of this organization.'); } @@ -131,7 +156,10 @@ export async function inviteMember( joined_at: new Date().toISOString() // Auto-join for now }); - if (error) throw error; + if (error) { + log.error('inviteMember failed', { error, data: { orgId, email, role } }); + throw error; + } } export async function updateMemberRole( @@ -144,7 +172,10 @@ export async function updateMemberRole( .update({ role }) .eq('id', memberId); - if (error) throw error; + if (error) { + log.error('updateMemberRole failed', { error, data: { memberId, role } }); + throw error; + } } export async function removeMember( @@ -152,7 +183,10 @@ export async function removeMember( memberId: string ): Promise<void> { const { error } = await supabase.from('org_members').delete().eq('id', memberId); - if (error) throw error; + if (error) { + log.error('removeMember failed', { error, data: { memberId } }); + throw error; + } } export function generateSlug(name: string): string { diff --git a/src/lib/components/calendar/Calendar.svelte b/src/lib/components/calendar/Calendar.svelte index cbb6dbc..8013151 100644 --- a/src/lib/components/calendar/Calendar.svelte +++ b/src/lib/components/calendar/Calendar.svelte @@ -22,31 +22,20 @@ let currentView = $state<ViewType>(initialView); const today = new Date(); - const weekDays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + const weekDayHeaders = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; const days = $derived( getMonthDays(currentDate.getFullYear(), currentDate.getMonth()), ); - function prevMonth() { - currentDate = new Date( - currentDate.getFullYear(), - currentDate.getMonth() - 1, - 1, - ); - } - - function nextMonth() { - currentDate = new Date( - currentDate.getFullYear(), - currentDate.getMonth() + 1, - 1, - ); - } - - function goToToday() { - currentDate = new Date(); - } + // Group days into weeks (rows of 7) + const weeks = $derived.by(() => { + const result: Date[][] = []; + for (let i = 0; i < days.length; i += 7) { + result.push(days.slice(i, i + 7)); + } + return result; + }); function getEventsForDay(date: Date): CalendarEvent[] { return events.filter((event) => { @@ -66,10 +55,12 @@ }), ); - // Get week days for week view + // Get week days for week view (Mon-Sun) function getWeekDays(date: Date): Date[] { const startOfWeek = new Date(date); - startOfWeek.setDate(date.getDate() - date.getDay()); + const dayOfWeek = startOfWeek.getDay(); + const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; + startOfWeek.setDate(date.getDate() + mondayOffset); return Array.from({ length: 7 }, (_, i) => { const d = new Date(startOfWeek); d.setDate(startOfWeek.getDate() + i); @@ -79,7 +70,6 @@ const weekDates = $derived(getWeekDays(currentDate)); - // Navigation functions for different views function prev() { if (currentView === "month") { currentDate = new Date( @@ -112,7 +102,11 @@ } } - const headerTitle = $derived(() => { + function goToToday() { + currentDate = new Date(); + } + + const headerTitle = $derived.by(() => { if (currentView === "day") { return currentDate.toLocaleDateString("en-US", { weekday: "long", @@ -129,207 +123,200 @@ }); </script> -<div class="bg-surface rounded-xl p-4"> - <div class="flex items-center justify-between mb-4"> - <h2 class="text-xl font-semibold text-light">{headerTitle()}</h2> +<div class="flex flex-col h-full gap-2"> + <!-- Navigation bar --> + <div class="flex items-center justify-between px-2"> <div class="flex items-center gap-2"> - <!-- View Switcher --> - <div class="flex bg-dark rounded-lg p-0.5"> - <button - class="px-3 py-1 text-sm rounded-md transition-colors {currentView === - 'day' - ? 'bg-primary text-white' - : 'text-light/60 hover:text-light'}" - onclick={() => (currentView = "day")} - > - Day - </button> - <button - class="px-3 py-1 text-sm rounded-md transition-colors {currentView === - 'week' - ? 'bg-primary text-white' - : 'text-light/60 hover:text-light'}" - onclick={() => (currentView = "week")} - > - Week - </button> - <button - class="px-3 py-1 text-sm rounded-md transition-colors {currentView === - 'month' - ? 'bg-primary text-white' - : 'text-light/60 hover:text-light'}" - onclick={() => (currentView = "month")} - > - Month - </button> - </div> - <button - class="px-3 py-1.5 text-sm text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors" - onclick={goToToday} - > - Today - </button> <button - class="p-2 text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors" + class="p-1 text-light/60 hover:text-light hover:bg-dark rounded-lg transition-colors" onclick={prev} aria-label="Previous" > - <svg - class="w-5 h-5" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" + <span + class="material-symbols-rounded" + style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;" + >chevron_left</span > - <path d="m15 18-6-6 6-6" /> - </svg> </button> + <span + class="font-heading text-h4 text-white min-w-[200px] text-center" + >{headerTitle}</span + > <button - class="p-2 text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors" + class="p-1 text-light/60 hover:text-light hover:bg-dark rounded-lg transition-colors" onclick={next} aria-label="Next" > - <svg - class="w-5 h-5" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" + <span + class="material-symbols-rounded" + style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;" + >chevron_right</span > - <path d="m9 18 6-6-6-6" /> - </svg> + </button> + <button + class="px-3 py-1 text-body-md font-body text-light/60 hover:text-white hover:bg-dark rounded-[32px] transition-colors ml-2" + onclick={goToToday} + > + Today </button> </div> + <div class="flex bg-dark rounded-[32px] p-0.5"> + <button + class="px-3 py-1 text-body-md font-body rounded-[32px] transition-colors {currentView === + 'day' + ? 'bg-primary text-night' + : 'text-light/60 hover:text-light'}" + onclick={() => (currentView = "day")}>Day</button + > + <button + class="px-3 py-1 text-body-md font-body rounded-[32px] transition-colors {currentView === + 'week' + ? 'bg-primary text-night' + : 'text-light/60 hover:text-light'}" + onclick={() => (currentView = "week")}>Week</button + > + <button + class="px-3 py-1 text-body-md font-body rounded-[32px] transition-colors {currentView === + 'month' + ? 'bg-primary text-night' + : 'text-light/60 hover:text-light'}" + onclick={() => (currentView = "month")}>Month</button + > + </div> </div> <!-- Month View --> {#if currentView === "month"} <div - class="grid grid-cols-7 gap-px bg-light/10 rounded-lg overflow-hidden" + class="flex flex-col flex-1 gap-2 min-h-0 bg-background rounded-xl p-2" > - {#each weekDays as day} - <div - class="bg-dark px-2 py-2 text-center text-sm font-medium text-light/50" - > - {day} - </div> - {/each} - - {#each days as day} - {@const dayEvents = getEventsForDay(day)} - {@const isToday = isSameDay(day, today)} - {@const inMonth = isCurrentMonth(day)} - <button - class="bg-dark min-h-[80px] p-1 text-left transition-colors hover:bg-light/5" - class:opacity-40={!inMonth} - onclick={() => onDateClick?.(day)} - > - <div class="flex items-center justify-center w-7 h-7 mb-1"> + <!-- Day Headers --> + <div class="grid grid-cols-7 gap-2"> + {#each weekDayHeaders as day} + <div class="flex items-center justify-center py-2 px-2"> <span - class="text-sm {isToday - ? 'bg-primary text-white rounded-full w-7 h-7 flex items-center justify-center' - : 'text-light/80'}" + class="font-heading text-h4 text-white text-center" + >{day}</span > - {day.getDate()} - </span> </div> - <div class="space-y-0.5"> - {#each dayEvents.slice(0, 3) as event} - <button - class="w-full text-xs px-1 py-0.5 rounded truncate text-left" - style="background-color: {event.color ?? - '#6366f1'}20; color: {event.color ?? - '#6366f1'}" - onclick={(e) => { - e.stopPropagation(); - onEventClick?.(event); - }} + {/each} + </div> + + <!-- Calendar Grid --> + <div class="flex-1 flex flex-col gap-2 min-h-0"> + {#each weeks as week} + <div class="grid grid-cols-7 gap-2 flex-1"> + {#each week as day} + {@const dayEvents = getEventsForDay(day)} + {@const isToday = isSameDay(day, today)} + {@const inMonth = isCurrentMonth(day)} + <div + class="bg-night rounded-none flex flex-col items-start px-4 py-5 overflow-hidden transition-colors hover:bg-dark/50 min-h-0 cursor-pointer + {!inMonth ? 'opacity-50' : ''}" + onclick={() => onDateClick?.(day)} > - {event.title} - </button> + <span + class="font-body text-body text-white {isToday + ? 'text-primary font-bold' + : ''}" + > + {day.getDate()} + </span> + {#each dayEvents.slice(0, 2) as event} + <button + class="w-full mt-1 px-2 py-0.5 rounded-[4px] text-body-sm font-bold font-body text-night truncate text-left" + style="background-color: {event.color ?? + '#00A3E0'}" + onclick={(e) => { + e.stopPropagation(); + onEventClick?.(event); + }} + > + {event.title} + </button> + {/each} + {#if dayEvents.length > 2} + <span + class="text-body-sm text-light/40 mt-0.5" + >+{dayEvents.length - 2} more</span + > + {/if} + </div> {/each} - {#if dayEvents.length > 3} - <p class="text-xs text-light/40 px-1"> - +{dayEvents.length - 3} more - </p> - {/if} </div> - </button> - {/each} + {/each} + </div> </div> {/if} <!-- Week View --> {#if currentView === "week"} <div - class="grid grid-cols-7 gap-px bg-light/10 rounded-lg overflow-hidden" + class="flex flex-col flex-1 gap-2 min-h-0 bg-background rounded-xl p-2" > - {#each weekDates as day} - {@const dayEvents = getEventsForDay(day)} - {@const isToday = isSameDay(day, today)} - <div class="bg-dark"> - <div class="px-2 py-2 text-center border-b border-light/10"> - <div class="text-xs text-light/50"> - {weekDays[day.getDay()]} - </div> - <div - class="text-lg font-medium {isToday - ? 'text-primary' - : 'text-light'}" - > - {day.getDate()} - </div> - </div> - <div class="min-h-[300px] p-1 space-y-1"> - {#each dayEvents as event} - <button - class="w-full text-xs px-2 py-1.5 rounded text-left" - style="background-color: {event.color ?? - '#6366f1'}20; color: {event.color ?? - '#6366f1'}" - onclick={() => onEventClick?.(event)} + <div class="grid grid-cols-7 gap-2 flex-1"> + {#each weekDates as day} + {@const dayEvents = getEventsForDay(day)} + {@const isToday = isSameDay(day, today)} + <div class="flex flex-col overflow-hidden"> + <div class="px-4 py-3 text-center"> + <div + class="font-heading text-h4 {isToday + ? 'text-primary' + : 'text-white'}" > - <div class="font-medium truncate"> + {weekDayHeaders[(day.getDay() + 6) % 7]} + </div> + <div + class="font-body text-body-md {isToday + ? 'text-primary' + : 'text-light/60'}" + > + {day.getDate()} + </div> + </div> + <div class="flex-1 px-2 pb-2 space-y-1 overflow-y-auto"> + {#each dayEvents as event} + <button + class="w-full px-2 py-1.5 rounded-[4px] text-body-sm font-bold font-body text-night truncate text-left" + style="background-color: {event.color ?? + '#00A3E0'}" + onclick={() => onEventClick?.(event)} + > {event.title} - </div> - <div class="text-[10px] opacity-70"> - {new Date( - event.start_time, - ).toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - })} - </div> - </button> - {/each} + </button> + {/each} + </div> </div> - </div> - {/each} + {/each} + </div> </div> {/if} <!-- Day View --> {#if currentView === "day"} {@const dayEvents = getEventsForDay(currentDate)} - <div class="bg-dark rounded-lg p-4 min-h-[400px]"> + <div class="flex-1 bg-night px-4 py-5 min-h-0 overflow-auto"> {#if dayEvents.length === 0} <div class="text-center text-light/40 py-12"> - <p>No events for this day</p> + <p class="font-body text-body">No events for this day</p> </div> {:else} <div class="space-y-2"> {#each dayEvents as event} <button - class="w-full text-left p-3 rounded-lg transition-colors hover:opacity-80" + class="w-full text-left p-3 rounded-[8px] transition-colors hover:opacity-80" style="background-color: {event.color ?? - '#6366f1'}20; border-left: 3px solid {event.color ?? - '#6366f1'}" + '#00A3E0'}20; border-left: 3px solid {event.color ?? + '#00A3E0'}" onclick={() => onEventClick?.(event)} > - <div class="font-medium text-light"> + <div class="font-heading text-h5 text-white"> {event.title} </div> - <div class="text-sm text-light/60 mt-1"> + <div + class="font-body text-body-md text-light/60 mt-1" + > {new Date(event.start_time).toLocaleTimeString( "en-US", { hour: "numeric", minute: "2-digit" }, @@ -340,7 +327,9 @@ )} </div> {#if event.description} - <div class="text-sm text-light/50 mt-2"> + <div + class="font-body text-body-md text-light/50 mt-2" + > {event.description} </div> {/if} diff --git a/src/lib/components/documents/DocumentViewer.svelte b/src/lib/components/documents/DocumentViewer.svelte new file mode 100644 index 0000000..1a77942 --- /dev/null +++ b/src/lib/components/documents/DocumentViewer.svelte @@ -0,0 +1,105 @@ +<script lang="ts"> + import { goto } from "$app/navigation"; + import { Button } from "$lib/components/ui"; + import { Editor } from "$lib/components/documents"; + import type { Document, Json } from "$lib/supabase/types"; + + interface Props { + document: Document; + onSave?: (content: Json) => void; + /** "preview" = read-only with Edit button that navigates to editUrl. "edit" = editable inline. */ + mode?: "preview" | "edit"; + /** URL to navigate to when clicking "+ Edit" in preview mode */ + editUrl?: string; + /** Whether the document is locked by another user */ + locked?: boolean; + /** Name of the user who holds the lock */ + lockedByName?: string | null; + } + + let { + document, + onSave, + mode = "preview", + editUrl, + locked = false, + lockedByName = null, + }: Props = $props(); + + let isEditing = $state(false); + + $effect(() => { + isEditing = mode === "edit" && !locked; + }); + + function handleEditClick() { + if (locked) return; + if (mode === "preview" && editUrl) { + goto(editUrl); + } else { + isEditing = !isEditing; + } + } +</script> + +<div + class="bg-night rounded-[32px] overflow-hidden flex flex-col min-w-0 h-full" +> + <!-- Lock Banner --> + {#if locked} + <div + class="flex items-center gap-2 px-4 py-2.5 bg-warning/10 border-b border-warning/20" + > + <span + class="material-symbols-rounded text-warning" + style="font-size: 20px; font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 20;" + > + lock + </span> + <span class="text-body-sm text-warning"> + {lockedByName || "Someone"} is currently editing this document. View-only + mode. + </span> + </div> + {/if} + + <!-- Header --> + <header class="flex items-center gap-2 px-4 py-5"> + <h2 class="flex-1 font-heading text-h1 text-white truncate"> + {document.name} + </h2> + {#if locked} + <Button size="md" disabled> + <span + class="material-symbols-rounded mr-1" + style="font-size: 16px; font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 16;" + >lock</span + > + Locked + </Button> + {:else if mode === "edit"} + <Button size="md" onclick={handleEditClick}> + {isEditing ? "Preview" : "Edit"} + </Button> + {:else} + <Button size="md" onclick={handleEditClick}>Edit</Button> + {/if} + <button + type="button" + class="p-1 hover:bg-dark rounded-lg transition-colors" + aria-label="More options" + > + <span + class="material-symbols-rounded text-light" + style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;" + > + more_horiz + </span> + </button> + </header> + + <!-- Editor Area --> + <div class="flex-1 bg-background rounded-[32px] mx-4 mb-4 overflow-auto"> + <Editor {document} {onSave} editable={isEditing} /> + </div> +</div> diff --git a/src/lib/components/documents/Editor.svelte b/src/lib/components/documents/Editor.svelte index f9ac297..86c3af4 100644 --- a/src/lib/components/documents/Editor.svelte +++ b/src/lib/components/documents/Editor.svelte @@ -3,15 +3,15 @@ import { Editor } from "@tiptap/core"; import StarterKit from "@tiptap/starter-kit"; import Placeholder from "@tiptap/extension-placeholder"; - import type { Document } from "$lib/supabase/types"; + import type { Document, Json } from "$lib/supabase/types"; interface Props { document?: Document | null; content?: object | null; editable?: boolean; placeholder?: string; - onUpdate?: (content: object) => void; - onSave?: (content: object) => void; + onUpdate?: (content: Json) => void; + onSave?: (content: Json) => void; } let { @@ -29,6 +29,7 @@ let element: HTMLDivElement; let editor: Editor | null = $state(null); let saveStatus = $state<"idle" | "saving" | "saved" | "error">("idle"); + let isMounted = $state(true); let saveTimeout: ReturnType<typeof setTimeout> | null = null; let statusTimeout: ReturnType<typeof setTimeout> | null = null; @@ -37,24 +38,25 @@ if (saveTimeout) clearTimeout(saveTimeout); saveStatus = "idle"; saveTimeout = setTimeout(async () => { - await saveNow(); + if (isMounted) await saveNow(); }, 1000); // Auto-save after 1 second of inactivity } async function saveNow() { - if (editor && onSave) { - saveStatus = "saving"; - try { - await onSave(editor.getJSON()); - saveStatus = "saved"; - // Reset status after 2 seconds - if (statusTimeout) clearTimeout(statusTimeout); - statusTimeout = setTimeout(() => { - saveStatus = "idle"; - }, 2000); - } catch { - saveStatus = "error"; - } + if (!isMounted || !editor || !onSave) return; + + saveStatus = "saving"; + try { + await onSave(editor.getJSON()); + if (!isMounted) return; // Guard after async + saveStatus = "saved"; + // Reset status after 2 seconds + if (statusTimeout) clearTimeout(statusTimeout); + statusTimeout = setTimeout(() => { + if (isMounted) saveStatus = "idle"; + }, 2000); + } catch { + if (isMounted) saveStatus = "error"; } } @@ -71,7 +73,7 @@ }, editorProps: { attributes: { - class: "prose prose-invert max-w-none focus:outline-none min-h-[200px] p-4", + class: "prose prose-invert max-w-3xl mx-auto focus:outline-none min-h-[200px] p-4", }, handleKeyDown: (view, event) => { if ((event.ctrlKey || event.metaKey) && event.key === "s") { @@ -86,6 +88,7 @@ }); onDestroy(() => { + isMounted = false; if (saveTimeout) clearTimeout(saveTimeout); if (statusTimeout) clearTimeout(statusTimeout); editor?.destroy(); @@ -124,11 +127,9 @@ } </script> -<div class="bg-surface rounded-xl border border-light/10 overflow-hidden"> +<div class="bg-background rounded-xl overflow-hidden"> {#if editable} - <div - class="flex items-center gap-1 px-2 py-1.5 border-b border-light/10 bg-dark/50" - > + <div class="flex items-center gap-1 px-2 py-1.5 bg-background"> <!-- Save Button --> <button class="flex items-center gap-1.5 px-2 py-1 mr-2 text-xs rounded hover:bg-light/10 transition-colors {saveStatus === @@ -346,7 +347,7 @@ </button> </div> {/if} - <div bind:this={element}></div> + <div class="border-none" bind:this={element}></div> </div> <style> diff --git a/src/lib/components/documents/FileBrowser.svelte b/src/lib/components/documents/FileBrowser.svelte new file mode 100644 index 0000000..fe0f373 --- /dev/null +++ b/src/lib/components/documents/FileBrowser.svelte @@ -0,0 +1,874 @@ +<script lang="ts"> + import { getContext } from "svelte"; + import { goto } from "$app/navigation"; + import { + Button, + Modal, + Input, + Avatar, + IconButton, + Icon, + } from "$lib/components/ui"; + import { DocumentViewer } from "$lib/components/documents"; + import { createLogger } from "$lib/utils/logger"; + import { toasts } from "$lib/stores/toast.svelte"; + import type { Document } from "$lib/supabase/types"; + import type { SupabaseClient } from "@supabase/supabase-js"; + import type { Database } from "$lib/supabase/types"; + + const log = createLogger("component.file-browser"); + + interface Props { + org: { id: string; name: string; slug: string }; + documents: Document[]; + currentFolderId: string | null; + user: { id: string } | null; + /** Page title shown in the header */ + title?: string; + } + + let { + org, + documents = $bindable(), + currentFolderId, + user, + title = "Files", + }: Props = $props(); + + const supabase = getContext<SupabaseClient<Database>>("supabase"); + + let selectedDoc = $state<Document | null>(null); + let showCreateModal = $state(false); + let showEditModal = $state(false); + let editingDoc = $state<Document | null>(null); + let newDocName = $state(""); + let newDocType = $state<"folder" | "document" | "kanban">("document"); + let viewMode = $state<"list" | "grid">("grid"); + + // Context menu state + let contextMenu = $state<{ x: number; y: number; doc: Document } | null>( + null, + ); + let showOrganizeMenu = $state(false); + + // Sort: folders first, then documents, then kanbans, alphabetical + function typeOrder(type: string): number { + if (type === "folder") return 0; + if (type === "document") return 1; + if (type === "kanban") return 2; + return 3; + } + + const currentFolderItems = $derived( + documents + .filter((d) => + currentFolderId === null + ? d.parent_id === null + : d.parent_id === currentFolderId, + ) + .sort((a, b) => { + const typeA = typeOrder(a.type); + const typeB = typeOrder(b.type); + if (typeA !== typeB) return typeA - typeB; + return a.name.localeCompare(b.name); + }), + ); + + // Drag and drop state + let draggedItem = $state<Document | null>(null); + let dragOverFolder = $state<string | null>(null); + let isDragging = $state(false); + let dragOverBreadcrumb = $state<string | null | undefined>(undefined); + + // Build breadcrumb path + const breadcrumbPath = $derived.by(() => { + const path: { id: string | null; name: string }[] = [ + { id: null, name: "Home" }, + ]; + if (currentFolderId === null) return path; + + let current = documents.find((d) => d.id === currentFolderId); + const ancestors: { id: string; name: string }[] = []; + while (current) { + ancestors.unshift({ id: current.id, name: current.name }); + current = current.parent_id + ? documents.find((d) => d.id === current!.parent_id) + : undefined; + } + return [...path, ...ancestors]; + }); + + // URL helpers + function getFolderUrl(folderId: string | null): string { + if (!folderId) return `/${org.slug}/documents`; + return `/${org.slug}/documents/folder/${folderId}`; + } + + function getFileUrl(doc: Document): string { + return `/${org.slug}/documents/file/${doc.id}`; + } + + function getDocIcon(doc: Document): string { + if (doc.type === "folder") return "folder"; + if (doc.type === "kanban") return "view_kanban"; + return "description"; + } + + function handleItemClick(doc: Document) { + if (isDragging) { + isDragging = false; + return; + } + if (doc.type === "folder") { + goto(getFolderUrl(doc.id)); + } else if (doc.type === "kanban") { + goto(getFileUrl(doc)); + } else { + selectedDoc = doc; + } + } + + function handleDoubleClick(doc: Document) { + if (doc.type === "folder") { + window.open(getFolderUrl(doc.id), "_blank"); + } else { + window.open(getFileUrl(doc), "_blank"); + } + } + + function handleAuxClick(e: MouseEvent, doc: Document) { + if (e.button === 1) { + e.preventDefault(); + if (doc.type === "folder") { + window.open(getFolderUrl(doc.id), "_blank"); + } else { + window.open(getFileUrl(doc), "_blank"); + } + } + } + + // Context menu handlers + function handleContextMenu(e: MouseEvent, doc: Document) { + e.preventDefault(); + contextMenu = { x: e.clientX, y: e.clientY, doc }; + showOrganizeMenu = false; + } + + function closeContextMenu() { + contextMenu = null; + showOrganizeMenu = false; + } + + function contextRename() { + if (!contextMenu) return; + editingDoc = contextMenu.doc; + newDocName = contextMenu.doc.name; + showEditModal = true; + closeContextMenu(); + } + + async function contextCopy() { + if (!contextMenu || !user) return; + const doc = contextMenu.doc; + closeContextMenu(); + const { data: newDoc, error } = await supabase + .from("documents") + .insert({ + org_id: org.id, + name: `${doc.name} (copy)`, + type: doc.type, + parent_id: doc.parent_id, + created_by: user.id, + content: doc.content, + }) + .select() + .single(); + if (!error && newDoc) { + documents = [...documents, newDoc as Document]; + toasts.success(`Copied "${doc.name}"`); + } else if (error) { + log.error("Failed to copy document", { error }); + toasts.error("Failed to copy document"); + } + } + + function contextOrganize() { + showOrganizeMenu = !showOrganizeMenu; + } + + async function contextMoveToFolder(folderId: string | null) { + if (!contextMenu) return; + const doc = contextMenu.doc; + closeContextMenu(); + await handleMove(doc.id, folderId); + toasts.success( + `Moved "${doc.name}" to ${folderId ? (documents.find((d) => d.id === folderId)?.name ?? "folder") : "Home"}`, + ); + } + + function contextDelete() { + if (!contextMenu) return; + const doc = contextMenu.doc; + closeContextMenu(); + handleDelete(doc); + } + + const availableFolders = $derived( + documents.filter( + (d) => d.type === "folder" && d.id !== contextMenu?.doc.id, + ), + ); + + function handleAdd() { + showCreateModal = true; + } + + // Drag handlers + function handleDragStart(e: DragEvent, doc: Document) { + isDragging = true; + draggedItem = doc; + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", doc.id); + } + } + + function handleDragEnd() { + resetDragState(); + } + + function handleDragOver(e: DragEvent, doc: Document) { + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; + if (draggedItem?.id === doc.id) return; + if (doc.type === "folder") { + dragOverFolder = doc.id; + } else { + dragOverFolder = null; + } + } + + function handleDragLeave() { + dragOverFolder = null; + } + + async function handleDrop(e: DragEvent, targetDoc: Document) { + e.preventDefault(); + e.stopPropagation(); + if (!draggedItem || draggedItem.id === targetDoc.id) { + resetDragState(); + return; + } + if (targetDoc.type === "folder") { + const draggedName = draggedItem.name; + await handleMove(draggedItem.id, targetDoc.id); + toasts.success(`Moved "${draggedName}" into "${targetDoc.name}"`); + } + resetDragState(); + } + + function handleContainerDragOver(e: DragEvent) { + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; + } + + async function handleDropOnEmpty(e: DragEvent) { + e.preventDefault(); + if (!draggedItem) return; + if (draggedItem.parent_id !== currentFolderId) { + await handleMove(draggedItem.id, currentFolderId); + } + resetDragState(); + } + + function resetDragState() { + draggedItem = null; + dragOverFolder = null; + setTimeout(() => { + isDragging = false; + }, 100); + } + + async function handleMove(docId: string, newParentId: string | null) { + documents = documents.map((d) => + d.id === docId ? { ...d, parent_id: newParentId } : d, + ); + const { error } = await supabase + .from("documents") + .update({ + parent_id: newParentId, + updated_at: new Date().toISOString(), + }) + .eq("id", docId); + if (error) { + log.error("Failed to move document", { + error, + data: { docId, newParentId }, + }); + toasts.error("Failed to move file"); + const { data: freshDocs } = await supabase + .from("documents") + .select("*") + .eq("org_id", org.id) + .order("name"); + if (freshDocs) documents = freshDocs as Document[]; + } + } + + async function handleCreate() { + if (!newDocName.trim() || !user) return; + + if (newDocType === "kanban") { + const { data: newBoard, error: boardError } = await supabase + .from("kanban_boards") + .insert({ org_id: org.id, name: newDocName }) + .select() + .single(); + if (boardError || !newBoard) { + toasts.error("Failed to create kanban board"); + return; + } + await supabase.from("kanban_columns").insert([ + { board_id: newBoard.id, name: "To Do", position: 0 }, + { board_id: newBoard.id, name: "In Progress", position: 1 }, + { board_id: newBoard.id, name: "Done", position: 2 }, + ]); + const { data: newDoc, error } = await supabase + .from("documents") + .insert({ + id: newBoard.id, + org_id: org.id, + name: newDocName, + type: "kanban", + parent_id: currentFolderId, + created_by: user.id, + content: { + type: "kanban", + board_id: newBoard.id, + } as import("$lib/supabase/types").Json, + }) + .select() + .single(); + if (!error && newDoc) { + goto(getFileUrl(newDoc as Document)); + } else if (error) { + toasts.error("Failed to create kanban document"); + } + } else { + let content: any = null; + if (newDocType === "document") { + content = { type: "doc", content: [] }; + } + const { data: newDoc, error } = await supabase + .from("documents") + .insert({ + org_id: org.id, + name: newDocName, + type: newDocType as "folder" | "document", + parent_id: currentFolderId, + created_by: user.id, + content, + }) + .select() + .single(); + if (!error && newDoc) { + documents = [...documents, newDoc as Document]; + if (newDocType === "document") { + goto(getFileUrl(newDoc as Document)); + } + } else if (error) { + toasts.error("Failed to create document"); + } + } + + showCreateModal = false; + newDocName = ""; + newDocType = "document"; + } + + async function handleSave(content: import("$lib/supabase/types").Json) { + if (!selectedDoc) return; + await supabase + .from("documents") + .update({ content, updated_at: new Date().toISOString() }) + .eq("id", selectedDoc.id); + documents = documents.map((d) => + d.id === selectedDoc!.id ? { ...d, content } : d, + ); + } + + async function handleRename() { + if (!editingDoc || !newDocName.trim()) return; + const { error } = await supabase + .from("documents") + .update({ name: newDocName, updated_at: new Date().toISOString() }) + .eq("id", editingDoc.id); + if (!error) { + documents = documents.map((d) => + d.id === editingDoc!.id ? { ...d, name: newDocName } : d, + ); + if (selectedDoc?.id === editingDoc.id) { + selectedDoc = { ...selectedDoc, name: newDocName }; + } + } + showEditModal = false; + editingDoc = null; + newDocName = ""; + } + + async function handleDelete(doc: Document) { + const itemType = + doc.type === "folder" ? "folder and all its contents" : "document"; + if (!confirm(`Delete this ${itemType}?`)) return; + + // Recursively collect all descendant IDs for proper deletion + function collectDescendantIds(parentId: string): string[] { + const children = documents.filter((d) => d.parent_id === parentId); + let ids: string[] = []; + for (const child of children) { + ids.push(child.id); + if (child.type === "folder") { + ids = ids.concat(collectDescendantIds(child.id)); + } + } + return ids; + } + + if (doc.type === "folder") { + const descendantIds = collectDescendantIds(doc.id); + if (descendantIds.length > 0) { + await supabase + .from("documents") + .delete() + .in("id", descendantIds); + } + } + + const { error } = await supabase + .from("documents") + .delete() + .eq("id", doc.id); + if (!error) { + const deletedIds = new Set([ + doc.id, + ...(doc.type === "folder" ? collectDescendantIds(doc.id) : []), + ]); + documents = documents.filter((d) => !deletedIds.has(d.id)); + if (selectedDoc?.id === doc.id) { + selectedDoc = null; + } + } + } +</script> + +<div class="flex h-full gap-4"> + <!-- Files Panel --> + <div + class="bg-night rounded-[32px] flex flex-col gap-4 px-4 py-5 overflow-hidden flex-1 min-w-0 h-full" + > + <!-- Header --> + <header class="flex items-center gap-2 p-1"> + <Avatar name={title} size="md" /> + <h1 class="flex-1 font-heading text-h1 text-white">{title}</h1> + <Button size="md" onclick={handleAdd}>+ New</Button> + <IconButton + title="Toggle view" + onclick={() => + (viewMode = viewMode === "list" ? "grid" : "list")} + > + <Icon + name={viewMode === "list" ? "grid_view" : "view_list"} + size={24} + /> + </IconButton> + </header> + + <!-- Breadcrumb Path --> + <nav class="flex items-center gap-2 text-h3 font-heading"> + {#each breadcrumbPath as crumb, i} + {#if i > 0} + <span + class="material-symbols-rounded text-light/30" + style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;" + > + chevron_right + </span> + {/if} + <a + href={getFolderUrl(crumb.id)} + class="px-3 py-1 rounded-xl transition-colors + {crumb.id === currentFolderId + ? 'text-white' + : 'text-light/60 hover:text-primary'} + {dragOverBreadcrumb === (crumb.id ?? '__root__') + ? 'ring-2 ring-primary bg-primary/10' + : ''}" + ondragover={(e) => { + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; + dragOverBreadcrumb = crumb.id ?? "__root__"; + }} + ondragleave={() => { + dragOverBreadcrumb = undefined; + }} + ondrop={async (e) => { + e.preventDefault(); + e.stopPropagation(); + dragOverBreadcrumb = undefined; + if (!draggedItem) return; + if (draggedItem.parent_id === crumb.id) { + resetDragState(); + return; + } + const draggedName = draggedItem.name; + await handleMove(draggedItem.id, crumb.id); + toasts.success( + `Moved "${draggedName}" to "${crumb.name}"`, + ); + resetDragState(); + }} + > + {crumb.name} + </a> + {/each} + </nav> + + <!-- File List/Grid --> + <div class="flex-1 overflow-auto min-h-0"> + {#if viewMode === "list"} + <div + class="flex flex-col gap-1" + ondragover={handleContainerDragOver} + ondrop={handleDropOnEmpty} + role="list" + > + {#if currentFolderItems.length === 0} + <div class="text-center text-light/40 py-8 text-sm"> + <p> + No files yet. Drag files here or create a new + one. + </p> + </div> + {:else} + {#each currentFolderItems as item} + <button + type="button" + class="flex items-center gap-2 h-10 pl-1 pr-2 py-1 rounded-[32px] w-full text-left transition-colors hover:bg-dark + {selectedDoc?.id === item.id ? 'bg-dark' : ''} + {draggedItem?.id === item.id ? 'opacity-50' : ''} + {dragOverFolder === item.id ? 'ring-2 ring-primary bg-primary/10' : ''}" + draggable="true" + ondragstart={(e) => handleDragStart(e, item)} + ondragend={handleDragEnd} + ondragover={(e) => handleDragOver(e, item)} + ondragleave={handleDragLeave} + ondrop={(e) => handleDrop(e, item)} + onclick={() => handleItemClick(item)} + ondblclick={() => handleDoubleClick(item)} + onauxclick={(e) => handleAuxClick(e, item)} + oncontextmenu={(e) => + handleContextMenu(e, item)} + > + <div + class="w-8 h-8 flex items-center justify-center p-1" + > + <span + class="material-symbols-rounded text-light" + style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;" + > + {getDocIcon(item)} + </span> + </div> + <span + class="font-body text-body text-white truncate flex-1" + >{item.name}</span + > + {#if item.type === "folder"} + <span + class="material-symbols-rounded text-light/50" + style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;" + > + chevron_right + </span> + {/if} + </button> + {/each} + {/if} + </div> + {:else} + <!-- Grid View --> + <div + class="grid grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4" + ondragover={handleContainerDragOver} + ondrop={handleDropOnEmpty} + role="list" + > + {#if currentFolderItems.length === 0} + <div + class="col-span-full text-center text-light/40 py-8 text-sm" + > + <p> + No files yet. Drag files here or create a new + one. + </p> + </div> + {:else} + {#each currentFolderItems as item} + <button + type="button" + class="flex flex-col items-center gap-2 p-4 rounded-xl transition-colors hover:bg-dark + {selectedDoc?.id === item.id ? 'bg-dark' : ''} + {draggedItem?.id === item.id ? 'opacity-50' : ''} + {dragOverFolder === item.id ? 'ring-2 ring-primary bg-primary/10' : ''}" + draggable="true" + ondragstart={(e) => handleDragStart(e, item)} + ondragend={handleDragEnd} + ondragover={(e) => handleDragOver(e, item)} + ondragleave={handleDragLeave} + ondrop={(e) => handleDrop(e, item)} + onclick={() => handleItemClick(item)} + ondblclick={() => handleDoubleClick(item)} + onauxclick={(e) => handleAuxClick(e, item)} + oncontextmenu={(e) => + handleContextMenu(e, item)} + > + <span + class="material-symbols-rounded text-light" + style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48;" + > + {getDocIcon(item)} + </span> + <span + class="font-body text-body-md text-white text-center truncate w-full" + >{item.name}</span + > + </button> + {/each} + {/if} + </div> + {/if} + </div> + </div> + + <!-- Compact Editor Panel (shown when a doc is selected) --> + {#if selectedDoc} + <div class="flex-1 min-w-0 h-full"> + <DocumentViewer + document={selectedDoc} + onSave={handleSave} + mode="preview" + editUrl={getFileUrl(selectedDoc)} + /> + </div> + {/if} +</div> + +<Modal + isOpen={showCreateModal} + onClose={() => (showCreateModal = false)} + title="Create New" +> + <div class="space-y-4"> + <div class="flex gap-2"> + <button + type="button" + class="flex-1 py-2 px-4 rounded-lg border transition-colors {newDocType === + 'document' + ? 'border-primary bg-primary/10' + : 'border-light/20'}" + onclick={() => (newDocType = "document")} + > + <span + class="material-symbols-rounded text-h4 mr-1" + style="font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;" + >description</span + > + Document + </button> + <button + type="button" + class="flex-1 py-2 px-4 rounded-lg border transition-colors {newDocType === + 'folder' + ? 'border-primary bg-primary/10' + : 'border-light/20'}" + onclick={() => (newDocType = "folder")} + > + <span + class="material-symbols-rounded text-h4 mr-1" + style="font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;" + >folder</span + > + Folder + </button> + <button + type="button" + class="flex-1 py-2 px-4 rounded-lg border transition-colors {newDocType === + 'kanban' + ? 'border-primary bg-primary/10' + : 'border-light/20'}" + onclick={() => (newDocType = "kanban")} + > + <span + class="material-symbols-rounded text-h4 mr-1" + style="font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;" + >view_kanban</span + > + Kanban + </button> + </div> + <Input + label="Name" + bind:value={newDocName} + placeholder={newDocType === "folder" + ? "Folder name" + : newDocType === "kanban" + ? "Kanban board name" + : "Document name"} + /> + <div class="flex justify-end gap-2 pt-2"> + <Button variant="tertiary" onclick={() => (showCreateModal = false)} + >Cancel</Button + > + <Button onclick={handleCreate} disabled={!newDocName.trim()} + >Create</Button + > + </div> + </div> +</Modal> + +<!-- Context Menu --> +{#if contextMenu} + <!-- svelte-ignore a11y_click_events_have_key_events --> + <!-- svelte-ignore a11y_no_static_element_interactions --> + <div class="fixed inset-0 z-50" onclick={closeContextMenu}></div> + <div + class="fixed z-50 bg-night border border-light/10 rounded-xl shadow-2xl py-1 min-w-[200px]" + style="left: {contextMenu.x}px; top: {contextMenu.y}px;" + > + <button + type="button" + class="w-full flex items-center gap-3 px-4 py-2.5 text-left text-body-md text-white hover:bg-dark transition-colors" + onclick={contextRename} + > + <span + class="material-symbols-rounded text-light" + style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;" + >edit</span + > + Rename + </button> + <button + type="button" + class="w-full flex items-center gap-3 px-4 py-2.5 text-left text-body-md text-white hover:bg-dark transition-colors" + onclick={contextCopy} + > + <span + class="material-symbols-rounded text-light" + style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;" + >content_copy</span + > + Make a copy + </button> + <div class="relative"> + <button + type="button" + class="w-full flex items-center gap-3 px-4 py-2.5 text-left text-body-md text-white hover:bg-dark transition-colors" + onclick={contextOrganize} + > + <span + class="material-symbols-rounded text-light" + style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;" + >drive_file_move</span + > + Organize + <span + class="material-symbols-rounded text-light/50 ml-auto" + style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;" + >chevron_right</span + > + </button> + {#if showOrganizeMenu} + <div + class="absolute left-full top-0 ml-1 bg-night border border-light/10 rounded-xl shadow-2xl py-1 min-w-[180px] max-h-[240px] overflow-auto" + > + {#if contextMenu.doc.parent_id !== null} + <button + type="button" + class="w-full flex items-center gap-3 px-4 py-2.5 text-left text-body-md text-white hover:bg-dark transition-colors" + onclick={() => contextMoveToFolder(null)} + > + <span + class="material-symbols-rounded text-light" + style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;" + >home</span + > + Home + </button> + {/if} + {#each availableFolders as folder} + {#if folder.id !== contextMenu.doc.parent_id} + <button + type="button" + class="w-full flex items-center gap-3 px-4 py-2.5 text-left text-body-md text-white hover:bg-dark transition-colors" + onclick={() => contextMoveToFolder(folder.id)} + > + <span + class="material-symbols-rounded text-light" + style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;" + >folder</span + > + {folder.name} + </button> + {/if} + {/each} + </div> + {/if} + </div> + <div class="border-t border-light/10 my-1"></div> + <button + type="button" + class="w-full flex items-center gap-3 px-4 py-2.5 text-left text-body-md text-error hover:bg-error/10 transition-colors" + onclick={contextDelete} + > + <span + class="material-symbols-rounded" + style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;" + >delete</span + > + Delete + </button> + </div> +{/if} + +<Modal + isOpen={showEditModal} + onClose={() => { + showEditModal = false; + editingDoc = null; + newDocName = ""; + }} + title="Rename" +> + <div class="space-y-4"> + <Input + label="Name" + bind:value={newDocName} + placeholder="Enter new name" + /> + <div class="flex justify-end gap-2 pt-2"> + <Button + variant="tertiary" + onclick={() => { + showEditModal = false; + editingDoc = null; + newDocName = ""; + }}>Cancel</Button + > + <Button onclick={handleRename} disabled={!newDocName.trim()} + >Save</Button + > + </div> + </div> +</Modal> diff --git a/src/lib/components/documents/FileTree.svelte b/src/lib/components/documents/FileTree.svelte deleted file mode 100644 index 63942dd..0000000 --- a/src/lib/components/documents/FileTree.svelte +++ /dev/null @@ -1,253 +0,0 @@ -<script lang="ts"> - import type { DocumentWithChildren } from "$lib/api/documents"; - - interface Props { - items: DocumentWithChildren[]; - selectedId?: string | null; - onSelect: (doc: DocumentWithChildren) => void; - onDoubleClick?: (doc: DocumentWithChildren) => void; - onAdd?: (parentId: string | null) => void; - onMove?: (docId: string, newParentId: string | null) => void; - onEdit?: (doc: DocumentWithChildren) => void; - onDelete?: (doc: DocumentWithChildren) => void; - level?: number; - } - - let { - items, - selectedId = null, - onSelect, - onDoubleClick, - onAdd, - onMove, - onEdit, - onDelete, - level = 0, - }: Props = $props(); - - let expandedFolders = $state<Set<string>>(new Set()); - let dragOverId = $state<string | null>(null); - - function toggleFolder(id: string, e?: MouseEvent) { - e?.stopPropagation(); - const newSet = new Set(expandedFolders); - if (newSet.has(id)) { - newSet.delete(id); - } else { - newSet.add(id); - } - expandedFolders = newSet; - } - - function handleSelect(doc: DocumentWithChildren) { - onSelect(doc); - } - - function handleAdd(e: MouseEvent, parentId: string | null) { - e.stopPropagation(); - onAdd?.(parentId); - } - - function handleDragStart(e: DragEvent, doc: DocumentWithChildren) { - if (!e.dataTransfer) return; - e.dataTransfer.effectAllowed = "move"; - e.dataTransfer.setData("text/plain", doc.id); - } - - function handleDragOver( - e: DragEvent, - targetId: string | null, - isFolder: boolean, - ) { - if (!isFolder && targetId !== null) return; - e.preventDefault(); - dragOverId = targetId; - } - - function handleDragLeave() { - dragOverId = null; - } - - function handleDrop(e: DragEvent, targetFolderId: string | null) { - e.preventDefault(); - dragOverId = null; - const docId = e.dataTransfer?.getData("text/plain"); - if (docId && docId !== targetFolderId) { - onMove?.(docId, targetFolderId); - } - } -</script> - -<div - class="space-y-0.5" - ondragover={(e) => level === 0 && handleDragOver(e, null, true)} - ondragleave={handleDragLeave} - ondrop={(e) => level === 0 && handleDrop(e, null)} - role="tree" -> - {#each items as item} - <div role="treeitem"> - <div - class="group w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left transition-colors cursor-pointer - {selectedId === item.id - ? 'bg-primary/20 text-primary' - : 'text-light/80 hover:bg-light/5'} - {dragOverId === item.id ? 'ring-2 ring-primary bg-primary/10' : ''}" - onclick={() => handleSelect(item)} - ondblclick={() => onDoubleClick?.(item)} - draggable="true" - ondragstart={(e) => handleDragStart(e, item)} - ondragover={(e) => - handleDragOver(e, item.id, item.type === "folder")} - ondragleave={handleDragLeave} - ondrop={(e) => item.type === "folder" && handleDrop(e, item.id)} - role="button" - tabindex="0" - > - {#if item.type === "folder"} - <button - class="p-0.5 hover:bg-light/10 rounded" - onclick={(e) => toggleFolder(item.id, e)} - aria-label="Toggle folder" - > - <svg - class="w-4 h-4 transition-transform {expandedFolders.has( - item.id, - ) - ? 'rotate-90' - : ''}" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - > - <path d="m9 18 6-6-6-6" /> - </svg> - </button> - <svg - class="w-4 h-4 text-warning" - viewBox="0 0 24 24" - fill="currentColor" - > - <path - d="M3 7V17C3 18.1046 3.89543 19 5 19H19C20.1046 19 21 18.1046 21 17V9C21 7.89543 20.1046 7 19 7H12L10 5H5C3.89543 5 3 5.89543 3 7Z" - /> - </svg> - {:else} - <div class="w-5"></div> - <svg - class="w-4 h-4 text-light/50" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - > - <path - d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" - /> - <polyline points="14,2 14,8 20,8" /> - <line x1="16" y1="13" x2="8" y2="13" /> - <line x1="16" y1="17" x2="8" y2="17" /> - </svg> - {/if} - <span class="flex-1 truncate text-sm">{item.name}</span> - - <div - class="opacity-0 group-hover:opacity-100 flex items-center gap-0.5 transition-opacity" - > - {#if item.type === "folder" && onAdd} - <button - class="p-1 hover:bg-light/10 rounded" - onclick={(e) => handleAdd(e, item.id)} - aria-label="Add to folder" - > - <svg - class="w-4 h-4" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - > - <line x1="12" y1="5" x2="12" y2="19" /> - <line x1="5" y1="12" x2="19" y2="12" /> - </svg> - </button> - {/if} - {#if onEdit} - <button - class="p-1 hover:bg-light/10 rounded" - onclick={(e) => { - e.stopPropagation(); - onEdit(item); - }} - aria-label="Rename" - > - <svg - class="w-4 h-4" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - > - <path - d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" - /> - <path - d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" - /> - </svg> - </button> - {/if} - {#if onDelete} - <button - class="p-1 hover:bg-error/20 hover:text-error rounded" - onclick={(e) => { - e.stopPropagation(); - onDelete(item); - }} - aria-label="Delete" - > - <svg - class="w-4 h-4" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - > - <polyline points="3,6 5,6 21,6" /> - <path - d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" - /> - </svg> - </button> - {/if} - </div> - </div> - - {#if item.type === "folder" && expandedFolders.has(item.id)} - <div class="ml-4 border-l border-light/10 pl-2"> - {#if item.children?.length} - <svelte:self - items={item.children} - {selectedId} - {onSelect} - {onAdd} - {onMove} - {onEdit} - {onDelete} - level={level + 1} - /> - {:else} - <p class="text-light/30 text-xs px-3 py-2 italic"> - Empty folder - </p> - {/if} - </div> - {/if} - </div> - {/each} - - {#if items.length === 0 && level === 0} - <p class="text-light/40 text-sm px-3 py-2">No documents yet</p> - {/if} -</div> diff --git a/src/lib/components/documents/index.ts b/src/lib/components/documents/index.ts index 9e3eeff..638afeb 100644 --- a/src/lib/components/documents/index.ts +++ b/src/lib/components/documents/index.ts @@ -1,2 +1,3 @@ -export { default as FileTree } from './FileTree.svelte'; export { default as Editor } from './Editor.svelte'; +export { default as DocumentViewer } from './DocumentViewer.svelte'; +export { default as FileBrowser } from './FileBrowser.svelte'; diff --git a/src/lib/components/kanban/CardChecklist.svelte b/src/lib/components/kanban/CardChecklist.svelte new file mode 100644 index 0000000..4f0cb8c --- /dev/null +++ b/src/lib/components/kanban/CardChecklist.svelte @@ -0,0 +1,170 @@ +<script lang="ts"> + import { getContext, onDestroy } from "svelte"; + import { Button, Input, Icon } from "$lib/components/ui"; + import type { SupabaseClient } from "@supabase/supabase-js"; + import type { Database } from "$lib/supabase/types"; + + interface ChecklistItem { + id: string; + card_id: string; + title: string; + completed: boolean; + position: number; + } + + interface Props { + cardId: string; + items: ChecklistItem[]; + onItemsChange: (items: ChecklistItem[]) => void; + } + + let { cardId, items, onItemsChange }: Props = $props(); + + const supabase = getContext<SupabaseClient<Database>>("supabase"); + + let isMounted = $state(true); + let newItemTitle = $state(""); + let isAdding = $state(false); + + onDestroy(() => { + isMounted = false; + }); + + const completedCount = $derived(items.filter((i) => i.completed).length); + const progress = $derived( + items.length > 0 ? (completedCount / items.length) * 100 : 0, + ); + + async function handleAddItem() { + if (!newItemTitle.trim() || !isMounted) return; + isAdding = true; + + const position = items.length; + const { data, error } = await supabase + .from("kanban_checklist_items") + .insert({ + card_id: cardId, + title: newItemTitle.trim(), + position, + completed: false, + }) + .select() + .single(); + + if (!isMounted) return; + + if (!error && data) { + onItemsChange([...items, data as ChecklistItem]); + newItemTitle = ""; + } + isAdding = false; + } + + async function toggleItem(item: ChecklistItem) { + if (!isMounted) return; + + // Optimistic update + const updated = items.map((i) => + i.id === item.id ? { ...i, completed: !i.completed } : i, + ); + onItemsChange(updated); + + const { error } = await supabase + .from("kanban_checklist_items") + .update({ completed: !item.completed }) + .eq("id", item.id); + + if (error && isMounted) { + // Rollback on error + onItemsChange(items); + } + } + + async function deleteItem(itemId: string) { + if (!isMounted) return; + + const { error } = await supabase + .from("kanban_checklist_items") + .delete() + .eq("id", itemId); + + if (!error && isMounted) { + onItemsChange(items.filter((i) => i.id !== itemId)); + } + } +</script> + +<div class="space-y-3"> + <div class="flex items-center justify-between"> + <h4 class="text-sm font-medium text-light">Checklist</h4> + <span class="text-xs text-light/50" + >{completedCount}/{items.length}</span + > + </div> + + <!-- Progress bar --> + {#if items.length > 0} + <div class="h-1.5 bg-dark rounded-full overflow-hidden"> + <div + class="h-full bg-primary transition-all duration-300" + style="width: {progress}%" + ></div> + </div> + {/if} + + <!-- Checklist items --> + <div class="space-y-1"> + {#each items as item (item.id)} + <div class="flex items-center gap-2 group py-1"> + <button + type="button" + class="w-4 h-4 rounded border flex items-center justify-center transition-colors {item.completed + ? 'bg-primary border-primary' + : 'border-light/30 hover:border-primary'}" + onclick={() => toggleItem(item)} + > + {#if item.completed} + <Icon name="check" size={12} class="text-white" /> + {/if} + </button> + <span + class="flex-1 text-sm {item.completed + ? 'line-through text-light/40' + : 'text-light'}" + > + {item.title} + </span> + <button + type="button" + class="opacity-0 group-hover:opacity-100 p-1 text-light/40 hover:text-error transition-all" + onclick={() => deleteItem(item.id)} + aria-label="Delete item" + > + <Icon name="close" size={14} /> + </button> + </div> + {/each} + </div> + + <!-- Add item form --> + <form + class="flex gap-2 items-end" + onsubmit={(e) => { + e.preventDefault(); + handleAddItem(); + }} + > + <Input + placeholder="Add checklist item..." + bind:value={newItemTitle} + disabled={isAdding} + /> + <Button + type="submit" + size="md" + disabled={!newItemTitle.trim() || isAdding} + > + Add + </Button> + </form> +</div> diff --git a/src/lib/components/kanban/CardComments.svelte b/src/lib/components/kanban/CardComments.svelte new file mode 100644 index 0000000..85fa45c --- /dev/null +++ b/src/lib/components/kanban/CardComments.svelte @@ -0,0 +1,159 @@ +<script lang="ts"> + import { getContext, onDestroy } from "svelte"; + import { Button, Input, Icon, Avatar } from "$lib/components/ui"; + import type { SupabaseClient } from "@supabase/supabase-js"; + import type { Database } from "$lib/supabase/types"; + + interface Comment { + id: string; + card_id: string; + user_id: string; + content: string; + created_at: string; + profiles?: { full_name: string | null; email: string }; + } + + interface Props { + cardId: string; + userId: string; + comments: Comment[]; + onCommentsChange: (comments: Comment[]) => void; + } + + let { cardId, userId, comments, onCommentsChange }: Props = $props(); + + const supabase = getContext<SupabaseClient<Database>>("supabase"); + + let isMounted = $state(true); + let newComment = $state(""); + let isAdding = $state(false); + + onDestroy(() => { + isMounted = false; + }); + + function formatDate(dateStr: string): string { + return new Date(dateStr).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + } + + async function handleAddComment() { + if (!newComment.trim() || !isMounted) return; + isAdding = true; + + const { data, error } = await supabase + .from("kanban_comments") + .insert({ + card_id: cardId, + user_id: userId, + content: newComment.trim(), + }) + .select( + ` + id, + card_id, + user_id, + content, + created_at, + profiles:user_id (full_name, email) + `, + ) + .single(); + + if (!isMounted) return; + + if (!error && data) { + onCommentsChange([...comments, data as Comment]); + newComment = ""; + } + isAdding = false; + } + + async function deleteComment(commentId: string) { + if (!isMounted) return; + + const { error } = await supabase + .from("kanban_comments") + .delete() + .eq("id", commentId); + + if (!error && isMounted) { + onCommentsChange(comments.filter((c) => c.id !== commentId)); + } + } +</script> + +<div class="space-y-3"> + <span class="px-3 font-bold font-body text-body text-white">Comments</span> + + <!-- Comment list --> + {#if comments.length > 0} + <div class="space-y-3 max-h-48 overflow-y-auto"> + {#each comments as comment (comment.id)} + <div class="flex gap-2 group"> + <Avatar + name={comment.profiles?.full_name || + comment.profiles?.email || + "?"} + size="sm" + /> + <div class="flex-1 min-w-0"> + <div class="flex items-center gap-2"> + <span + class="text-sm font-medium text-light truncate" + > + {comment.profiles?.full_name || + comment.profiles?.email || + "Unknown"} + </span> + <span class="text-xs text-light/40"> + {formatDate(comment.created_at)} + </span> + {#if comment.user_id === userId} + <button + type="button" + class="opacity-0 group-hover:opacity-100 p-0.5 text-light/40 hover:text-error transition-all ml-auto" + onclick={() => deleteComment(comment.id)} + aria-label="Delete comment" + > + <Icon name="close" size={12} /> + </button> + {/if} + </div> + <p class="text-sm text-light/70 break-words"> + {comment.content} + </p> + </div> + </div> + {/each} + </div> + {:else} + <p class="text-sm text-light/40 text-center py-2">No comments yet</p> + {/if} + + <!-- Add comment form --> + <form + class="flex gap-2 items-end" + onsubmit={(e) => { + e.preventDefault(); + handleAddComment(); + }} + > + <Input + placeholder="Add a comment..." + bind:value={newComment} + disabled={isAdding} + /> + <Button + type="submit" + size="md" + disabled={!newComment.trim() || isAdding} + > + Send + </Button> + </form> +</div> diff --git a/src/lib/components/kanban/CardDetailModal.svelte b/src/lib/components/kanban/CardDetailModal.svelte index 54a6bbe..f41823a 100644 --- a/src/lib/components/kanban/CardDetailModal.svelte +++ b/src/lib/components/kanban/CardDetailModal.svelte @@ -1,10 +1,23 @@ <script lang="ts"> - import { getContext } from "svelte"; - import { Modal, Button, Input, Textarea } from "$lib/components/ui"; + import { getContext, onDestroy } from "svelte"; + import { + Modal, + Button, + Input, + Textarea, + Select, + AssigneePicker, + Icon, + } from "$lib/components/ui"; import type { KanbanCard } from "$lib/supabase/types"; import type { SupabaseClient } from "@supabase/supabase-js"; import type { Database } from "$lib/supabase/types"; + let isMounted = $state(true); + onDestroy(() => { + isMounted = false; + }); + interface ChecklistItem { id: string; card_id: string; @@ -33,6 +46,12 @@ }; } + interface OrgTag { + id: string; + name: string; + color: string | null; + } + interface Props { card: KanbanCard | null; isOpen: boolean; @@ -42,6 +61,7 @@ mode?: "edit" | "create"; columnId?: string; userId?: string; + orgId?: string; onCreate?: (card: KanbanCard) => void; members?: Member[]; } @@ -55,6 +75,7 @@ mode = "edit", columnId, userId, + orgId, onCreate, members = [], }: Props = $props(); @@ -74,20 +95,35 @@ let isSaving = $state(false); let showAssigneePicker = $state(false); + // Tags state + let orgTags = $state<OrgTag[]>([]); + let cardTagIds = $state<Set<string>>(new Set()); + let newTagName = $state(""); + let showTagInput = $state(false); + + const TAG_COLORS = [ + "#00A3E0", + "#33E000", + "#E03D00", + "#FFAB00", + "#A855F7", + "#EC4899", + "#6366F1", + ]; + $effect(() => { if (isOpen) { if (mode === "edit" && card) { title = card.title; description = card.description ?? ""; - assigneeId = (card as any).assignee_id ?? null; - dueDate = (card as any).due_date - ? new Date((card as any).due_date) - .toISOString() - .split("T")[0] + assigneeId = card.assignee_id ?? null; + dueDate = card.due_date + ? new Date(card.due_date).toISOString().split("T")[0] : ""; - priority = (card as any).priority ?? "medium"; + priority = card.priority ?? "medium"; loadChecklist(); loadComments(); + loadTags(); } else if (mode === "create") { title = ""; description = ""; @@ -96,12 +132,14 @@ priority = "medium"; checklist = []; comments = []; + cardTagIds = new Set(); + loadOrgTags(); } } }); async function loadChecklist() { - if (!card) return; + if (!card || !isMounted) return; isLoading = true; const { data } = await supabase @@ -110,12 +148,13 @@ .eq("card_id", card.id) .order("position"); + if (!isMounted) return; checklist = (data ?? []) as ChecklistItem[]; isLoading = false; } async function loadComments() { - if (!card) return; + if (!card || !isMounted) return; const { data } = await supabase .from("kanban_comments") @@ -132,10 +171,75 @@ .eq("card_id", card.id) .order("created_at", { ascending: true }); + if (!isMounted) return; comments = (data ?? []) as Comment[]; } + async function loadOrgTags() { + if (!orgId) return; + const { data } = await supabase + .from("tags") + .select("id, name, color") + .eq("org_id", orgId) + .order("name"); + if (!isMounted) return; + orgTags = (data ?? []) as OrgTag[]; + } + + async function loadTags() { + await loadOrgTags(); + if (!card) return; + const { data } = await supabase + .from("card_tags") + .select("tag_id") + .eq("card_id", card.id); + if (!isMounted) return; + cardTagIds = new Set((data ?? []).map((t) => t.tag_id)); + } + + async function toggleTag(tagId: string) { + if (!card) return; + if (cardTagIds.has(tagId)) { + await supabase + .from("card_tags") + .delete() + .eq("card_id", card.id) + .eq("tag_id", tagId); + cardTagIds.delete(tagId); + cardTagIds = new Set(cardTagIds); + } else { + await supabase + .from("card_tags") + .insert({ card_id: card.id, tag_id: tagId }); + cardTagIds.add(tagId); + cardTagIds = new Set(cardTagIds); + } + } + + async function createTag() { + if (!newTagName.trim() || !orgId) return; + const color = TAG_COLORS[orgTags.length % TAG_COLORS.length]; + const { data: newTag, error } = await supabase + .from("tags") + .insert({ name: newTagName.trim(), org_id: orgId, color }) + .select() + .single(); + if (!error && newTag) { + orgTags = [...orgTags, newTag as OrgTag]; + if (card) { + await supabase + .from("card_tags") + .insert({ card_id: card.id, tag_id: newTag.id }); + cardTagIds.add(newTag.id); + cardTagIds = new Set(cardTagIds); + } + } + newTagName = ""; + showTagInput = false; + } + async function handleSave() { + if (!isMounted) return; if (mode === "create") { await handleCreate(); return; @@ -178,7 +282,7 @@ .eq("id", columnId) .single(); - const position = (column as any)?.cards?.[0]?.count ?? 0; + const position = (column as any)?.cards?.[0]?.count ?? 0; // join aggregation not typed const { data: newCard, error } = await supabase .from("kanban_cards") @@ -186,6 +290,9 @@ column_id: columnId, title, description: description || null, + priority: priority || null, + due_date: dueDate || null, + assignee_id: assigneeId || null, position, created_by: userId, }) @@ -320,133 +427,97 @@ rows={3} /> - <!-- Assignee, Due Date, Priority Row --> - <div class="grid grid-cols-3 gap-4"> - <!-- Assignee --> - <div class="relative"> - <label class="block text-sm font-medium text-light mb-1" - >Assignee</label - > - <button - type="button" - class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-left text-sm flex items-center gap-2 hover:border-light/40 transition-colors" - onclick={() => - (showAssigneePicker = !showAssigneePicker)} - > - {#if assigneeId && getAssignee(assigneeId)} - {@const assignee = getAssignee(assigneeId)} - <div - class="w-6 h-6 rounded-full bg-primary/20 flex items-center justify-center text-xs text-primary" - > - {(assignee?.profiles.full_name || - assignee?.profiles.email || - "?")[0].toUpperCase()} - </div> - <span class="text-light truncate" - >{assignee?.profiles.full_name || - assignee?.profiles.email}</span - > - {:else} - <div - class="w-6 h-6 rounded-full bg-light/10 flex items-center justify-center" - > - <svg - class="w-3 h-3 text-light/40" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - > - <path - d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" - /> - <circle cx="12" cy="7" r="4" /> - </svg> - </div> - <span class="text-light/40">Unassigned</span> - {/if} - </button> - {#if showAssigneePicker} - <div - class="absolute top-full left-0 right-0 mt-1 bg-dark border border-light/20 rounded-lg shadow-lg z-10 max-h-48 overflow-y-auto" + <!-- Tags --> + <div> + <span + class="px-3 font-bold font-body text-body text-white mb-2 block" + >Tags</span + > + <div class="flex flex-wrap gap-2 items-center"> + {#each orgTags as tag} + <button + type="button" + class="rounded-[4px] px-2 py-1 font-body font-bold text-[13px] leading-none transition-all border-2" + style="background-color: {cardTagIds.has(tag.id) + ? tag.color || '#00A3E0' + : 'transparent'}; color: {cardTagIds.has(tag.id) + ? '#0A121F' + : tag.color || + '#00A3E0'}; border-color: {tag.color || + '#00A3E0'};" + onclick={() => toggleTag(tag.id)} > + {tag.name} + </button> + {/each} + {#if showTagInput} + <div class="flex gap-1 items-center"> + <input + type="text" + class="bg-dark border border-light/20 rounded-lg px-2 py-1 text-sm text-white w-24 focus:outline-none focus:border-primary" + placeholder="Tag name" + bind:value={newTagName} + onkeydown={(e) => + e.key === "Enter" && createTag()} + /> + <button + type="button" + class="text-primary text-sm font-bold hover:text-primary/80" + onclick={createTag} + > + Add + </button> <button - class="w-full px-3 py-2 text-left text-sm text-light/60 hover:bg-light/5 flex items-center gap-2" + type="button" + class="text-light/40 text-sm hover:text-light" onclick={() => { - assigneeId = null; - showAssigneePicker = false; + showTagInput = false; + newTagName = ""; }} > - <div - class="w-6 h-6 rounded-full bg-light/10" - ></div> - Unassigned + Cancel </button> - {#each members as member} - <button - class="w-full px-3 py-2 text-left text-sm hover:bg-light/5 flex items-center gap-2 {assigneeId === - member.user_id - ? 'bg-primary/10 text-primary' - : 'text-light'}" - onclick={() => { - assigneeId = member.user_id; - showAssigneePicker = false; - }} - > - <div - class="w-6 h-6 rounded-full bg-primary/20 flex items-center justify-center text-xs" - > - {(member.profiles.full_name || - member.profiles.email || - "?")[0].toUpperCase()} - </div> - {member.profiles.full_name || - member.profiles.email} - </button> - {/each} </div> + {:else} + <button + type="button" + class="rounded-lg px-2 py-1 text-sm text-light/50 hover:text-light border border-dashed border-light/20 hover:border-light/40 transition-colors" + onclick={() => (showTagInput = true)} + > + + New tag + </button> {/if} </div> + </div> - <!-- Due Date --> - <div> - <label - for="due-date" - class="block text-sm font-medium text-light mb-1" - >Due Date</label - > - <input - id="due-date" - type="date" - bind:value={dueDate} - class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-sm text-light focus:outline-none focus:border-primary" - /> - </div> - - <!-- Priority --> - <div> - <label - for="priority" - class="block text-sm font-medium text-light mb-1" - >Priority</label - > - <select - id="priority" - bind:value={priority} - class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-sm text-light focus:outline-none focus:border-primary" - > - <option value="low">Low</option> - <option value="medium">Medium</option> - <option value="high">High</option> - <option value="urgent">Urgent</option> - </select> - </div> + <!-- Assignee, Due Date, Priority Row --> + <div class="grid grid-cols-3 gap-4"> + <AssigneePicker + label="Assignee" + value={assigneeId} + {members} + onchange={(id) => (assigneeId = id)} + /> + + <Input type="date" label="Due Date" bind:value={dueDate} /> + + <Select + label="Priority" + bind:value={priority} + placeholder="" + options={[ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + { value: "urgent", label: "Urgent" }, + ]} + /> </div> <div> <div class="flex items-center justify-between mb-3"> - <label class="text-sm font-medium text-light" - >Checklist</label + <span class="px-3 font-bold font-body text-body text-white" + >Checklist</span > {#if checklist.length > 0} <span class="text-xs text-light/50" @@ -499,36 +570,26 @@ {item.title} </span> <button + type="button" class="opacity-0 group-hover:opacity-100 p-1 text-light/40 hover:text-error transition-all" onclick={() => deleteItem(item.id)} aria-label="Delete item" > - <svg - class="w-4 h-4" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - > - <line x1="18" y1="6" x2="6" y2="18" /> - <line x1="6" y1="6" x2="18" y2="18" /> - </svg> + <Icon name="close" size={16} /> </button> </div> {/each} </div> - <div class="flex gap-2"> - <input - type="text" - class="flex-1 px-3 py-2 bg-dark border border-light/20 rounded-lg text-sm text-light placeholder:text-light/40 focus:outline-none focus:border-primary" + <div class="flex gap-2 items-end"> + <Input placeholder="Add an item..." bind:value={newItemTitle} onkeydown={(e) => e.key === "Enter" && handleAddItem()} /> <Button - size="sm" + size="md" onclick={handleAddItem} disabled={!newItemTitle.trim()} > @@ -541,8 +602,9 @@ <!-- Comments Section --> {#if mode === "edit"} <div> - <label class="block text-sm font-medium text-light mb-3" - >Comments</label + <span + class="px-3 font-bold font-body text-body text-white mb-3 block" + >Comments</span > <div class="space-y-3 mb-3 max-h-48 overflow-y-auto"> {#each comments as comment} @@ -550,8 +612,8 @@ <div class="w-8 h-8 rounded-full bg-primary/20 flex-shrink-0 flex items-center justify-center text-xs text-primary" > - {((comment.profiles as any)?.full_name || - (comment.profiles as any)?.email || + {(comment.profiles?.full_name || + comment.profiles?.email || "?")[0].toUpperCase()} </div> <div class="flex-1 min-w-0"> @@ -559,10 +621,8 @@ <span class="text-sm font-medium text-light" > - {(comment.profiles as any) - ?.full_name || - (comment.profiles as any) - ?.email || + {comment.profiles?.full_name || + comment.profiles?.email || "Unknown"} </span> <span class="text-xs text-light/40" @@ -583,17 +643,15 @@ </p> {/if} </div> - <div class="flex gap-2"> - <input - type="text" - class="flex-1 px-3 py-2 bg-dark border border-light/20 rounded-lg text-sm text-light placeholder:text-light/40 focus:outline-none focus:border-primary" + <div class="flex gap-2 items-end"> + <Input placeholder="Add a comment..." bind:value={newComment} onkeydown={(e) => e.key === "Enter" && handleAddComment()} /> <Button - size="sm" + size="md" onclick={handleAddComment} disabled={!newComment.trim()} > @@ -614,7 +672,7 @@ <div></div> {/if} <div class="flex gap-2"> - <Button variant="ghost" onclick={onClose}>Cancel</Button> + <Button variant="tertiary" onclick={onClose}>Cancel</Button> <Button onclick={handleSave} loading={isSaving} diff --git a/src/lib/components/kanban/CardMetadata.svelte b/src/lib/components/kanban/CardMetadata.svelte new file mode 100644 index 0000000..27dd9a5 --- /dev/null +++ b/src/lib/components/kanban/CardMetadata.svelte @@ -0,0 +1,90 @@ +<script lang="ts"> + import { Input, Select, AssigneePicker, Badge } from "$lib/components/ui"; + + interface Member { + id: string; + user_id: string; + profiles: { + id: string; + full_name: string | null; + email: string; + avatar_url: string | null; + }; + } + + interface Props { + assigneeId: string | null; + dueDate: string; + priority: string; + members: Member[]; + onAssigneeChange: (id: string | null) => void; + onDueDateChange: (date: string) => void; + onPriorityChange: (priority: string) => void; + } + + let { + assigneeId, + dueDate, + priority, + members, + onAssigneeChange, + onDueDateChange, + onPriorityChange, + }: Props = $props(); + + let dueDateLocal = $state(""); + + $effect(() => { + dueDateLocal = dueDate; + }); + + const priorityColors: Record<string, string> = { + low: "bg-green-500/20 text-green-400", + medium: "bg-yellow-500/20 text-yellow-400", + high: "bg-orange-500/20 text-orange-400", + urgent: "bg-red-500/20 text-red-400", + }; +</script> + +<div class="grid grid-cols-3 gap-3"> + <AssigneePicker + label="Assignee" + value={assigneeId} + {members} + onchange={onAssigneeChange} + /> + + <Input + type="date" + label="Due Date" + bind:value={dueDateLocal} + onchange={() => onDueDateChange(dueDateLocal)} + /> + + <Select + label="Priority" + value={priority} + placeholder="" + options={[ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + { value: "urgent", label: "Urgent" }, + ]} + onchange={(e) => + onPriorityChange((e.target as HTMLSelectElement).value)} + /> +</div> + +<!-- Priority indicator pill --> +{#if priority && priority !== "medium"} + <div class="mt-2"> + <span + class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium {priorityColors[ + priority + ] || priorityColors.medium}" + > + {priority.charAt(0).toUpperCase() + priority.slice(1)} Priority + </span> + </div> +{/if} diff --git a/src/lib/components/kanban/KanbanBoard.svelte b/src/lib/components/kanban/KanbanBoard.svelte index d77d181..fdedc96 100644 --- a/src/lib/components/kanban/KanbanBoard.svelte +++ b/src/lib/components/kanban/KanbanBoard.svelte @@ -1,7 +1,7 @@ <script lang="ts"> import type { ColumnWithCards } from "$lib/api/kanban"; import type { KanbanCard } from "$lib/supabase/types"; - import { Button, Card, Badge } from "$lib/components/ui"; + import KanbanCardComponent from "./KanbanCard.svelte"; interface Props { columns: ColumnWithCards[]; @@ -29,15 +29,11 @@ canEdit = true, }: Props = $props(); - function handleDeleteCard(e: MouseEvent, cardId: string) { - e.stopPropagation(); - if (confirm("Are you sure you want to delete this task?")) { - onDeleteCard?.(cardId); - } - } - let draggedCard = $state<KanbanCard | null>(null); let dragOverColumn = $state<string | null>(null); + let dragOverCardIndex = $state<{ columnId: string; index: number } | null>( + null, + ); function handleDragStart(e: DragEvent, card: KanbanCard) { draggedCard = card; @@ -47,272 +43,193 @@ } } - function handleDragOver(e: DragEvent, columnId: string) { + function handleColumnDragOver(e: DragEvent, columnId: string) { e.preventDefault(); dragOverColumn = columnId; } - function handleDragLeave() { + function handleColumnDragLeave() { dragOverColumn = null; + dragOverCardIndex = null; } - function handleDrop(e: DragEvent, columnId: string) { + function handleCardDragOver(e: DragEvent, columnId: string, index: number) { e.preventDefault(); - dragOverColumn = null; + e.stopPropagation(); + if (!draggedCard) return; - if (draggedCard && draggedCard.column_id !== columnId) { - const column = columns.find((c) => c.id === columnId); - const newPosition = column?.cards.length ?? 0; - onCardMove?.(draggedCard.id, columnId, newPosition); - } - draggedCard = null; + // Determine if we're in the top or bottom half of the card + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + const midY = rect.top + rect.height / 2; + const dropIndex = e.clientY < midY ? index : index + 1; + + dragOverColumn = columnId; + dragOverCardIndex = { columnId, index: dropIndex }; } - function formatDueDate(dateStr: string | null): string { - if (!dateStr) return ""; - const date = new Date(dateStr); - const now = new Date(); - const diff = date.getTime() - now.getTime(); - const days = Math.ceil(diff / (1000 * 60 * 60 * 24)); + function handleDrop(e: DragEvent, columnId: string) { + e.preventDefault(); + const targetIndex = dragOverCardIndex; + dragOverColumn = null; + dragOverCardIndex = null; - if (days < 0) return "Overdue"; - if (days === 0) return "Today"; - if (days === 1) return "Tomorrow"; - return date.toLocaleDateString(); - } + if (!draggedCard) return; + + const column = columns.find((c) => c.id === columnId); + if (!column) { + draggedCard = null; + return; + } - function getDueDateColor( - dateStr: string | null, - ): "error" | "warning" | "default" { - if (!dateStr) return "default"; - const date = new Date(dateStr); - const now = new Date(); - const diff = date.getTime() - now.getTime(); - const days = Math.ceil(diff / (1000 * 60 * 60 * 24)); + let newPosition: number; + if (targetIndex && targetIndex.columnId === columnId) { + newPosition = targetIndex.index; + // If moving within the same column and the card is above the target, adjust + if (draggedCard.column_id === columnId) { + const currentIndex = column.cards.findIndex( + (c) => c.id === draggedCard!.id, + ); + if (currentIndex !== -1 && currentIndex < newPosition) { + newPosition = Math.max(0, newPosition - 1); + } + // No-op if dropping in the same position + if (currentIndex === newPosition) { + draggedCard = null; + return; + } + } + } else { + newPosition = column.cards.length; + } - if (days < 0) return "error"; - if (days <= 2) return "warning"; - return "default"; + onCardMove?.(draggedCard.id, columnId, newPosition); + draggedCard = null; } </script> -<div class="flex gap-4 overflow-x-auto pb-4 min-h-[500px] scrollbar-visible"> +<div class="flex gap-2 overflow-x-auto pb-4 h-full kanban-scroll"> {#each columns as column} <div - class="flex-shrink-0 w-72 bg-surface/80 backdrop-blur-sm rounded-xl p-3 flex flex-col max-h-[calc(100vh-200px)] border border-light/10 shadow-lg {dragOverColumn === + class="flex-shrink-0 w-[256px] bg-background rounded-[32px] px-4 py-5 flex flex-col gap-4 max-h-full {dragOverColumn === column.id - ? 'ring-2 ring-primary bg-primary/5' + ? 'ring-2 ring-primary' : ''}" - ondragover={(e) => handleDragOver(e, column.id)} - ondragleave={handleDragLeave} + ondragover={(e) => handleColumnDragOver(e, column.id)} + ondragleave={handleColumnDragLeave} ondrop={(e) => handleDrop(e, column.id)} role="list" > - <div class="flex items-center justify-between mb-3 px-1"> - <h3 class="font-medium text-light flex items-center gap-2"> - {column.name} + <!-- Column Header --> + <div class="flex items-center gap-2 p-1 rounded-[32px]"> + <div class="flex items-center gap-2 flex-1 min-w-0"> + <h3 class="font-heading text-h4 text-white truncate"> + {column.name} + </h3> + <div + class="bg-dark flex items-center justify-center px-1.5 py-0.5 rounded-[8px] shrink-0" + > + <span class="font-heading text-h6 text-white" + >{column.cards.length}</span + > + </div> + </div> + <button + type="button" + class="p-1 hover:bg-night rounded-lg transition-colors shrink-0" + onclick={() => onDeleteColumn?.(column.id)} + aria-label="Column options" + > <span - class="text-xs text-light/50 bg-light/10 px-1.5 py-0.5 rounded" + class="material-symbols-rounded text-light/50" + style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;" > - {column.cards.length} + more_horiz </span> - </h3> - <div class="flex items-center gap-1"> - {#if column.color} + </button> + </div> + + <!-- Cards --> + <div class="flex-1 overflow-y-auto flex flex-col gap-0"> + {#each column.cards as card, cardIndex} + <!-- Drop indicator before card --> + {#if draggedCard && dragOverCardIndex?.columnId === column.id && dragOverCardIndex?.index === cardIndex && draggedCard.id !== card.id} <div - class="w-3 h-3 rounded-full" - style="background-color: {column.color}" + class="h-1 bg-primary rounded-full mx-2 my-1 transition-all" ></div> {/if} - {#if canEdit} - <button - class="p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-error/20 text-light/40 hover:text-error transition-all" - onclick={() => onDeleteColumn?.(column.id)} - title="Delete column" - > - <svg - class="w-3.5 h-3.5" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - > - <polyline points="3,6 5,6 21,6" /> - <path - d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" - /> - </svg> - </button> - {/if} - </div> - </div> - - <div class="flex-1 overflow-y-auto space-y-2"> - {#each column.cards as card} <div - class="group bg-dark rounded-lg p-3 cursor-pointer hover:ring-1 hover:ring-light/20 transition-all relative" - class:opacity-50={draggedCard?.id === card.id} - draggable={canEdit} - ondragstart={(e) => handleDragStart(e, card)} - onclick={() => onCardClick?.(card)} - onkeydown={(e) => - e.key === "Enter" && onCardClick?.(card)} - role="listitem" - tabindex="0" + class="mb-2" + ondragover={(e) => + handleCardDragOver(e, column.id, cardIndex)} > - {#if canEdit} - <button - class="absolute top-2 right-2 p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-error/20 text-light/40 hover:text-error transition-all" - onclick={(e) => handleDeleteCard(e, card.id)} - title="Delete task" - > - <svg - class="w-3.5 h-3.5" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - > - <polyline points="3,6 5,6 21,6" /> - <path - d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" - /> - </svg> - </button> - {/if} - {#if card.color} - <div - class="w-full h-1 rounded-full mb-2" - style="background-color: {card.color}" - ></div> - {/if} - <p class="text-sm text-light pr-6">{card.title}</p> - {#if card.description} - <p class="text-xs text-light/50 mt-1 line-clamp-2"> - {card.description} - </p> - {/if} - {#if card.due_date || (card as any).checklist_total > 0 || (card as any).assignee_id} - <div class="mt-2 flex items-center gap-2 flex-wrap"> - {#if card.due_date} - <Badge - size="sm" - variant={getDueDateColor(card.due_date)} - > - {formatDueDate(card.due_date)} - </Badge> - {/if} - {#if (card as any).checklist_total > 0} - <span - class="text-xs flex items-center gap-1 {( - card as any - ).checklist_done === - (card as any).checklist_total - ? 'text-success' - : 'text-light/50'}" - > - <svg - class="w-3 h-3" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - > - <polyline - points="9,11 12,14 22,4" - /> - <path - d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" - /> - </svg> - {(card as any).checklist_done}/{( - card as any - ).checklist_total} - </span> - {/if} - {#if (card as any).assignee_id} - <div - class="w-5 h-5 rounded-full bg-primary/30 flex items-center justify-center text-[10px] text-primary ml-auto" - title="Assigned" - > - <svg - class="w-3 h-3" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - > - <path - d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" - /> - <circle cx="12" cy="7" r="4" /> - </svg> - </div> - {/if} - </div> - {/if} + <KanbanCardComponent + {card} + isDragging={draggedCard?.id === card.id} + draggable={canEdit} + ondragstart={(e) => handleDragStart(e, card)} + onclick={() => onCardClick?.(card)} + ondelete={canEdit + ? (id) => onDeleteCard?.(id) + : undefined} + /> </div> {/each} + <!-- Drop indicator at end of column --> + {#if draggedCard && dragOverCardIndex?.columnId === column.id && dragOverCardIndex?.index === column.cards.length} + <div + class="h-1 bg-primary rounded-full mx-2 my-1 transition-all" + ></div> + {/if} </div> + <!-- Add Card Button (secondary style) --> {#if canEdit} <button - class="mt-2 w-full py-2 text-sm text-light/50 hover:text-light hover:bg-light/5 rounded-lg transition-colors flex items-center justify-center gap-1" + type="button" + class="w-full py-3 border-[3px] border-primary text-primary font-heading text-h5 rounded-[32px] hover:bg-primary/10 transition-colors" onclick={() => onAddCard?.(column.id)} > - <svg - class="w-4 h-4" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - > - <line x1="12" y1="5" x2="12" y2="19" /> - <line x1="5" y1="12" x2="19" y2="12" /> - </svg> Add card </button> {/if} </div> {/each} + <!-- Add Column Button --> {#if canEdit} <button - class="flex-shrink-0 w-72 h-12 bg-light/5 hover:bg-light/10 rounded-xl flex items-center justify-center gap-2 text-light/50 hover:text-light transition-colors" + type="button" + class="flex-shrink-0 w-[256px] h-12 border-[3px] border-primary/30 hover:border-primary rounded-[32px] flex items-center justify-center gap-2 text-primary/50 hover:text-primary transition-colors" onclick={() => onAddColumn?.()} > - <svg - class="w-5 h-5" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" + <span + class="material-symbols-rounded" + style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;" > - <line x1="12" y1="5" x2="12" y2="19" /> - <line x1="5" y1="12" x2="19" y2="12" /> - </svg> + add + </span> Add column </button> {/if} </div> <style> - .scrollbar-visible { + .kanban-scroll { scrollbar-width: thin; scrollbar-color: rgba(229, 230, 240, 0.3) transparent; } - .scrollbar-visible::-webkit-scrollbar { + .kanban-scroll::-webkit-scrollbar { height: 8px; } - .scrollbar-visible::-webkit-scrollbar-track { - background: rgba(229, 230, 240, 0.1); + .kanban-scroll::-webkit-scrollbar-track { + background: transparent; border-radius: 4px; } - .scrollbar-visible::-webkit-scrollbar-thumb { + .kanban-scroll::-webkit-scrollbar-thumb { background: rgba(229, 230, 240, 0.3); border-radius: 4px; } - .scrollbar-visible::-webkit-scrollbar-thumb:hover { + .kanban-scroll::-webkit-scrollbar-thumb:hover { background: rgba(229, 230, 240, 0.5); } </style> diff --git a/src/lib/components/kanban/KanbanCard.svelte b/src/lib/components/kanban/KanbanCard.svelte index bd8d1c6..bdbad96 100644 --- a/src/lib/components/kanban/KanbanCard.svelte +++ b/src/lib/components/kanban/KanbanCard.svelte @@ -1,17 +1,24 @@ <script lang="ts"> import type { KanbanCard as KanbanCardType } from "$lib/supabase/types"; - import { Badge } from "$lib/components/ui"; + import { Avatar } from "$lib/components/ui"; - // Extended card type with optional new fields from migration - interface ExtendedCard extends KanbanCardType { - priority?: "low" | "medium" | "high" | "urgent" | null; - assignee_id?: string | null; + interface Tag { + id: string; + name: string; + color: string; } interface Props { - card: ExtendedCard; + card: KanbanCardType & { + tags?: Tag[]; + checklist_done?: number; + checklist_total?: number; + assignee_name?: string | null; + assignee_avatar?: string | null; + }; isDragging?: boolean; onclick?: () => void; + ondelete?: (cardId: string) => void; draggable?: boolean; ondragstart?: (e: DragEvent) => void; } @@ -20,114 +27,125 @@ card, isDragging = false, onclick, + ondelete, draggable = true, ondragstart, }: Props = $props(); - function formatDueDate(dateStr: string | null): string { - if (!dateStr) return ""; - const date = new Date(dateStr); - const now = new Date(); - const diff = date.getTime() - now.getTime(); - const days = Math.ceil(diff / (1000 * 60 * 60 * 24)); - - if (days < 0) return "Overdue"; - if (days === 0) return "Today"; - if (days === 1) return "Tomorrow"; - return date.toLocaleDateString(); + function handleDelete(e: MouseEvent) { + e.stopPropagation(); + if (confirm("Are you sure you want to delete this card?")) { + ondelete?.(card.id); + } } - function getDueDateVariant( - dateStr: string | null, - ): "error" | "warning" | "default" { - if (!dateStr) return "default"; + function formatDueDate(dateStr: string | null): string { + if (!dateStr) return ""; const date = new Date(dateStr); - const now = new Date(); - const diff = date.getTime() - now.getTime(); - const days = Math.ceil(diff / (1000 * 60 * 60 * 24)); - - if (days < 0) return "error"; - if (days <= 2) return "warning"; - return "default"; + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); } - function getPriorityColor(priority: string | null): string { - switch (priority) { - case "urgent": - return "#E03D00"; - case "high": - return "#FFAB00"; - case "medium": - return "#00A3E0"; - case "low": - return "#33E000"; - default: - return "#E5E6F0"; - } - } + const hasFooter = $derived( + !!card.due_date || + (card.checklist_total ?? 0) > 0 || + !!card.assignee_id, + ); </script> -<div - class="bg-night rounded-[16px] p-3 cursor-pointer hover:ring-1 hover:ring-primary/30 transition-all group" +<button + type="button" + class="bg-night rounded-[16px] p-2 cursor-pointer hover:ring-1 hover:ring-primary/30 transition-all group w-full text-left overflow-clip flex flex-col gap-2 relative" class:opacity-50={isDragging} {draggable} {ondragstart} {onclick} - onkeydown={(e) => e.key === "Enter" && onclick?.()} - role="listitem" - tabindex="0" > - <!-- Priority indicator --> - {#if card.priority} - <div - class="w-full h-1 rounded-full mb-2" - style="background-color: {getPriorityColor(card.priority)}" - ></div> - {:else if card.color} - <div - class="w-full h-1 rounded-full mb-2" - style="background-color: {card.color}" - ></div> + <!-- Delete button (top-right, visible on hover) --> + {#if ondelete} + <button + type="button" + class="absolute top-1 right-1 p-1 rounded-lg opacity-0 group-hover:opacity-100 hover:bg-error/20 transition-all z-10" + onclick={handleDelete} + aria-label="Delete card" + > + <span + class="material-symbols-rounded text-light/40 hover:text-error" + style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;" + > + delete + </span> + </button> {/if} - <!-- Title --> - <p class="text-sm font-medium text-light">{card.title}</p> - - <!-- Description --> - {#if card.description} - <p class="text-xs text-light/50 mt-1 line-clamp-2"> - {card.description} - </p> + <!-- Tags / Chips --> + {#if card.tags && card.tags.length > 0} + <div class="flex gap-[10px] items-start flex-wrap"> + {#each card.tags as tag} + <span + class="rounded-[4px] px-1 py-[4px] font-body font-bold text-[14px] text-night leading-none overflow-clip" + style="background-color: {tag.color || '#00A3E0'}" + > + {tag.name} + </span> + {/each} + </div> {/if} - <!-- Footer with metadata --> - <div class="mt-3 flex items-center justify-between gap-2"> - <!-- Due date --> - {#if card.due_date} - <Badge size="sm" variant={getDueDateVariant(card.due_date)}> - <svg - class="w-3 h-3 mr-1" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - > - <rect x="3" y="4" width="18" height="18" rx="2" /> - <line x1="16" y1="2" x2="16" y2="6" /> - <line x1="8" y1="2" x2="8" y2="6" /> - <line x1="3" y1="10" x2="21" y2="10" /> - </svg> - {formatDueDate(card.due_date)} - </Badge> - {/if} + <!-- Title --> + <p class="font-body text-body text-white w-full leading-none"> + {card.title} + </p> - <!-- Assignee placeholder --> - {#if card.assignee_id} - <div - class="w-6 h-6 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xs font-medium" - > - A + <!-- Bottom row: details + avatar --> + {#if hasFooter} + <div class="flex items-center justify-between w-full"> + <div class="flex gap-1 items-center"> + <!-- Due date --> + {#if card.due_date} + <div class="flex items-center"> + <span + class="material-symbols-rounded text-light p-1" + style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;" + > + calendar_today + </span> + <span + class="font-body text-[12px] text-light leading-none" + > + {formatDueDate(card.due_date)} + </span> + </div> + {/if} + + <!-- Checklist --> + {#if (card.checklist_total ?? 0) > 0} + <div class="flex items-center"> + <span + class="material-symbols-rounded text-light p-1" + style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;" + > + check_box + </span> + <span + class="font-body text-[12px] text-light leading-none" + > + {card.checklist_done ?? 0}/{card.checklist_total} + </span> + </div> + {/if} </div> - {/if} - </div> -</div> + + <!-- Assignee avatar --> + {#if card.assignee_id} + <Avatar + name={card.assignee_name || "?"} + src={card.assignee_avatar} + size="sm" + /> + {/if} + </div> + {/if} +</button> diff --git a/src/lib/components/kanban/index.ts b/src/lib/components/kanban/index.ts index c23f21a..afbed20 100644 --- a/src/lib/components/kanban/index.ts +++ b/src/lib/components/kanban/index.ts @@ -1,3 +1,6 @@ export { default as KanbanBoard } from './KanbanBoard.svelte'; export { default as CardDetailModal } from './CardDetailModal.svelte'; export { default as KanbanCard } from './KanbanCard.svelte'; +export { default as CardChecklist } from './CardChecklist.svelte'; +export { default as CardComments } from './CardComments.svelte'; +export { default as CardMetadata } from './CardMetadata.svelte'; diff --git a/src/lib/components/settings/SettingsGeneral.svelte b/src/lib/components/settings/SettingsGeneral.svelte new file mode 100644 index 0000000..a3881c5 --- /dev/null +++ b/src/lib/components/settings/SettingsGeneral.svelte @@ -0,0 +1,216 @@ +<script lang="ts"> + import { Button, Input, Avatar } from "$lib/components/ui"; + import type { SupabaseClient } from "@supabase/supabase-js"; + import type { Database } from "$lib/supabase/types"; + import { toasts } from "$lib/stores/toast.svelte"; + import { invalidateAll } from "$app/navigation"; + + interface Props { + supabase: SupabaseClient<Database>; + org: { + id: string; + name: string; + slug: string; + avatar_url?: string | null; + }; + isOwner: boolean; + onLeave: () => void; + onDelete: () => void; + } + + let { supabase, org, isOwner, onLeave, onDelete }: Props = $props(); + + let orgName = $state(org.name); + let orgSlug = $state(org.slug); + let avatarUrl = $state(org.avatar_url ?? null); + let isSaving = $state(false); + let isUploading = $state(false); + let avatarInput = $state<HTMLInputElement | null>(null); + + $effect(() => { + orgName = org.name; + orgSlug = org.slug; + avatarUrl = org.avatar_url ?? null; + }); + + async function handleAvatarUpload(e: Event) { + const input = e.target as HTMLInputElement; + const file = input.files?.[0]; + if (!file) return; + + // Validate file + if (!file.type.startsWith("image/")) { + toasts.error("Please select an image file."); + return; + } + if (file.size > 2 * 1024 * 1024) { + toasts.error("Image must be under 2MB."); + return; + } + + isUploading = true; + try { + const ext = file.name.split(".").pop() || "png"; + const path = `org-avatars/${org.id}.${ext}`; + + const { error: uploadError } = await supabase.storage + .from("avatars") + .upload(path, file, { upsert: true }); + + if (uploadError) { + toasts.error("Failed to upload avatar."); + return; + } + + const { data: urlData } = supabase.storage + .from("avatars") + .getPublicUrl(path); + + const publicUrl = `${urlData.publicUrl}?t=${Date.now()}`; + + const { error: dbError } = await supabase + .from("organizations") + .update({ avatar_url: publicUrl }) + .eq("id", org.id); + + if (dbError) { + toasts.error("Failed to save avatar URL."); + return; + } + + avatarUrl = publicUrl; + await invalidateAll(); + toasts.success("Avatar updated."); + } catch (err) { + toasts.error("Avatar upload failed."); + } finally { + isUploading = false; + input.value = ""; + } + } + + async function removeAvatar() { + isSaving = true; + const { error } = await supabase + .from("organizations") + .update({ avatar_url: null }) + .eq("id", org.id); + + if (error) { + toasts.error("Failed to remove avatar."); + } else { + avatarUrl = null; + await invalidateAll(); + toasts.success("Avatar removed."); + } + isSaving = false; + } + + async function saveGeneralSettings() { + isSaving = true; + const { error } = await supabase + .from("organizations") + .update({ name: orgName, slug: orgSlug }) + .eq("id", org.id); + + if (error) { + toasts.error("Failed to save settings."); + } else if (orgSlug !== org.slug) { + window.location.href = `/${orgSlug}/settings`; + } else { + toasts.success("Settings saved."); + } + isSaving = false; + } +</script> + +<div class="flex flex-col gap-8"> + <!-- Organization Details --> + <h2 class="font-heading text-h2 text-white">Organization details</h2> + + <div class="flex flex-col gap-8"> + <div class="flex flex-col gap-4"> + <!-- Avatar Upload --> + <div class="flex flex-col gap-2"> + <span class="font-body text-body-sm text-light">Avatar</span> + <div class="flex items-center gap-4"> + <Avatar name={orgName || "?"} src={avatarUrl} size="lg" /> + <div class="flex gap-2"> + <input + type="file" + accept="image/*" + class="hidden" + bind:this={avatarInput} + onchange={handleAvatarUpload} + /> + <Button + variant="secondary" + size="sm" + onclick={() => avatarInput?.click()} + loading={isUploading} + > + Upload + </Button> + {#if avatarUrl} + <Button + variant="tertiary" + size="sm" + onclick={removeAvatar} + > + Remove + </Button> + {/if} + </div> + </div> + </div> + <Input + label="Name" + bind:value={orgName} + placeholder="Organization name" + /> + <Input + label="URL slug (yoursite.com/...)" + bind:value={orgSlug} + placeholder="my-org" + /> + <div> + <Button onclick={saveGeneralSettings} loading={isSaving} + >Save Changes</Button + > + </div> + </div> + + <!-- Danger Zone --> + {#if isOwner} + <div class="flex flex-col gap-4"> + <h4 class="font-heading text-h4 text-white">Danger Zone</h4> + <p class="font-body text-body text-white"> + Permanently delete this organization and all its data. + </p> + <div> + <Button variant="danger" onclick={onDelete} + >Delete Organization</Button + > + </div> + </div> + {/if} + + <!-- Leave Organization (non-owners) --> + {#if !isOwner} + <div class="flex flex-col gap-4"> + <h4 class="font-heading text-h4 text-white"> + Leave Organization + </h4> + <p class="font-body text-body text-white"> + Leave this organization. You will need to be re-invited to + rejoin. + </p> + <div> + <Button variant="secondary" onclick={onLeave} + >Leave {org.name}</Button + > + </div> + </div> + {/if} + </div> +</div> diff --git a/src/lib/components/settings/index.ts b/src/lib/components/settings/index.ts new file mode 100644 index 0000000..c44a2a5 --- /dev/null +++ b/src/lib/components/settings/index.ts @@ -0,0 +1 @@ +export { default as SettingsGeneral } from './SettingsGeneral.svelte'; diff --git a/src/lib/components/ui/AssigneePicker.svelte b/src/lib/components/ui/AssigneePicker.svelte new file mode 100644 index 0000000..9beb3a2 --- /dev/null +++ b/src/lib/components/ui/AssigneePicker.svelte @@ -0,0 +1,108 @@ +<script lang="ts"> + import { Avatar } from "$lib/components/ui"; + + interface Member { + id: string; + user_id: string; + profiles: { + id: string; + full_name: string | null; + email: string; + avatar_url: string | null; + }; + } + + interface Props { + value: string | null; + members: Member[]; + label?: string; + onchange: (userId: string | null) => void; + } + + let { value, members, label, onchange }: Props = $props(); + + let isOpen = $state(false); + + function getAssignee(id: string | null) { + if (!id) return null; + return members.find((m) => m.user_id === id); + } + + const assignee = $derived(getAssignee(value)); + + function select(userId: string | null) { + onchange(userId); + isOpen = false; + } +</script> + +<div class="flex flex-col gap-3 w-full"> + {#if label} + <span class="px-3 font-bold font-body text-body text-white"> + {label} + </span> + {/if} + + <div class="relative"> + <button + type="button" + class="w-full p-3 bg-background text-white rounded-[32px] min-w-[192px] + font-medium font-input text-body + focus:outline-none focus:ring-2 focus:ring-primary + transition-colors text-left flex items-center gap-3" + onclick={() => (isOpen = !isOpen)} + > + {#if assignee} + <Avatar + name={assignee.profiles.full_name || + assignee.profiles.email} + size="sm" + /> + <span class="truncate"> + {assignee.profiles.full_name || assignee.profiles.email} + </span> + {:else} + <Avatar name="?" size="sm" /> + <span class="text-white/40">Unassigned</span> + {/if} + </button> + + {#if isOpen} + <!-- svelte-ignore a11y_no_static_element_interactions --> + <!-- svelte-ignore a11y_click_events_have_key_events --> + <div + class="fixed inset-0 z-40" + onclick={() => (isOpen = false)} + ></div> + <div + class="absolute top-full left-0 right-0 mt-2 bg-night border border-light/10 rounded-2xl shadow-xl z-50 max-h-48 overflow-y-auto py-1" + > + <button + type="button" + class="w-full px-4 py-2.5 text-left text-body-md text-white/60 hover:bg-dark transition-colors flex items-center gap-3" + onclick={() => select(null)} + > + <Avatar name="?" size="sm" /> + Unassigned + </button> + {#each members as member} + <button + type="button" + class="w-full px-4 py-2.5 text-left text-body-md hover:bg-dark transition-colors flex items-center gap-3 + {value === member.user_id ? 'bg-primary/10 text-primary' : 'text-white'}" + onclick={() => select(member.user_id)} + > + <Avatar + name={member.profiles.full_name || + member.profiles.email} + size="sm" + /> + <span class="truncate"> + {member.profiles.full_name || member.profiles.email} + </span> + </button> + {/each} + </div> + {/if} + </div> +</div> diff --git a/src/lib/components/ui/Avatar.svelte b/src/lib/components/ui/Avatar.svelte index f4558d6..e3dd16b 100644 --- a/src/lib/components/ui/Avatar.svelte +++ b/src/lib/components/ui/Avatar.svelte @@ -1,92 +1,35 @@ <script lang="ts"> interface Props { + name: string; src?: string | null; - name?: string; - size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'; - status?: 'online' | 'offline' | 'away' | 'busy' | null; + size?: "sm" | "md" | "lg" | "xl"; } - let { src = null, name = '?', size = 'md', status = null }: Props = $props(); + let { name, src = null, size = "md" }: Props = $props(); - const sizeClasses = { - xs: 'w-6 h-6 text-xs', - sm: 'w-8 h-8 text-sm', - md: 'w-10 h-10 text-base', - lg: 'w-12 h-12 text-lg', - xl: 'w-16 h-16 text-xl', - '2xl': 'w-20 h-20 text-2xl' - }; - - const statusSizes = { - xs: 'w-2 h-2', - sm: 'w-2.5 h-2.5', - md: 'w-3 h-3', - lg: 'w-3.5 h-3.5', - xl: 'w-4 h-4', - '2xl': 'w-5 h-5' - }; + const initial = $derived(name ? name[0].toUpperCase() : "?"); - const statusColors = { - online: 'bg-success', - offline: 'bg-light/30', - away: 'bg-warning', - busy: 'bg-error' + const sizes = { + sm: { box: "w-8 h-8", text: "text-body", radius: "rounded-[16px]" }, + md: { box: "w-12 h-12", text: "text-h3", radius: "rounded-[24px]" }, + lg: { box: "w-16 h-16", text: "text-h2", radius: "rounded-[32px]" }, + xl: { box: "w-24 h-24", text: "text-h1", radius: "rounded-[48px]" }, }; - - function getInitials(name: string): string { - return name - .split(' ') - .map((part) => part[0]) - .join('') - .toUpperCase() - .slice(0, 2); - } - - function getColorFromName(name: string): string { - const colors = [ - 'bg-red-500', - 'bg-orange-500', - 'bg-amber-500', - 'bg-yellow-500', - 'bg-lime-500', - 'bg-green-500', - 'bg-emerald-500', - 'bg-teal-500', - 'bg-cyan-500', - 'bg-sky-500', - 'bg-blue-500', - 'bg-indigo-500', - 'bg-violet-500', - 'bg-purple-500', - 'bg-fuchsia-500', - 'bg-pink-500' - ]; - let hash = 0; - for (let i = 0; i < name.length; i++) { - hash = name.charCodeAt(i) + ((hash << 5) - hash); - } - return colors[Math.abs(hash) % colors.length]; - } </script> -<div class="relative inline-block"> +{#if src} + <img + {src} + alt={name} + class="{sizes[size].box} {sizes[size].radius} object-cover shrink-0" + /> +{:else} <div - class="rounded-full flex items-center justify-center font-medium text-white overflow-hidden {sizeClasses[ - size - ]} {!src ? getColorFromName(name) : 'bg-surface'}" + class="{sizes[size].box} {sizes[size] + .radius} bg-primary flex items-center justify-center shrink-0" > - {#if src} - <img {src} alt={name} class="w-full h-full object-cover" /> - {:else} - {getInitials(name)} - {/if} + <span class="font-heading {sizes[size].text} text-night leading-none"> + {initial} + </span> </div> - - {#if status} - <div - class="absolute bottom-0 right-0 rounded-full border-2 border-dark {statusSizes[size]} {statusColors[ - status - ]}" - ></div> - {/if} -</div> +{/if} diff --git a/src/lib/components/ui/Badge.svelte b/src/lib/components/ui/Badge.svelte index bb1d85e..20d54f8 100644 --- a/src/lib/components/ui/Badge.svelte +++ b/src/lib/components/ui/Badge.svelte @@ -1,30 +1,40 @@ <script lang="ts"> - import type { Snippet } from 'svelte'; + import type { Snippet } from "svelte"; interface Props { - variant?: 'default' | 'primary' | 'success' | 'warning' | 'error' | 'info'; - size?: 'sm' | 'md' | 'lg'; + variant?: + | "default" + | "primary" + | "success" + | "warning" + | "error" + | "info"; + size?: "sm" | "md" | "lg"; children: Snippet; } - let { variant = 'default', size = 'md', children }: Props = $props(); + let { variant = "default", size = "md", children }: Props = $props(); const variantClasses = { - default: 'bg-light/10 text-light', - primary: 'bg-primary/20 text-primary', - success: 'bg-success/20 text-success', - warning: 'bg-warning/20 text-warning', - error: 'bg-error/20 text-error', - info: 'bg-info/20 text-info' + default: "bg-light/10 text-light", + primary: "bg-primary/20 text-primary", + success: "bg-success/20 text-success", + warning: "bg-warning/20 text-warning", + error: "bg-error/20 text-error", + info: "bg-info/20 text-info", }; const sizeClasses = { - sm: 'px-1.5 py-0.5 text-xs', - md: 'px-2 py-0.5 text-sm', - lg: 'px-2.5 py-1 text-sm' + sm: "px-1.5 py-0.5 text-xs", + md: "px-2 py-0.5 text-sm", + lg: "px-2.5 py-1 text-sm", }; </script> -<span class="inline-flex items-center font-medium rounded-full {variantClasses[variant]} {sizeClasses[size]}"> +<span + class="inline-flex items-center font-medium rounded-full {variantClasses[ + variant + ]} {sizeClasses[size]}" +> {@render children()} </span> diff --git a/src/lib/components/ui/Button.svelte b/src/lib/components/ui/Button.svelte index 62d561c..21b7a85 100644 --- a/src/lib/components/ui/Button.svelte +++ b/src/lib/components/ui/Button.svelte @@ -2,14 +2,16 @@ import type { Snippet } from "svelte"; interface Props { - variant?: "primary" | "secondary" | "ghost" | "danger" | "success"; + variant?: "primary" | "secondary" | "tertiary" | "danger" | "success"; size?: "sm" | "md" | "lg"; disabled?: boolean; loading?: boolean; - type?: "button" | "submit" | "reset"; fullWidth?: boolean; + icon?: string; + type?: "button" | "submit" | "reset"; onclick?: (e: MouseEvent) => void; - children: Snippet; + children?: Snippet; + class?: string; } let { @@ -17,59 +19,100 @@ size = "md", disabled = false, loading = false, - type = "button", fullWidth = false, + icon, + type = "button", onclick, children, + class: className, }: Props = $props(); - // Figma-matched base styles const baseClasses = - "inline-flex items-center justify-center font-bold transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-30 disabled:cursor-not-allowed rounded-[32px]"; + "inline-flex items-center justify-center gap-2 font-heading rounded-[32px] overflow-clip transition-all cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"; - // Figma-matched variant styles const variantClasses = { primary: - "bg-primary text-night hover:brightness-110 active:brightness-90", + "btn-primary bg-primary text-night hover:btn-primary-hover active:btn-primary-active", secondary: - "border-2 border-primary text-primary bg-transparent hover:bg-primary/10 active:bg-primary/20", - ghost: "bg-primary/10 text-primary hover:bg-primary/20 active:bg-primary/30", - danger: "bg-error text-night hover:brightness-110 active:brightness-90", + "bg-transparent text-primary border-solid border-primary hover:bg-primary/10 active:bg-primary/20", + tertiary: + "bg-primary/10 text-primary hover:bg-primary/20 active:bg-primary/30", + danger: "btn-primary bg-error text-white hover:btn-primary-hover active:btn-primary-active", success: - "bg-success text-night hover:brightness-110 active:brightness-90", + "btn-primary bg-success text-night hover:btn-primary-hover active:btn-primary-active", }; - // Figma-matched size styles (px values from Figma) const sizeClasses = { - sm: "px-3 py-1.5 text-sm gap-1.5 min-w-[96px]", - md: "px-4 py-2 text-base gap-2 min-w-[128px]", - lg: "px-5 py-3 text-xl gap-2.5 min-w-[128px]", + sm: "min-w-[36px] p-[10px] text-btn-sm", + md: "min-w-[48px] p-[12px] text-btn-md", + lg: "min-w-[56px] p-[16px] text-btn-lg", }; + + const borderClasses = { + sm: "border-2", + md: "border-3", + lg: "border-4", + }; + + const secondaryBorder = $derived( + variant === "secondary" ? borderClasses[size] : "", + ); + + const iconSize = $derived(size === "sm" ? 16 : size === "lg" ? 20 : 18); </script> <button {type} - class="{baseClasses} {variantClasses[variant]} {sizeClasses[size]}" + class="{baseClasses} {variantClasses[variant]} {sizeClasses[ + size + ]} {secondaryBorder} {className ?? ''}" class:w-full={fullWidth} disabled={disabled || loading} {onclick} > {#if loading} - <svg class="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none"> - <circle - class="opacity-25" - cx="12" - cy="12" - r="10" - stroke="currentColor" - stroke-width="4" - ></circle> - <path - class="opacity-75" - fill="currentColor" - d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" - ></path> - </svg> + <span + class="material-symbols-rounded animate-spin" + style="font-size: {iconSize}px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' {iconSize};" + > + progress_activity + </span> + {:else if icon} + <span + class="material-symbols-rounded" + style="font-size: {iconSize}px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' {iconSize};" + > + {icon} + </span> + {/if} + {#if children} + {@render children()} {/if} - {@render children()} </button> + +<style> + .btn-primary:hover:not(:disabled) { + background-image: linear-gradient( + rgba(255, 255, 255, 0.2), + rgba(255, 255, 255, 0.2) + ); + } + .btn-primary-hover:not(:disabled) { + background-image: linear-gradient( + rgba(255, 255, 255, 0.2), + rgba(255, 255, 255, 0.2) + ); + } + .btn-primary:active:not(:disabled) { + background-image: linear-gradient( + rgba(14, 15, 25, 0.2), + rgba(14, 15, 25, 0.2) + ); + } + .btn-primary-active:not(:disabled) { + background-image: linear-gradient( + rgba(14, 15, 25, 0.2), + rgba(14, 15, 25, 0.2) + ); + } +</style> diff --git a/src/lib/components/ui/CalendarDay.svelte b/src/lib/components/ui/CalendarDay.svelte new file mode 100644 index 0000000..3406068 --- /dev/null +++ b/src/lib/components/ui/CalendarDay.svelte @@ -0,0 +1,35 @@ +<script lang="ts"> + import type { Snippet } from "svelte"; + + interface Props { + day?: number | string; + isHeader?: boolean; + isPast?: boolean; + events?: Snippet; + } + + let { day, isHeader = false, isPast = false, events }: Props = $props(); +</script> + +{#if isHeader} + <div + class="flex flex-col items-center justify-center px-2 pt-2 pb-4 w-full" + > + <span class="font-heading text-h4 text-white text-center truncate"> + {day} + </span> + </div> +{:else} + <div + class="flex flex-col items-start gap-2 bg-night px-4 py-5 min-h-[82px] w-full {isPast + ? 'opacity-50' + : ''}" + > + <span class="font-body text-body text-white truncate w-full"> + {day} + </span> + {#if events} + {@render events()} + {/if} + </div> +{/if} diff --git a/src/lib/components/ui/Chip.svelte b/src/lib/components/ui/Chip.svelte new file mode 100644 index 0000000..c60d8dc --- /dev/null +++ b/src/lib/components/ui/Chip.svelte @@ -0,0 +1,26 @@ +<script lang="ts"> + import type { Snippet } from "svelte"; + + interface Props { + variant?: "primary" | "success" | "warning" | "error" | "default"; + children: Snippet; + } + + let { variant = "primary", children }: Props = $props(); + + const variantClasses = { + primary: "bg-primary text-background", + success: "bg-success text-background", + warning: "bg-warning text-background", + error: "bg-error text-background", + default: "bg-dark text-light", + }; +</script> + +<div + class="inline-flex items-center justify-center px-1 py-1 rounded-[4px] overflow-hidden font-bold font-body text-body-md {variantClasses[ + variant + ]}" +> + {@render children()} +</div> diff --git a/src/lib/components/ui/ContentHeader.svelte b/src/lib/components/ui/ContentHeader.svelte new file mode 100644 index 0000000..a333869 --- /dev/null +++ b/src/lib/components/ui/ContentHeader.svelte @@ -0,0 +1,42 @@ +<script lang="ts"> + import type { Snippet } from "svelte"; + import Button from "./Button.svelte"; + + interface Props { + title: string; + actionLabel?: string; + onAction?: () => void; + onMore?: () => void; + children?: Snippet; + } + + let { title, actionLabel, onAction, onMore, children }: Props = $props(); +</script> + +<div class="flex flex-wrap items-center gap-2 p-1 rounded-[32px] w-full"> + <div class="flex-1 min-w-0"> + <h1 class="font-heading text-h1 text-white truncate">{title}</h1> + </div> + {#if children} + {@render children()} + {/if} + {#if actionLabel && onAction} + <Button variant="primary" onclick={onAction}> + {actionLabel} + </Button> + {/if} + {#if onMore} + <button + type="button" + class="p-1 flex items-center justify-center hover:bg-dark/50 rounded-full transition-colors" + onclick={onMore} + > + <span + class="material-symbols-rounded text-light" + style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;" + > + more_horiz + </span> + </button> + {/if} +</div> diff --git a/src/lib/components/ui/Dropdown.svelte b/src/lib/components/ui/Dropdown.svelte new file mode 100644 index 0000000..105c44c --- /dev/null +++ b/src/lib/components/ui/Dropdown.svelte @@ -0,0 +1,64 @@ +<script lang="ts"> + import type { Snippet } from "svelte"; + + interface Props { + trigger: Snippet; + children: Snippet; + align?: "left" | "right"; + width?: "auto" | "sm" | "md" | "lg"; + } + + let { trigger, children, align = "left", width = "auto" }: Props = $props(); + + let isOpen = $state(false); + + const alignClasses = { + left: "left-0", + right: "right-0", + }; + + const widthClasses = { + auto: "min-w-[10rem]", + sm: "w-48", + md: "w-56", + lg: "w-64", + }; + + function handleClickOutside(e: MouseEvent) { + const target = e.target as HTMLElement; + if (!target.closest(".dropdown-container")) { + isOpen = false; + } + } + + function handleKeydown(e: KeyboardEvent) { + if (e.key === "Escape") isOpen = false; + } +</script> + +<svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} /> + +<div class="relative dropdown-container"> + <button + type="button" + class="w-full text-left" + onclick={() => (isOpen = !isOpen)} + aria-expanded={isOpen} + aria-haspopup="true" + > + {@render trigger()} + </button> + + {#if isOpen} + <div + class=" + absolute z-50 mt-2 py-1 bg-surface border border-light/10 rounded-xl shadow-xl + animate-in fade-in slide-in-from-top-2 duration-150 + {alignClasses[align]} + {widthClasses[width]} + " + > + {@render children()} + </div> + {/if} +</div> diff --git a/src/lib/components/ui/DropdownItem.svelte b/src/lib/components/ui/DropdownItem.svelte new file mode 100644 index 0000000..f2e65eb --- /dev/null +++ b/src/lib/components/ui/DropdownItem.svelte @@ -0,0 +1,31 @@ +<script lang="ts"> + import type { Snippet } from 'svelte'; + + interface Props { + children: Snippet; + onclick?: () => void; + icon?: Snippet; + danger?: boolean; + disabled?: boolean; + } + + let { children, onclick, icon, danger = false, disabled = false }: Props = $props(); +</script> + +<button + type="button" + {onclick} + {disabled} + class=" + w-full flex items-center gap-3 px-3 py-2 text-sm text-left transition-colors + disabled:opacity-50 disabled:cursor-not-allowed + {danger ? 'text-error hover:bg-error/10' : 'text-light hover:bg-light/5'} + " +> + {#if icon} + <span class="w-4 h-4 shrink-0 opacity-60"> + {@render icon()} + </span> + {/if} + <span class="flex-1">{@render children()}</span> +</button> diff --git a/src/lib/components/ui/EmptyState.svelte b/src/lib/components/ui/EmptyState.svelte new file mode 100644 index 0000000..c4b4668 --- /dev/null +++ b/src/lib/components/ui/EmptyState.svelte @@ -0,0 +1,29 @@ +<script lang="ts"> + import type { Snippet } from 'svelte'; + + interface Props { + icon?: Snippet; + title: string; + description?: string; + action?: Snippet; + } + + let { icon, title, description, action }: Props = $props(); +</script> + +<div class="flex flex-col items-center justify-center py-12 px-4 text-center"> + {#if icon} + <div class="w-12 h-12 sm:w-16 sm:h-16 text-light/30 mb-4"> + {@render icon()} + </div> + {/if} + <h3 class="text-base sm:text-lg font-medium text-light mb-1">{title}</h3> + {#if description} + <p class="text-sm text-light/50 max-w-sm mb-4">{description}</p> + {/if} + {#if action} + <div class="mt-2"> + {@render action()} + </div> + {/if} +</div> diff --git a/src/lib/components/ui/Icon.svelte b/src/lib/components/ui/Icon.svelte new file mode 100644 index 0000000..62942cb --- /dev/null +++ b/src/lib/components/ui/Icon.svelte @@ -0,0 +1,16 @@ +<script lang="ts"> + interface Props { + name: string; + size?: number; + class?: string; + } + + let { name, size = 24, class: className = "" }: Props = $props(); +</script> + +<span + class="material-symbols-rounded {className}" + style="font-size: {size}px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' {size};" +> + {name} +</span> diff --git a/src/lib/components/ui/IconButton.svelte b/src/lib/components/ui/IconButton.svelte new file mode 100644 index 0000000..c83e555 --- /dev/null +++ b/src/lib/components/ui/IconButton.svelte @@ -0,0 +1,59 @@ +<script lang="ts"> + import type { Snippet } from 'svelte'; + + interface Props { + children: Snippet; + onclick?: () => void; + variant?: 'ghost' | 'subtle' | 'solid'; + size?: 'sm' | 'md' | 'lg'; + disabled?: boolean; + title?: string; + class?: string; + } + + let { + children, + onclick, + variant = 'ghost', + size = 'md', + disabled = false, + title, + class: className = '', + }: Props = $props(); + + const variantClasses = { + ghost: 'hover:bg-light/10 text-light/60 hover:text-light', + subtle: 'bg-light/5 hover:bg-light/10 text-light/60 hover:text-light', + solid: 'bg-primary/20 hover:bg-primary/30 text-primary', + }; + + const sizeClasses = { + sm: 'w-7 h-7', + md: 'w-9 h-9', + lg: 'w-11 h-11', + }; + + const iconSizeClasses = { + sm: '[&>svg]:w-4 [&>svg]:h-4', + md: '[&>svg]:w-5 [&>svg]:h-5', + lg: '[&>svg]:w-6 [&>svg]:h-6', + }; +</script> + +<button + type="button" + {onclick} + {disabled} + {title} + aria-label={title} + class=" + inline-flex items-center justify-center rounded-lg transition-colors + disabled:opacity-50 disabled:cursor-not-allowed + {variantClasses[variant]} + {sizeClasses[size]} + {iconSizeClasses[size]} + {className} + " +> + {@render children()} +</button> diff --git a/src/lib/components/ui/Input.svelte b/src/lib/components/ui/Input.svelte index 3c62008..65a576b 100644 --- a/src/lib/components/ui/Input.svelte +++ b/src/lib/components/ui/Input.svelte @@ -1,6 +1,15 @@ <script lang="ts"> interface Props { - type?: "text" | "password" | "email" | "url" | "search" | "number"; + type?: + | "text" + | "password" + | "email" + | "url" + | "search" + | "number" + | "tel" + | "date" + | "datetime-local"; value?: string; placeholder?: string; label?: string; @@ -9,7 +18,9 @@ disabled?: boolean; required?: boolean; autocomplete?: AutoFill; + icon?: string; oninput?: (e: Event) => void; + onchange?: (e: Event) => void; onkeydown?: (e: KeyboardEvent) => void; } @@ -23,7 +34,9 @@ disabled = false, required = false, autocomplete, + icon, oninput, + onchange, onkeydown, }: Props = $props(); @@ -33,67 +46,72 @@ const inputType = $derived(isPassword && showPassword ? "text" : type); </script> -<div class="flex flex-col gap-3"> +<div class="flex flex-col gap-3 w-full"> {#if label} - <label for={inputId} class="px-3 font-heading text-xl text-white"> + <label + for={inputId} + class="px-3 font-bold font-body text-body text-white" + > {#if required}<span class="text-error">* </span>{/if}{label} </label> {/if} - <div class="relative"> - <input - id={inputId} - type={inputType} - bind:value - {placeholder} - {disabled} - {required} - {autocomplete} - {oninput} - {onkeydown} - class="w-full px-3 py-3 bg-night text-white rounded-[32px] min-w-[192px] - placeholder:text-white/40 - focus:outline-none focus:ring-2 focus:ring-primary - disabled:opacity-30 disabled:cursor-not-allowed - transition-colors" - class:ring-1={error} - class:ring-error={error} - /> - {#if isPassword} - <button - type="button" - class="absolute right-3 top-1/2 -translate-y-1/2 text-white/40 hover:text-white transition-colors" - onclick={() => (showPassword = !showPassword)} + <div class="flex items-center gap-3 w-full"> + {#if icon} + <div + class="w-8 h-8 rounded-full bg-light flex items-center justify-center shrink-0" > - {#if showPassword} - <svg - class="w-5 h-5" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - > - <path - d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" - /> - <line x1="1" y1="1" x2="23" y2="23" /> - </svg> - {:else} - <svg - class="w-5 h-5" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - > - <path - d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" - /> - <circle cx="12" cy="12" r="3" /> - </svg> - {/if} - </button> + <span + class="material-symbols-rounded text-background" + style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;" + > + {icon} + </span> + </div> {/if} + + <div class="relative flex-1"> + <input + id={inputId} + type={inputType} + bind:value + {placeholder} + {disabled} + {required} + {autocomplete} + {oninput} + {onchange} + {onkeydown} + class=" + w-full p-3 bg-background text-white rounded-[32px] min-w-[192px] + font-medium font-input text-body + placeholder:text-white/40 + focus:outline-none focus:ring-2 focus:ring-primary + disabled:opacity-30 disabled:cursor-not-allowed + transition-colors + " + class:ring-1={error} + class:ring-error={error} + class:pr-12={isPassword} + /> + {#if isPassword} + <button + type="button" + class="absolute right-3 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-colors" + onclick={() => (showPassword = !showPassword)} + aria-label={showPassword + ? "Hide password" + : "Show password"} + > + <span + class="material-symbols-rounded" + style="font-size: 22px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;" + > + {showPassword ? "visibility_off" : "visibility"} + </span> + </button> + {/if} + </div> </div> {#if error} diff --git a/src/lib/components/ui/KanbanColumn.svelte b/src/lib/components/ui/KanbanColumn.svelte new file mode 100644 index 0000000..17fd233 --- /dev/null +++ b/src/lib/components/ui/KanbanColumn.svelte @@ -0,0 +1,61 @@ +<script lang="ts"> + import type { Snippet } from "svelte"; + import Button from "./Button.svelte"; + + interface Props { + title: string; + count?: number; + onAddCard?: () => void; + onMore?: () => void; + children?: Snippet; + } + + let { title, count = 0, onAddCard, onMore, children }: Props = $props(); +</script> + +<div + class="bg-background flex flex-col gap-4 items-start overflow-hidden px-4 py-5 rounded-[32px] w-64 h-[512px]" +> + <!-- Header --> + <div class="flex items-center gap-2 p-1 rounded-[32px] w-full"> + <div class="flex-1 flex items-center gap-2 min-w-0"> + <span class="font-heading text-h4 text-white truncate">{title}</span + > + <div + class="bg-dark flex items-center justify-center p-1 rounded-lg shrink-0" + > + <span class="font-heading text-h6 text-white">{count}</span> + </div> + </div> + {#if onMore} + <button + type="button" + class="p-1 flex items-center justify-center hover:bg-dark/50 rounded-full transition-colors" + onclick={onMore} + > + <span + class="material-symbols-rounded text-light" + style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;" + > + more_horiz + </span> + </button> + {/if} + </div> + + <!-- Cards container --> + <div + class="flex-1 flex flex-col gap-2 items-start overflow-y-auto w-full min-h-0" + > + {#if children} + {@render children()} + {/if} + </div> + + <!-- Add button --> + {#if onAddCard} + <Button variant="secondary" fullWidth onclick={onAddCard}> + Add card + </Button> + {/if} +</div> diff --git a/src/lib/components/ui/ListItem.svelte b/src/lib/components/ui/ListItem.svelte new file mode 100644 index 0000000..4f9019f --- /dev/null +++ b/src/lib/components/ui/ListItem.svelte @@ -0,0 +1,60 @@ +<script lang="ts"> + import type { Snippet } from "svelte"; + + interface Props { + variant?: "default" | "hover" | "active"; + icon?: string; + size?: "sm" | "md"; + onclick?: () => void; + children: Snippet; + } + + let { + variant = "default", + icon, + size = "md", + onclick, + children, + }: Props = $props(); + + const baseClasses = + "flex items-center gap-2 overflow-hidden rounded-[32px] transition-colors cursor-pointer"; + + const variantClasses = { + default: "bg-night hover:bg-dark", + hover: "bg-dark", + active: "bg-primary text-background", + }; + + const sizeClasses = { + sm: "h-10 pl-1 pr-2 py-1", + md: "h-10 pl-1 pr-2 py-1", + }; +</script> + +<button + type="button" + class="{baseClasses} {variantClasses[variant]} {sizeClasses[size]} w-full" + {onclick} +> + {#if icon} + <div class="w-8 h-8 flex items-center justify-center p-1 shrink-0"> + <span + class="material-symbols-rounded {variant === 'active' + ? 'text-background' + : 'text-light'}" + style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;" + > + {icon} + </span> + </div> + {/if} + <span + class="flex-1 text-left font-body text-body truncate {variant === + 'active' + ? 'text-background' + : 'text-white'}" + > + {@render children()} + </span> +</button> diff --git a/src/lib/components/ui/Logo.svelte b/src/lib/components/ui/Logo.svelte new file mode 100644 index 0000000..1599c78 --- /dev/null +++ b/src/lib/components/ui/Logo.svelte @@ -0,0 +1,39 @@ +<script lang="ts"> + interface Props { + size?: "sm" | "md"; + } + + let { size = "md" }: Props = $props(); + + const sizeClasses = { + sm: "w-10 h-10", + md: "w-12 h-12", + }; +</script> + +<div class="flex items-center justify-center {sizeClasses[size]}"> + <svg + viewBox="0 0 38 21" + fill="none" + xmlns="http://www.w3.org/2000/svg" + class="w-full h-auto" + > + <!-- Root logo SVG paths matching Figma --> + <path + d="M0 0.5C0 0.224 0.224 0 0.5 0H37.5C37.776 0 38 0.224 38 0.5V12.203C38 12.479 37.776 12.703 37.5 12.703H0.5C0.224 12.703 0 12.479 0 12.203V0.5Z" + fill="#00A3E0" + fill-opacity="0.2" + /> + <!-- Left eye --> + <circle cx="11.5" cy="7.5" r="5" fill="#00A3E0" /> + <!-- Right eye --> + <circle cx="23.5" cy="7.5" r="5" fill="#00A3E0" /> + <!-- Mouth/smile --> + <path + d="M12.25 15.04C12.25 15.04 15 20.25 18.75 20.25C22.5 20.25 25.25 15.04 25.25 15.04" + stroke="#00A3E0" + stroke-width="2" + stroke-linecap="round" + /> + </svg> +</div> diff --git a/src/lib/components/ui/Modal.svelte b/src/lib/components/ui/Modal.svelte index 0646070..01fbf18 100644 --- a/src/lib/components/ui/Modal.svelte +++ b/src/lib/components/ui/Modal.svelte @@ -1,25 +1,27 @@ <script lang="ts"> - import type { Snippet } from 'svelte'; + import type { Snippet } from "svelte"; + import { fade, fly } from "svelte/transition"; + import { cubicOut } from "svelte/easing"; interface Props { isOpen: boolean; onClose: () => void; title?: string; - size?: 'sm' | 'md' | 'lg' | 'xl'; + size?: "sm" | "md" | "lg" | "xl"; children: Snippet; } - let { isOpen, onClose, title, size = 'md', children }: Props = $props(); + let { isOpen, onClose, title, size = "md", children }: Props = $props(); const sizeClasses = { - sm: 'max-w-sm', - md: 'max-w-md', - lg: 'max-w-lg', - xl: 'max-w-xl' + sm: "max-w-sm", + md: "max-w-md", + lg: "max-w-lg", + xl: "max-w-xl", }; function handleKeydown(e: KeyboardEvent) { - if (e.key === 'Escape') { + if (e.key === "Escape") { onClose(); } } @@ -39,23 +41,40 @@ onkeydown={handleKeydown} role="dialog" aria-modal="true" - aria-labelledby={title ? 'modal-title' : undefined} + aria-labelledby={title ? "modal-title" : undefined} tabindex="-1" + transition:fade={{ duration: 150 }} > <div - class="bg-surface rounded-2xl w-full mx-4 {sizeClasses[size]} shadow-xl" + class="bg-surface rounded-2xl w-full mx-4 {sizeClasses[ + size + ]} shadow-xl" onclick={(e) => e.stopPropagation()} role="document" + transition:fly={{ y: 10, duration: 200, easing: cubicOut }} > {#if title} - <div class="flex items-center justify-between px-6 py-4 border-b border-light/10"> - <h2 id="modal-title" class="text-lg font-semibold text-light">{title}</h2> + <div + class="flex items-center justify-between px-6 py-4 border-b border-light/10" + > + <h2 + id="modal-title" + class="text-lg font-semibold text-light" + > + {title} + </h2> <button class="w-8 h-8 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded-lg transition-colors" onclick={onClose} aria-label="Close" > - <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + <svg + class="w-5 h-5" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + > <line x1="18" y1="6" x2="6" y2="18" /> <line x1="6" y1="6" x2="18" y2="18" /> </svg> diff --git a/src/lib/components/ui/OrgHeader.svelte b/src/lib/components/ui/OrgHeader.svelte new file mode 100644 index 0000000..0502fcb --- /dev/null +++ b/src/lib/components/ui/OrgHeader.svelte @@ -0,0 +1,30 @@ +<script lang="ts"> + import Avatar from "./Avatar.svelte"; + + interface Props { + name: string; + role?: string; + size?: "sm" | "md"; + isHover?: boolean; + } + + let { name, role, size = "md", isHover = false }: Props = $props(); +</script> + +<div + class="flex items-center gap-2 p-1 rounded-[32px] w-full transition-colors {isHover + ? 'bg-dark' + : 'bg-night'}" +> + <Avatar {name} size={size === "sm" ? "sm" : "md"} /> + {#if size !== "sm"} + <div class="flex-1 flex flex-col min-w-0"> + <span class="font-heading text-h3 text-white truncate">{name}</span> + {#if role} + <span class="font-body text-body-sm text-white truncate" + >{role}</span + > + {/if} + </div> + {/if} +</div> diff --git a/src/lib/components/ui/Select.svelte b/src/lib/components/ui/Select.svelte index 0159752..a481eef 100644 --- a/src/lib/components/ui/Select.svelte +++ b/src/lib/components/ui/Select.svelte @@ -10,8 +10,10 @@ label?: string; placeholder?: string; error?: string; + hint?: string; disabled?: boolean; required?: boolean; + onchange?: (e: Event) => void; } let { @@ -20,18 +22,22 @@ label, placeholder = "Select...", error, + hint, disabled = false, required = false, + onchange, }: Props = $props(); const inputId = `select-${crypto.randomUUID().slice(0, 8)}`; </script> -<div class="flex flex-col gap-1.5"> +<div class="flex flex-col gap-3 w-full"> {#if label} - <label for={inputId} class="text-sm font-medium text-light/80"> - {label} - {#if required}<span class="text-primary">*</span>{/if} + <label + for={inputId} + class="px-3 font-bold font-body text-body text-white" + > + {#if required}<span class="text-error">* </span>{/if}{label} </label> {/if} @@ -40,21 +46,27 @@ bind:value {disabled} {required} - class="w-full px-4 py-2.5 bg-surface text-light rounded-xl border border-light/20 - focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary - disabled:opacity-50 disabled:cursor-not-allowed - transition-colors appearance-none cursor-pointer" - class:border-error={error} - class:placeholder-shown={!value} + {onchange} + class="w-full p-3 bg-background text-white rounded-[32px] min-w-[192px] + font-medium font-input text-body + focus:outline-none focus:ring-2 focus:ring-primary + disabled:opacity-30 disabled:cursor-not-allowed + transition-colors appearance-none cursor-pointer" + class:ring-1={error} + class:ring-error={error} > - <option value="" disabled>{placeholder}</option> + {#if placeholder} + <option value="" disabled>{placeholder}</option> + {/if} {#each options as option} <option value={option.value}>{option.label}</option> {/each} </select> {#if error} - <p class="text-sm text-error">{error}</p> + <p class="text-sm text-error px-3">{error}</p> + {:else if hint} + <p class="text-sm text-white/50 px-3">{hint}</p> {/if} </div> diff --git a/src/lib/components/ui/Skeleton.svelte b/src/lib/components/ui/Skeleton.svelte new file mode 100644 index 0000000..f628955 --- /dev/null +++ b/src/lib/components/ui/Skeleton.svelte @@ -0,0 +1,72 @@ +<script lang="ts"> + interface Props { + class?: string; + variant?: 'text' | 'circular' | 'rectangular' | 'card'; + width?: string; + height?: string; + lines?: number; + } + + let { + class: className = '', + variant = 'text', + width, + height, + lines = 1, + }: Props = $props(); + + const variantClasses: Record<string, string> = { + text: 'h-4 rounded', + circular: 'rounded-full', + rectangular: 'rounded-lg', + card: 'rounded-2xl', + }; + + const defaultSizes: Record<string, { w: string; h: string }> = { + text: { w: '100%', h: '1rem' }, + circular: { w: '2.5rem', h: '2.5rem' }, + rectangular: { w: '100%', h: '4rem' }, + card: { w: '100%', h: '8rem' }, + }; + + const finalWidth = width || defaultSizes[variant].w; + const finalHeight = height || defaultSizes[variant].h; +</script> + +{#if variant === 'text' && lines > 1} + <div class="space-y-2 {className}"> + {#each Array(lines) as _, i} + <div + class="skeleton {variantClasses[variant]}" + style="width: {i === lines - 1 ? '75%' : finalWidth}; height: {finalHeight}" + ></div> + {/each} + </div> +{:else} + <div + class="skeleton {variantClasses[variant]} {className}" + style="width: {finalWidth}; height: {finalHeight}" + ></div> +{/if} + +<style> + .skeleton { + background: linear-gradient( + 90deg, + rgb(var(--color-light) / 0.06) 0%, + rgb(var(--color-light) / 0.12) 50%, + rgb(var(--color-light) / 0.06) 100% + ); + background-size: 200% 100%; + animation: shimmer 1.5s ease-in-out infinite; + } + + @keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } + } +</style> diff --git a/src/lib/components/ui/Textarea.svelte b/src/lib/components/ui/Textarea.svelte index a8079e9..fdbd17a 100644 --- a/src/lib/components/ui/Textarea.svelte +++ b/src/lib/components/ui/Textarea.svelte @@ -8,36 +8,38 @@ disabled?: boolean; required?: boolean; rows?: number; - resize?: 'none' | 'vertical' | 'horizontal' | 'both'; + resize?: "none" | "vertical" | "horizontal" | "both"; } let { - value = $bindable(''), - placeholder = '', + value = $bindable(""), + placeholder = "", label, error, hint, disabled = false, required = false, rows = 3, - resize = 'vertical' + resize = "vertical", }: Props = $props(); const inputId = `textarea-${crypto.randomUUID().slice(0, 8)}`; const resizeClasses = { - none: 'resize-none', - vertical: 'resize-y', - horizontal: 'resize-x', - both: 'resize' + none: "resize-none", + vertical: "resize-y", + horizontal: "resize-x", + both: "resize", }; </script> -<div class="flex flex-col gap-1.5"> +<div class="flex flex-col gap-3 w-full"> {#if label} - <label for={inputId} class="text-sm font-medium text-light/80"> - {label} - {#if required}<span class="text-primary">*</span>{/if} + <label + for={inputId} + class="px-3 font-bold font-body text-body text-white" + > + {#if required}<span class="text-error">* </span>{/if}{label} </label> {/if} @@ -48,19 +50,19 @@ {disabled} {required} {rows} - class="w-full px-4 py-2.5 bg-surface text-light rounded-xl border border-light/20 - placeholder:text-light/40 - focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary - disabled:opacity-50 disabled:cursor-not-allowed - transition-colors {resizeClasses[resize]}" - class:border-error={error} - class:focus:border-error={error} - class:focus:ring-error={error} + class="w-full p-3 bg-background text-white rounded-2xl min-w-[192px] + font-medium font-input text-body + placeholder:text-white/40 + focus:outline-none focus:ring-2 focus:ring-primary + disabled:opacity-30 disabled:cursor-not-allowed + transition-colors {resizeClasses[resize]}" + class:ring-1={error} + class:ring-error={error} ></textarea> {#if error} - <p class="text-sm text-error">{error}</p> + <p class="text-sm text-error px-3">{error}</p> {:else if hint} - <p class="text-sm text-light/50">{hint}</p> + <p class="text-sm text-white/50 px-3">{hint}</p> {/if} </div> diff --git a/src/lib/components/ui/ToastContainer.svelte b/src/lib/components/ui/ToastContainer.svelte index 3b4d815..785ef22 100644 --- a/src/lib/components/ui/ToastContainer.svelte +++ b/src/lib/components/ui/ToastContainer.svelte @@ -1,6 +1,6 @@ <script lang="ts"> - import { toasts } from '$lib/stores/toast'; - import Toast from './Toast.svelte'; + import { toasts } from "$lib/stores/toast.svelte"; + import Toast from "./Toast.svelte"; </script> <div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm"> diff --git a/src/lib/components/ui/index.ts b/src/lib/components/ui/index.ts index 4d2c339..ee5e8c4 100644 --- a/src/lib/components/ui/index.ts +++ b/src/lib/components/ui/index.ts @@ -10,3 +10,17 @@ export { default as Spinner } from './Spinner.svelte'; export { default as Toggle } from './Toggle.svelte'; export { default as Toast } from './Toast.svelte'; export { default as ToastContainer } from './ToastContainer.svelte'; +export { default as Skeleton } from './Skeleton.svelte'; +export { default as EmptyState } from './EmptyState.svelte'; +export { default as IconButton } from './IconButton.svelte'; +export { default as Dropdown } from './Dropdown.svelte'; +export { default as DropdownItem } from './DropdownItem.svelte'; +export { default as Chip } from './Chip.svelte'; +export { default as ListItem } from './ListItem.svelte'; +export { default as CalendarDay } from './CalendarDay.svelte'; +export { default as OrgHeader } from './OrgHeader.svelte'; +export { default as KanbanColumn } from './KanbanColumn.svelte'; +export { default as Logo } from './Logo.svelte'; +export { default as ContentHeader } from './ContentHeader.svelte'; +export { default as Icon } from './Icon.svelte'; +export { default as AssigneePicker } from './AssigneePicker.svelte'; diff --git a/src/lib/index.ts b/src/lib/index.ts deleted file mode 100644 index 856f2b6..0000000 --- a/src/lib/index.ts +++ /dev/null @@ -1 +0,0 @@ -// place files you want to import through the `$lib` alias in this folder. diff --git a/src/lib/stores/auth.svelte.ts b/src/lib/stores/auth.svelte.ts deleted file mode 100644 index 35e729a..0000000 --- a/src/lib/stores/auth.svelte.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { Session, User } from '@supabase/supabase-js'; - -class AuthStore { - session = $state<Session | null>(null); - user = $state<User | null>(null); - isLoading = $state(true); - - setSession(session: Session | null, user: User | null) { - this.session = session; - this.user = user; - this.isLoading = false; - } - - get isAuthenticated() { - return !!this.session && !!this.user; - } - - get userId() { - return this.user?.id ?? null; - } - - get email() { - return this.user?.email ?? null; - } -} - -export const auth = new AuthStore(); diff --git a/src/lib/stores/documents.svelte.ts b/src/lib/stores/documents.svelte.ts deleted file mode 100644 index 4612aba..0000000 --- a/src/lib/stores/documents.svelte.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { Document } from '$lib/supabase/types'; -import type { DocumentWithChildren } from '$lib/api/documents'; -import { buildDocumentTree } from '$lib/api/documents'; - -class DocumentsStore { - documents = $state<Document[]>([]); - currentDocument = $state<Document | null>(null); - isLoading = $state(false); - isSaving = $state(false); - - setDocuments(docs: Document[]) { - this.documents = docs; - } - - setCurrentDocument(doc: Document | null) { - this.currentDocument = doc; - } - - addDocument(doc: Document) { - this.documents = [...this.documents, doc]; - } - - updateDocument(id: string, updates: Partial<Document>) { - this.documents = this.documents.map((doc) => - doc.id === id ? { ...doc, ...updates } : doc - ); - if (this.currentDocument?.id === id) { - this.currentDocument = { ...this.currentDocument, ...updates }; - } - } - - removeDocument(id: string) { - this.documents = this.documents.filter((doc) => doc.id !== id); - if (this.currentDocument?.id === id) { - this.currentDocument = null; - } - } - - get tree(): DocumentWithChildren[] { - return buildDocumentTree(this.documents); - } - - get folders() { - return this.documents.filter((doc) => doc.type === 'folder'); - } - - get files() { - return this.documents.filter((doc) => doc.type === 'document'); - } -} - -export const docs = new DocumentsStore(); diff --git a/src/lib/stores/index.ts b/src/lib/stores/index.ts deleted file mode 100644 index 93a81a1..0000000 --- a/src/lib/stores/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { auth } from './auth.svelte'; -export { orgs, type OrgWithRole } from './organizations.svelte'; diff --git a/src/lib/stores/organizations.svelte.ts b/src/lib/stores/organizations.svelte.ts deleted file mode 100644 index 97b38d3..0000000 --- a/src/lib/stores/organizations.svelte.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { Organization, OrgMember, MemberRole } from '$lib/supabase/types'; - -export interface OrgWithRole extends Organization { - role: MemberRole; - memberCount?: number; -} - -class OrganizationsStore { - organizations = $state<OrgWithRole[]>([]); - currentOrg = $state<OrgWithRole | null>(null); - members = $state<(OrgMember & { profile?: { email: string; full_name: string | null; avatar_url: string | null } })[]>([]); - isLoading = $state(false); - - setOrganizations(orgs: OrgWithRole[]) { - this.organizations = orgs; - } - - setCurrentOrg(org: OrgWithRole | null) { - this.currentOrg = org; - } - - setMembers(members: typeof this.members) { - this.members = members; - } - - addOrganization(org: OrgWithRole) { - this.organizations = [...this.organizations, org]; - } - - updateOrganization(id: string, updates: Partial<Organization>) { - this.organizations = this.organizations.map((org) => - org.id === id ? { ...org, ...updates } : org - ); - if (this.currentOrg?.id === id) { - this.currentOrg = { ...this.currentOrg, ...updates }; - } - } - - removeOrganization(id: string) { - this.organizations = this.organizations.filter((org) => org.id !== id); - if (this.currentOrg?.id === id) { - this.currentOrg = null; - } - } - - get hasOrganizations() { - return this.organizations.length > 0; - } - - get isOwnerOrAdmin() { - return this.currentOrg?.role === 'owner' || this.currentOrg?.role === 'admin'; - } - - get canEdit() { - return ['owner', 'admin', 'editor'].includes(this.currentOrg?.role ?? ''); - } -} - -export const orgs = new OrganizationsStore(); diff --git a/src/lib/stores/theme.ts b/src/lib/stores/theme.ts deleted file mode 100644 index 41fcd73..0000000 --- a/src/lib/stores/theme.ts +++ /dev/null @@ -1,222 +0,0 @@ -/** - * Theme Store - Manages app theme (dark/light mode and accent colors) - * Inspired by root-v2 - */ -import { writable, derived } from 'svelte/store'; -import { browser } from '$app/environment'; - -export type ThemeMode = 'dark' | 'light' | 'system'; - -export interface ThemeColors { - primary: string; - name: string; -} - -export const PRESET_COLORS: ThemeColors[] = [ - { name: 'Cyan', primary: '#00A3E0' }, - { name: 'Purple', primary: '#8B5CF6' }, - { name: 'Pink', primary: '#EC4899' }, - { name: 'Green', primary: '#10B981' }, - { name: 'Orange', primary: '#F97316' }, - { name: 'Red', primary: '#EF4444' }, - { name: 'Blue', primary: '#3B82F6' }, - { name: 'Indigo', primary: '#6366F1' }, -]; - -const THEME_STORAGE_KEY = 'root_theme'; - -interface ThemeState { - mode: ThemeMode; - primaryColor: string; -} - -const defaultTheme: ThemeState = { - mode: 'dark', - primaryColor: '#00A3E0', -}; - -// Convert hex to HSL -function hexToHSL(hex: string): { h: number; s: number; l: number } { - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); - if (!result) return { h: 0, s: 0, l: 0 }; - - const r = parseInt(result[1], 16) / 255; - const g = parseInt(result[2], 16) / 255; - const b = parseInt(result[3], 16) / 255; - - const max = Math.max(r, g, b); - const min = Math.min(r, g, b); - let h = 0; - let s = 0; - const l = (max + min) / 2; - - if (max !== min) { - const d = max - min; - s = l > 0.5 ? d / (2 - max - min) : d / (max + min); - switch (max) { - case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break; - case g: h = ((b - r) / d + 2) / 6; break; - case b: h = ((r - g) / d + 4) / 6; break; - } - } - - return { h: h * 360, s: s * 100, l: l * 100 }; -} - -// Convert HSL to hex -function hslToHex(h: number, s: number, l: number): string { - s /= 100; - l /= 100; - const a = s * Math.min(l, 1 - l); - const f = (n: number) => { - const k = (n + h / 30) % 12; - const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); - return Math.round(255 * color).toString(16).padStart(2, '0'); - }; - return `#${f(0)}${f(8)}${f(4)}`; -} - -// Generate derived colors from primary -function generateDerivedColors(primary: string, isDark: boolean) { - const { h, s } = hexToHSL(primary); - - if (isDark) { - return { - night: hslToHex(h, Math.min(s, 40), 6), - dark: hslToHex(h, Math.min(s, 35), 10), - surface: hslToHex(h, Math.min(s, 30), 12), - background: hslToHex(h, Math.min(s, 30), 3), - light: '#e5e6f0', - text: '#ffffff', - }; - } else { - const lightSat = Math.min(s, 30); - return { - night: hslToHex(h, lightSat, 95), - dark: hslToHex(h, lightSat, 90), - surface: hslToHex(h, lightSat, 98), - background: hslToHex(h, lightSat, 100), - light: '#1a1a2e', - text: '#0a121f', - }; - } -} - -function getEffectiveMode(mode: ThemeMode): 'dark' | 'light' { - if (mode === 'system') { - if (!browser) return 'dark'; - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; - } - return mode; -} - -function loadTheme(): ThemeState { - if (!browser) return defaultTheme; - - try { - const stored = localStorage.getItem(THEME_STORAGE_KEY); - if (stored) { - return { ...defaultTheme, ...JSON.parse(stored) }; - } - } catch (e) { - console.warn('Failed to load theme:', e); - } - return defaultTheme; -} - -function saveTheme(theme: ThemeState): void { - if (!browser) return; - localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(theme)); -} - -export function applyTheme(state: ThemeState): void { - if (!browser) return; - - const root = document.documentElement; - const effectiveMode = getEffectiveMode(state.mode); - - // Set mode class - root.classList.remove('dark', 'light'); - root.classList.add(effectiveMode); - - // Set CSS custom properties - root.style.setProperty('--color-primary', state.primaryColor); - - // Calculate hover variant - const { h, s, l } = hexToHSL(state.primaryColor); - root.style.setProperty('--color-primary-hover', hslToHex(h, s, Math.min(100, l + 10))); - - // Generate and apply derived colors - const derived = generateDerivedColors(state.primaryColor, effectiveMode === 'dark'); - root.style.setProperty('--color-night', derived.night); - root.style.setProperty('--color-dark', derived.dark); - root.style.setProperty('--color-surface', derived.surface); - root.style.setProperty('--color-background', derived.background); - root.style.setProperty('--color-light', derived.light); - root.style.setProperty('--color-text', derived.text); -} - -function createThemeStore() { - const { subscribe, set, update } = writable<ThemeState>(loadTheme()); - - return { - subscribe, - setMode: (mode: ThemeMode) => { - update(state => { - const newState = { ...state, mode }; - saveTheme(newState); - applyTheme(newState); - return newState; - }); - }, - setPrimaryColor: (color: string) => { - update(state => { - const newState = { ...state, primaryColor: color }; - saveTheme(newState); - applyTheme(newState); - return newState; - }); - }, - toggleMode: () => { - update(state => { - const modes: ThemeMode[] = ['dark', 'light', 'system']; - const currentIndex = modes.indexOf(state.mode); - const newMode = modes[(currentIndex + 1) % modes.length]; - const newState: ThemeState = { ...state, mode: newMode }; - saveTheme(newState); - applyTheme(newState); - return newState; - }); - }, - reset: () => { - set(defaultTheme); - saveTheme(defaultTheme); - applyTheme(defaultTheme); - }, - init: () => { - const state = loadTheme(); - applyTheme(state); - set(state); - } - }; -} - -export const theme = createThemeStore(); - -// Derived stores for convenience -export const isDarkMode = derived(theme, $t => getEffectiveMode($t.mode) === 'dark'); -export const primaryColor = derived(theme, $t => $t.primaryColor); -export const themeMode = derived(theme, $t => $t.mode); - -// Initialize theme on load -if (browser) { - applyTheme(loadTheme()); - - // Listen for system theme changes - window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { - const state = loadTheme(); - if (state.mode === 'system') { - applyTheme(state); - } - }); -} diff --git a/src/lib/stores/toast.svelte.ts b/src/lib/stores/toast.svelte.ts new file mode 100644 index 0000000..9add43c --- /dev/null +++ b/src/lib/stores/toast.svelte.ts @@ -0,0 +1,83 @@ +/** + * Toast Store - Svelte 5 class-based $state store + * Manages toast notifications with auto-dismiss + */ + +export type ToastVariant = 'success' | 'error' | 'warning' | 'info'; + +export interface Toast { + id: string; + message: string; + variant: ToastVariant; + duration?: number; +} + +class ToastStore { + items = $state<Toast[]>([]); + private timeouts = new Map<string, ReturnType<typeof setTimeout>>(); + private subscribers = new Set<(value: Toast[]) => void>(); + + // Subscribe method for $store syntax compatibility + subscribe(fn: (value: Toast[]) => void): () => void { + this.subscribers.add(fn); + fn(this.items); + return () => this.subscribers.delete(fn); + } + + private notify() { + this.subscribers.forEach(fn => fn(this.items)); + } + + add(message: string, variant: ToastVariant = 'info', duration = 5000): string { + const id = crypto.randomUUID(); + const toast: Toast = { id, message, variant, duration }; + + this.items = [...this.items, toast]; + this.notify(); + + if (duration > 0) { + const timeout = setTimeout(() => this.remove(id), duration); + this.timeouts.set(id, timeout); + } + + return id; + } + + remove(id: string) { + // Clear any pending timeout + const timeout = this.timeouts.get(id); + if (timeout) { + clearTimeout(timeout); + this.timeouts.delete(id); + } + this.items = this.items.filter((t) => t.id !== id); + this.notify(); + } + + clear() { + // Clear all pending timeouts + this.timeouts.forEach((timeout) => clearTimeout(timeout)); + this.timeouts.clear(); + this.items = []; + this.notify(); + } + + // Convenience methods + success(message: string, duration?: number) { + return this.add(message, 'success', duration); + } + + error(message: string, duration?: number) { + return this.add(message, 'error', duration); + } + + warning(message: string, duration?: number) { + return this.add(message, 'warning', duration); + } + + info(message: string, duration?: number) { + return this.add(message, 'info', duration); + } +} + +export const toasts = new ToastStore(); diff --git a/src/lib/stores/toast.ts b/src/lib/stores/toast.ts deleted file mode 100644 index afcefa9..0000000 --- a/src/lib/stores/toast.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { writable } from 'svelte/store'; - -export type ToastVariant = 'success' | 'error' | 'warning' | 'info'; - -export interface Toast { - id: string; - message: string; - variant: ToastVariant; - duration?: number; -} - -function createToastStore() { - const { subscribe, update } = writable<Toast[]>([]); - - function add(message: string, variant: ToastVariant = 'info', duration = 5000) { - const id = crypto.randomUUID(); - const toast: Toast = { id, message, variant, duration }; - - update((toasts) => [...toasts, toast]); - - if (duration > 0) { - setTimeout(() => remove(id), duration); - } - - return id; - } - - function remove(id: string) { - update((toasts) => toasts.filter((t) => t.id !== id)); - } - - function clear() { - update(() => []); - } - - return { - subscribe, - add, - remove, - clear, - success: (message: string, duration?: number) => add(message, 'success', duration), - error: (message: string, duration?: number) => add(message, 'error', duration), - warning: (message: string, duration?: number) => add(message, 'warning', duration), - info: (message: string, duration?: number) => add(message, 'info', duration) - }; -} - -export const toasts = createToastStore(); diff --git a/src/lib/supabase/index.ts b/src/lib/supabase/index.ts index 04e7ee3..e31e754 100644 --- a/src/lib/supabase/index.ts +++ b/src/lib/supabase/index.ts @@ -1,3 +1,2 @@ export { createClient } from './client'; -export { createClient as createServerClient } from './server'; export type * from './types'; diff --git a/src/lib/supabase/server.ts b/src/lib/supabase/server.ts deleted file mode 100644 index ec75ed0..0000000 --- a/src/lib/supabase/server.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { createServerClient } from '@supabase/ssr'; -import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'; -import type { Database } from './types'; -import type { Cookies } from '@sveltejs/kit'; - -export function createClient(cookies: Cookies) { - return createServerClient<Database>(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { - cookies: { - getAll() { - return cookies.getAll(); - }, - setAll(cookiesToSet) { - cookiesToSet.forEach(({ name, value, options }) => { - cookies.set(name, value, { ...options, path: '/' }); - }); - } - } - }); -} diff --git a/src/lib/supabase/types.ts b/src/lib/supabase/types.ts index 664726b..05477bf 100644 --- a/src/lib/supabase/types.ts +++ b/src/lib/supabase/types.ts @@ -1,396 +1,1173 @@ -export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[]; +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[] -export interface Database { - public: { - Tables: { - organizations: { - Row: { - id: string; - name: string; - slug: string; - avatar_url: string | null; - created_at: string; - updated_at: string; - }; - Insert: { - id?: string; - name: string; - slug: string; - avatar_url?: string | null; - created_at?: string; - updated_at?: string; - }; - Update: { - id?: string; - name?: string; - slug?: string; - avatar_url?: string | null; - created_at?: string; - updated_at?: string; - }; - }; - org_members: { - Row: { - id: string; - org_id: string; - user_id: string; - role: string; - role_id: string | null; - invited_at: string; - joined_at: string | null; - }; - Insert: { - id?: string; - org_id: string; - user_id: string; - role?: string; - role_id?: string | null; - invited_at?: string; - joined_at?: string | null; - }; - Update: { - id?: string; - org_id?: string; - user_id?: string; - role?: string; - role_id?: string | null; - invited_at?: string; - joined_at?: string | null; - }; - }; - documents: { - Row: { - id: string; - org_id: string; - parent_id: string | null; - type: 'folder' | 'document'; - name: string; - content: Json | null; - created_by: string; - created_at: string; - updated_at: string; - }; - Insert: { - id?: string; - org_id: string; - parent_id?: string | null; - type: 'folder' | 'document'; - name: string; - content?: Json | null; - created_by: string; - created_at?: string; - updated_at?: string; - }; - Update: { - id?: string; - org_id?: string; - parent_id?: string | null; - type?: 'folder' | 'document'; - name?: string; - content?: Json | null; - created_by?: string; - created_at?: string; - updated_at?: string; - }; - }; - kanban_boards: { - Row: { - id: string; - org_id: string; - name: string; - created_at: string; - }; - Insert: { - id?: string; - org_id: string; - name: string; - created_at?: string; - }; - Update: { - id?: string; - org_id?: string; - name?: string; - created_at?: string; - }; - }; - kanban_columns: { - Row: { - id: string; - board_id: string; - name: string; - position: number; - color: string | null; - }; - Insert: { - id?: string; - board_id: string; - name: string; - position: number; - color?: string | null; - }; - Update: { - id?: string; - board_id?: string; - name?: string; - position?: number; - color?: string | null; - }; - }; - kanban_cards: { - Row: { - id: string; - column_id: string; - title: string; - description: string | null; - position: number; - due_date: string | null; - color: string | null; - created_by: string; - created_at: string; - }; - Insert: { - id?: string; - column_id: string; - title: string; - description?: string | null; - position: number; - due_date?: string | null; - color?: string | null; - created_by: string; - created_at?: string; - }; - Update: { - id?: string; - column_id?: string; - title?: string; - description?: string | null; - position?: number; - due_date?: string | null; - color?: string | null; - created_by?: string; - created_at?: string; - }; - }; - card_assignees: { - Row: { - card_id: string; - user_id: string; - }; - Insert: { - card_id: string; - user_id: string; - }; - Update: { - card_id?: string; - user_id?: string; - }; - }; - calendar_events: { - Row: { - id: string; - org_id: string; - title: string; - description: string | null; - start_time: string; - end_time: string; - all_day: boolean; - color: string | null; - recurrence: string | null; - created_by: string; - created_at: string; - }; - Insert: { - id?: string; - org_id: string; - title: string; - description?: string | null; - start_time: string; - end_time: string; - all_day?: boolean; - color?: string | null; - recurrence?: string | null; - created_by: string; - created_at?: string; - }; - Update: { - id?: string; - org_id?: string; - title?: string; - description?: string | null; - start_time?: string; - end_time?: string; - all_day?: boolean; - color?: string | null; - recurrence?: string | null; - created_by?: string; - created_at?: string; - }; - }; - event_attendees: { - Row: { - event_id: string; - user_id: string; - status: 'pending' | 'accepted' | 'declined'; - }; - Insert: { - event_id: string; - user_id: string; - status?: 'pending' | 'accepted' | 'declined'; - }; - Update: { - event_id?: string; - user_id?: string; - status?: 'pending' | 'accepted' | 'declined'; - }; - }; - org_roles: { - Row: { - id: string; - org_id: string; - name: string; - color: string; - permissions: string[]; - is_default: boolean; - is_system: boolean; - position: number; - created_at: string; - updated_at: string; - }; - Insert: { - id?: string; - org_id: string; - name: string; - color?: string; - permissions?: string[]; - is_default?: boolean; - is_system?: boolean; - position?: number; - created_at?: string; - updated_at?: string; - }; - Update: { - id?: string; - org_id?: string; - name?: string; - color?: string; - permissions?: string[]; - is_default?: boolean; - is_system?: boolean; - position?: number; - created_at?: string; - updated_at?: string; - }; - }; - org_invites: { - Row: { - id: string; - org_id: string; - email: string; - role_id: string | null; - role: string; - invited_by: string; - token: string; - expires_at: string; - accepted_at: string | null; - created_at: string; - }; - Insert: { - id?: string; - org_id: string; - email: string; - role_id?: string | null; - role?: string; - invited_by: string; - token?: string; - expires_at?: string; - accepted_at?: string | null; - created_at?: string; - }; - Update: { - id?: string; - org_id?: string; - email?: string; - role_id?: string | null; - role?: string; - invited_by?: string; - token?: string; - expires_at?: string; - accepted_at?: string | null; - created_at?: string; - }; - }; - org_google_calendars: { - Row: { - id: string; - org_id: string; - calendar_id: string; - calendar_name: string | null; - connected_by: string; - created_at: string; - updated_at: string; - }; - Insert: { - id?: string; - org_id: string; - calendar_id: string; - calendar_name?: string | null; - connected_by: string; - created_at?: string; - updated_at?: string; - }; - Update: { - id?: string; - org_id?: string; - calendar_id?: string; - calendar_name?: string | null; - connected_by?: string; - created_at?: string; - updated_at?: string; - }; - }; - profiles: { - Row: { - id: string; - email: string; - full_name: string | null; - avatar_url: string | null; - created_at: string; - updated_at: string; - }; - Insert: { - id: string; - email: string; - full_name?: string | null; - avatar_url?: string | null; - created_at?: string; - updated_at?: string; - }; - Update: { - id?: string; - email?: string; - full_name?: string | null; - avatar_url?: string | null; - created_at?: string; - updated_at?: string; - }; - }; - }; - Views: Record<string, never>; - Functions: Record<string, never>; - Enums: { - member_role: 'owner' | 'admin' | 'editor' | 'viewer'; - attendee_status: 'pending' | 'accepted' | 'declined'; - }; - }; +export type Database = { + // Allows to automatically instantiate createClient with right options + // instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY) + __InternalSupabase: { + PostgrestVersion: "14.1" + } + public: { + Tables: { + activity_log: { + Row: { + action: string + created_at: string | null + entity_id: string | null + entity_name: string | null + entity_type: string + id: string + metadata: Json | null + org_id: string + user_id: string + } + Insert: { + action: string + created_at?: string | null + entity_id?: string | null + entity_name?: string | null + entity_type: string + id?: string + metadata?: Json | null + org_id: string + user_id: string + } + Update: { + action?: string + created_at?: string | null + entity_id?: string | null + entity_name?: string | null + entity_type?: string + id?: string + metadata?: Json | null + org_id?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "activity_log_org_id_fkey" + columns: ["org_id"] + isOneToOne: false + referencedRelation: "organizations" + referencedColumns: ["id"] + }, + { + foreignKeyName: "activity_log_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + ] + } + calendar_events: { + Row: { + all_day: boolean | null + color: string | null + created_at: string | null + created_by: string | null + description: string | null + end_time: string + google_event_id: string | null + id: string + org_id: string | null + recurrence: string | null + start_time: string + synced_at: string | null + title: string + } + Insert: { + all_day?: boolean | null + color?: string | null + created_at?: string | null + created_by?: string | null + description?: string | null + end_time: string + google_event_id?: string | null + id?: string + org_id?: string | null + recurrence?: string | null + start_time: string + synced_at?: string | null + title: string + } + Update: { + all_day?: boolean | null + color?: string | null + created_at?: string | null + created_by?: string | null + description?: string | null + end_time?: string + google_event_id?: string | null + id?: string + org_id?: string | null + recurrence?: string | null + start_time?: string + synced_at?: string | null + title?: string + } + Relationships: [ + { + foreignKeyName: "calendar_events_org_id_fkey" + columns: ["org_id"] + isOneToOne: false + referencedRelation: "organizations" + referencedColumns: ["id"] + }, + ] + } + card_assignees: { + Row: { + card_id: string + user_id: string + } + Insert: { + card_id: string + user_id: string + } + Update: { + card_id?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "card_assignees_card_id_fkey" + columns: ["card_id"] + isOneToOne: false + referencedRelation: "kanban_cards" + referencedColumns: ["id"] + }, + ] + } + card_labels: { + Row: { + card_id: string + created_at: string | null + label_id: string + } + Insert: { + card_id: string + created_at?: string | null + label_id: string + } + Update: { + card_id?: string + created_at?: string | null + label_id?: string + } + Relationships: [ + { + foreignKeyName: "card_labels_card_id_fkey" + columns: ["card_id"] + isOneToOne: false + referencedRelation: "kanban_cards" + referencedColumns: ["id"] + }, + { + foreignKeyName: "card_labels_label_id_fkey" + columns: ["label_id"] + isOneToOne: false + referencedRelation: "kanban_labels" + referencedColumns: ["id"] + }, + ] + } + card_tags: { + Row: { + card_id: string + created_at: string | null + tag_id: string + } + Insert: { + card_id: string + created_at?: string | null + tag_id: string + } + Update: { + card_id?: string + created_at?: string | null + tag_id?: string + } + Relationships: [ + { + foreignKeyName: "card_tags_card_id_fkey" + columns: ["card_id"] + isOneToOne: false + referencedRelation: "kanban_cards" + referencedColumns: ["id"] + }, + { + foreignKeyName: "card_tags_tag_id_fkey" + columns: ["tag_id"] + isOneToOne: false + referencedRelation: "tags" + referencedColumns: ["id"] + }, + ] + } + checklist_items: { + Row: { + card_id: string | null + completed: boolean | null + created_at: string | null + id: string + position: number + title: string + } + Insert: { + card_id?: string | null + completed?: boolean | null + created_at?: string | null + id?: string + position: number + title: string + } + Update: { + card_id?: string | null + completed?: boolean | null + created_at?: string | null + id?: string + position?: number + title?: string + } + Relationships: [ + { + foreignKeyName: "checklist_items_card_id_fkey" + columns: ["card_id"] + isOneToOne: false + referencedRelation: "kanban_cards" + referencedColumns: ["id"] + }, + ] + } + document_locks: { + Row: { + document_id: string + id: string + last_heartbeat: string + locked_at: string + user_id: string + } + Insert: { + document_id: string + id?: string + last_heartbeat?: string + locked_at?: string + user_id: string + } + Update: { + document_id?: string + id?: string + last_heartbeat?: string + locked_at?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "document_locks_document_id_fkey" + columns: ["document_id"] + isOneToOne: true + referencedRelation: "documents" + referencedColumns: ["id"] + }, + ] + } + documents: { + Row: { + content: Json | null + created_at: string | null + created_by: string | null + id: string + name: string + org_id: string | null + parent_id: string | null + path: string | null + position: number | null + type: string + updated_at: string | null + } + Insert: { + content?: Json | null + created_at?: string | null + created_by?: string | null + id?: string + name: string + org_id?: string | null + parent_id?: string | null + path?: string | null + position?: number | null + type: string + updated_at?: string | null + } + Update: { + content?: Json | null + created_at?: string | null + created_by?: string | null + id?: string + name?: string + org_id?: string | null + parent_id?: string | null + path?: string | null + position?: number | null + type?: string + updated_at?: string | null + } + Relationships: [ + { + foreignKeyName: "documents_org_id_fkey" + columns: ["org_id"] + isOneToOne: false + referencedRelation: "organizations" + referencedColumns: ["id"] + }, + { + foreignKeyName: "documents_parent_id_fkey" + columns: ["parent_id"] + isOneToOne: false + referencedRelation: "documents" + referencedColumns: ["id"] + }, + ] + } + event_attendees: { + Row: { + event_id: string + status: string | null + user_id: string + } + Insert: { + event_id: string + status?: string | null + user_id: string + } + Update: { + event_id?: string + status?: string | null + user_id?: string + } + Relationships: [ + { + foreignKeyName: "event_attendees_event_id_fkey" + columns: ["event_id"] + isOneToOne: false + referencedRelation: "calendar_events" + referencedColumns: ["id"] + }, + ] + } + kanban_boards: { + Row: { + created_at: string | null + created_by: string | null + id: string + is_personal: boolean | null + name: string + org_id: string | null + team_id: string | null + } + Insert: { + created_at?: string | null + created_by?: string | null + id?: string + is_personal?: boolean | null + name: string + org_id?: string | null + team_id?: string | null + } + Update: { + created_at?: string | null + created_by?: string | null + id?: string + is_personal?: boolean | null + name?: string + org_id?: string | null + team_id?: string | null + } + Relationships: [ + { + foreignKeyName: "kanban_boards_created_by_fkey" + columns: ["created_by"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + { + foreignKeyName: "kanban_boards_org_id_fkey" + columns: ["org_id"] + isOneToOne: false + referencedRelation: "organizations" + referencedColumns: ["id"] + }, + { + foreignKeyName: "kanban_boards_team_id_fkey" + columns: ["team_id"] + isOneToOne: false + referencedRelation: "teams" + referencedColumns: ["id"] + }, + ] + } + kanban_cards: { + Row: { + assignee_id: string | null + color: string | null + column_id: string | null + created_at: string | null + created_by: string | null + description: string | null + due_date: string | null + id: string + position: number + priority: string | null + title: string + } + Insert: { + assignee_id?: string | null + color?: string | null + column_id?: string | null + created_at?: string | null + created_by?: string | null + description?: string | null + due_date?: string | null + id?: string + position: number + priority?: string | null + title: string + } + Update: { + assignee_id?: string | null + color?: string | null + column_id?: string | null + created_at?: string | null + created_by?: string | null + description?: string | null + due_date?: string | null + id?: string + position?: number + priority?: string | null + title?: string + } + Relationships: [ + { + foreignKeyName: "kanban_cards_assignee_id_fkey" + columns: ["assignee_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + { + foreignKeyName: "kanban_cards_column_id_fkey" + columns: ["column_id"] + isOneToOne: false + referencedRelation: "kanban_columns" + referencedColumns: ["id"] + }, + ] + } + kanban_checklist_items: { + Row: { + card_id: string + completed: boolean | null + created_at: string | null + id: string + position: number | null + title: string + updated_at: string | null + } + Insert: { + card_id: string + completed?: boolean | null + created_at?: string | null + id?: string + position?: number | null + title: string + updated_at?: string | null + } + Update: { + card_id?: string + completed?: boolean | null + created_at?: string | null + id?: string + position?: number | null + title?: string + updated_at?: string | null + } + Relationships: [ + { + foreignKeyName: "kanban_checklist_items_card_id_fkey" + columns: ["card_id"] + isOneToOne: false + referencedRelation: "kanban_cards" + referencedColumns: ["id"] + }, + ] + } + kanban_columns: { + Row: { + board_id: string | null + color: string | null + id: string + name: string + position: number + } + Insert: { + board_id?: string | null + color?: string | null + id?: string + name: string + position: number + } + Update: { + board_id?: string | null + color?: string | null + id?: string + name?: string + position?: number + } + Relationships: [ + { + foreignKeyName: "kanban_columns_board_id_fkey" + columns: ["board_id"] + isOneToOne: false + referencedRelation: "kanban_boards" + referencedColumns: ["id"] + }, + ] + } + kanban_comments: { + Row: { + card_id: string + content: string + created_at: string | null + id: string + updated_at: string | null + user_id: string + } + Insert: { + card_id: string + content: string + created_at?: string | null + id?: string + updated_at?: string | null + user_id: string + } + Update: { + card_id?: string + content?: string + created_at?: string | null + id?: string + updated_at?: string | null + user_id?: string + } + Relationships: [ + { + foreignKeyName: "kanban_comments_card_id_fkey" + columns: ["card_id"] + isOneToOne: false + referencedRelation: "kanban_cards" + referencedColumns: ["id"] + }, + { + foreignKeyName: "kanban_comments_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + ] + } + kanban_labels: { + Row: { + color: string + created_at: string | null + id: string + name: string + org_id: string + } + Insert: { + color?: string + created_at?: string | null + id?: string + name: string + org_id: string + } + Update: { + color?: string + created_at?: string | null + id?: string + name?: string + org_id?: string + } + Relationships: [ + { + foreignKeyName: "kanban_labels_org_id_fkey" + columns: ["org_id"] + isOneToOne: false + referencedRelation: "organizations" + referencedColumns: ["id"] + }, + ] + } + org_google_calendars: { + Row: { + calendar_id: string + calendar_name: string | null + connected_by: string | null + created_at: string | null + id: string + org_id: string | null + updated_at: string | null + } + Insert: { + calendar_id: string + calendar_name?: string | null + connected_by?: string | null + created_at?: string | null + id?: string + org_id?: string | null + updated_at?: string | null + } + Update: { + calendar_id?: string + calendar_name?: string | null + connected_by?: string | null + created_at?: string | null + id?: string + org_id?: string | null + updated_at?: string | null + } + Relationships: [ + { + foreignKeyName: "org_google_calendars_org_id_fkey" + columns: ["org_id"] + isOneToOne: true + referencedRelation: "organizations" + referencedColumns: ["id"] + }, + ] + } + org_invites: { + Row: { + accepted_at: string | null + created_at: string | null + email: string + expires_at: string | null + id: string + invited_by: string | null + org_id: string | null + role: string | null + role_id: string | null + token: string + } + Insert: { + accepted_at?: string | null + created_at?: string | null + email: string + expires_at?: string | null + id?: string + invited_by?: string | null + org_id?: string | null + role?: string | null + role_id?: string | null + token?: string + } + Update: { + accepted_at?: string | null + created_at?: string | null + email?: string + expires_at?: string | null + id?: string + invited_by?: string | null + org_id?: string | null + role?: string | null + role_id?: string | null + token?: string + } + Relationships: [ + { + foreignKeyName: "org_invites_org_id_fkey" + columns: ["org_id"] + isOneToOne: false + referencedRelation: "organizations" + referencedColumns: ["id"] + }, + { + foreignKeyName: "org_invites_role_id_fkey" + columns: ["role_id"] + isOneToOne: false + referencedRelation: "org_roles" + referencedColumns: ["id"] + }, + ] + } + org_members: { + Row: { + id: string + invited_at: string | null + joined_at: string | null + org_id: string | null + role: string + role_id: string | null + user_id: string | null + } + Insert: { + id?: string + invited_at?: string | null + joined_at?: string | null + org_id?: string | null + role: string + role_id?: string | null + user_id?: string | null + } + Update: { + id?: string + invited_at?: string | null + joined_at?: string | null + org_id?: string | null + role?: string + role_id?: string | null + user_id?: string | null + } + Relationships: [ + { + foreignKeyName: "org_members_org_id_fkey" + columns: ["org_id"] + isOneToOne: false + referencedRelation: "organizations" + referencedColumns: ["id"] + }, + { + foreignKeyName: "org_members_role_id_fkey" + columns: ["role_id"] + isOneToOne: false + referencedRelation: "org_roles" + referencedColumns: ["id"] + }, + ] + } + org_roles: { + Row: { + color: string | null + created_at: string | null + id: string + is_default: boolean | null + is_system: boolean | null + name: string + org_id: string | null + permissions: Json + position: number | null + updated_at: string | null + } + Insert: { + color?: string | null + created_at?: string | null + id?: string + is_default?: boolean | null + is_system?: boolean | null + name: string + org_id?: string | null + permissions?: Json + position?: number | null + updated_at?: string | null + } + Update: { + color?: string | null + created_at?: string | null + id?: string + is_default?: boolean | null + is_system?: boolean | null + name?: string + org_id?: string | null + permissions?: Json + position?: number | null + updated_at?: string | null + } + Relationships: [ + { + foreignKeyName: "org_roles_org_id_fkey" + columns: ["org_id"] + isOneToOne: false + referencedRelation: "organizations" + referencedColumns: ["id"] + }, + ] + } + organizations: { + Row: { + avatar_url: string | null + created_at: string | null + icon_url: string | null + id: string + name: string + slug: string + theme_color: string | null + updated_at: string | null + } + Insert: { + avatar_url?: string | null + created_at?: string | null + icon_url?: string | null + id?: string + name: string + slug: string + theme_color?: string | null + updated_at?: string | null + } + Update: { + avatar_url?: string | null + created_at?: string | null + icon_url?: string | null + id?: string + name?: string + slug?: string + theme_color?: string | null + updated_at?: string | null + } + Relationships: [] + } + profiles: { + Row: { + avatar_url: string | null + created_at: string | null + email: string + full_name: string | null + id: string + updated_at: string | null + } + Insert: { + avatar_url?: string | null + created_at?: string | null + email: string + full_name?: string | null + id: string + updated_at?: string | null + } + Update: { + avatar_url?: string | null + created_at?: string | null + email?: string + full_name?: string | null + id?: string + updated_at?: string | null + } + Relationships: [] + } + tags: { + Row: { + color: string | null + created_at: string | null + id: string + name: string + org_id: string + } + Insert: { + color?: string | null + created_at?: string | null + id?: string + name: string + org_id: string + } + Update: { + color?: string | null + created_at?: string | null + id?: string + name?: string + org_id?: string + } + Relationships: [ + { + foreignKeyName: "tags_org_id_fkey" + columns: ["org_id"] + isOneToOne: false + referencedRelation: "organizations" + referencedColumns: ["id"] + }, + ] + } + team_members: { + Row: { + joined_at: string | null + role: string | null + team_id: string + user_id: string + } + Insert: { + joined_at?: string | null + role?: string | null + team_id: string + user_id: string + } + Update: { + joined_at?: string | null + role?: string | null + team_id?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "team_members_team_id_fkey" + columns: ["team_id"] + isOneToOne: false + referencedRelation: "teams" + referencedColumns: ["id"] + }, + { + foreignKeyName: "team_members_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + ] + } + teams: { + Row: { + color: string | null + created_at: string | null + description: string | null + id: string + name: string + org_id: string + updated_at: string | null + } + Insert: { + color?: string | null + created_at?: string | null + description?: string | null + id?: string + name: string + org_id: string + updated_at?: string | null + } + Update: { + color?: string | null + created_at?: string | null + description?: string | null + id?: string + name?: string + org_id?: string + updated_at?: string | null + } + Relationships: [ + { + foreignKeyName: "teams_org_id_fkey" + columns: ["org_id"] + isOneToOne: false + referencedRelation: "organizations" + referencedColumns: ["id"] + }, + ] + } + user_preferences: { + Row: { + accent_color: string | null + created_at: string | null + id: string + sidebar_collapsed: boolean | null + theme: string | null + updated_at: string | null + use_org_theme: boolean | null + user_id: string + } + Insert: { + accent_color?: string | null + created_at?: string | null + id?: string + sidebar_collapsed?: boolean | null + theme?: string | null + updated_at?: string | null + use_org_theme?: boolean | null + user_id: string + } + Update: { + accent_color?: string | null + created_at?: string | null + id?: string + sidebar_collapsed?: boolean | null + theme?: string | null + updated_at?: string | null + use_org_theme?: boolean | null + user_id?: string + } + Relationships: [ + { + foreignKeyName: "user_preferences_user_id_fkey" + columns: ["user_id"] + isOneToOne: true + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + ] + } + } + Views: { + [_ in never]: never + } + Functions: { + compute_document_path: { Args: { doc_id: string }; Returns: string } + get_next_document_position: { + Args: { folder_id: string } + Returns: number + } + is_org_member: { Args: { org_id: string }; Returns: boolean } + } + Enums: { + [_ in never]: never + } + CompositeTypes: { + [_ in never]: never + } + } } -// Convenience types -export type Organization = Database['public']['Tables']['organizations']['Row']; -export type OrgMember = Database['public']['Tables']['org_members']['Row']; -export type Document = Database['public']['Tables']['documents']['Row']; -export type KanbanBoard = Database['public']['Tables']['kanban_boards']['Row']; -export type KanbanColumn = Database['public']['Tables']['kanban_columns']['Row']; -export type KanbanCard = Database['public']['Tables']['kanban_cards']['Row']; -export type CalendarEvent = Database['public']['Tables']['calendar_events']['Row']; -export type Profile = Database['public']['Tables']['profiles']['Row']; -export type MemberRole = Database['public']['Enums']['member_role']; +type DatabaseWithoutInternals = Omit<Database, "__InternalSupabase"> + +type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, "public">] + +export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { + Row: infer R + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & + DefaultSchema["Views"]) + ? (DefaultSchema["Tables"] & + DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R + } + ? R + : never + : never + +export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Insert: infer I + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I + } + ? I + : never + : never + +export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Update: infer U + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Update: infer U + } + ? U + : never + : never + +export type Enums< + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema["Enums"] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] + : never = never, +> = DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] + ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] + : never + +export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema["CompositeTypes"] + | { schema: keyof DatabaseWithoutInternals }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] + : never = never, +> = PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] + ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] + : never + +export const Constants = { + public: { + Enums: {}, + }, +} as const + +// ── Convenience type aliases ────────────────────────── +export type MemberRole = 'owner' | 'admin' | 'editor' | 'viewer'; +type PublicTables = Database['public']['Tables'] + +export type Organization = PublicTables['organizations']['Row'] +export type OrgMember = PublicTables['org_members']['Row'] +export type OrgRole = PublicTables['org_roles']['Row'] +export type OrgInvite = PublicTables['org_invites']['Row'] +export type Profile = PublicTables['profiles']['Row'] +export type Document = PublicTables['documents']['Row'] +export type DocumentLock = PublicTables['document_locks']['Row'] +export type CalendarEvent = PublicTables['calendar_events']['Row'] +export type KanbanBoard = PublicTables['kanban_boards']['Row'] +export type KanbanColumn = PublicTables['kanban_columns']['Row'] +export type KanbanCard = PublicTables['kanban_cards']['Row'] +export type KanbanComment = PublicTables['kanban_comments']['Row'] +export type KanbanLabel = PublicTables['kanban_labels']['Row'] +export type KanbanChecklistItem = PublicTables['kanban_checklist_items']['Row'] +export type Tag = PublicTables['tags']['Row'] +export type Team = PublicTables['teams']['Row'] +export type OrgGoogleCalendar = PublicTables['org_google_calendars']['Row'] +export type ActivityLog = PublicTables['activity_log']['Row'] +export type UserPreferences = PublicTables['user_preferences']['Row'] diff --git a/src/lib/utils/logger.ts b/src/lib/utils/logger.ts new file mode 100644 index 0000000..fc9bd70 --- /dev/null +++ b/src/lib/utils/logger.ts @@ -0,0 +1,207 @@ +/** + * Centralized Logger for Root Org + * + * Works on both client and server. Outputs structured logs with: + * - Timestamp + * - Level (debug/info/warn/error) + * - Context (which module/function) + * - Structured data + * + * On the server (dev terminal), logs are colorized and always visible. + * On the client, logs go to console and can optionally trigger toasts. + */ + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +export interface LogEntry { + level: LogLevel; + context: string; + message: string; + data?: unknown; + error?: unknown; + timestamp: string; +} + +const LEVEL_PRIORITY: Record<LogLevel, number> = { + debug: 0, + info: 1, + warn: 2, + error: 3, +}; + +const LEVEL_COLORS: Record<LogLevel, string> = { + debug: '\x1b[36m', // cyan + info: '\x1b[32m', // green + warn: '\x1b[33m', // yellow + error: '\x1b[31m', // red +}; + +const RESET = '\x1b[0m'; +const BOLD = '\x1b[1m'; +const DIM = '\x1b[2m'; + +// Minimum level to output — can be overridden +let minLevel: LogLevel = 'debug'; + +function shouldLog(level: LogLevel): boolean { + return LEVEL_PRIORITY[level] >= LEVEL_PRIORITY[minLevel]; +} + +function isServer(): boolean { + return typeof window === 'undefined'; +} + +function formatError(err: unknown): string { + if (err instanceof Error) { + const stack = err.stack ? `\n${err.stack}` : ''; + return `${err.name}: ${err.message}${stack}`; + } + if (typeof err === 'object' && err !== null) { + try { + return JSON.stringify(err, null, 2); + } catch { + return String(err); + } + } + return String(err); +} + +function formatData(data: unknown): string { + if (data === undefined || data === null) return ''; + try { + return JSON.stringify(data, null, 2); + } catch { + return String(data); + } +} + +function serverLog(entry: LogEntry) { + const color = LEVEL_COLORS[entry.level]; + const levelTag = `${color}${BOLD}[${entry.level.toUpperCase()}]${RESET}`; + const time = `${DIM}${entry.timestamp}${RESET}`; + const ctx = `${color}[${entry.context}]${RESET}`; + + let line = `${levelTag} ${time} ${ctx} ${entry.message}`; + + if (entry.data !== undefined) { + line += `\n ${DIM}data:${RESET} ${formatData(entry.data)}`; + } + if (entry.error !== undefined) { + line += `\n ${color}error:${RESET} ${formatError(entry.error)}`; + } + + // Use stderr for errors/warnings so they stand out in terminal + if (entry.level === 'error') { + console.error(line); + } else if (entry.level === 'warn') { + console.warn(line); + } else { + console.log(line); + } +} + +function clientLog(entry: LogEntry) { + const prefix = `[${entry.level.toUpperCase()}] [${entry.context}]`; + const args: unknown[] = [prefix, entry.message]; + + if (entry.data !== undefined) args.push(entry.data); + if (entry.error !== undefined) args.push(entry.error); + + switch (entry.level) { + case 'error': + console.error(...args); + break; + case 'warn': + console.warn(...args); + break; + case 'debug': + console.debug(...args); + break; + default: + console.log(...args); + } +} + +function log(level: LogLevel, context: string, message: string, extra?: { data?: unknown; error?: unknown }) { + if (!shouldLog(level)) return; + + const entry: LogEntry = { + level, + context, + message, + data: extra?.data, + error: extra?.error, + timestamp: new Date().toISOString(), + }; + + if (isServer()) { + serverLog(entry); + } else { + clientLog(entry); + } + + // Store in recent logs buffer for debugging + recentLogs.push(entry); + if (recentLogs.length > MAX_RECENT_LOGS) { + recentLogs.shift(); + } + + return entry; +} + +// Ring buffer of recent logs — useful for dumping context on crash +const MAX_RECENT_LOGS = 100; +const recentLogs: LogEntry[] = []; + +/** + * Create a scoped logger for a specific module/context. + * + * Usage: + * ```ts + * const log = createLogger('kanban.api'); + * log.info('Loading board', { data: { boardId } }); + * log.error('Failed to load board', { error: err, data: { boardId } }); + * ``` + */ +export function createLogger(context: string) { + return { + debug: (message: string, extra?: { data?: unknown; error?: unknown }) => + log('debug', context, message, extra), + info: (message: string, extra?: { data?: unknown; error?: unknown }) => + log('info', context, message, extra), + warn: (message: string, extra?: { data?: unknown; error?: unknown }) => + log('warn', context, message, extra), + error: (message: string, extra?: { data?: unknown; error?: unknown }) => + log('error', context, message, extra), + }; +} + +/** Set the minimum log level */ +export function setLogLevel(level: LogLevel) { + minLevel = level; +} + +/** Get recent log entries (for error reports / debugging) */ +export function getRecentLogs(): LogEntry[] { + return [...recentLogs]; +} + +/** Clear recent logs */ +export function clearRecentLogs() { + recentLogs.length = 0; +} + +/** + * Format recent logs as a copyable string for bug reports. + * User can paste this to you for debugging. + */ +export function dumpLogs(): string { + return recentLogs + .map((e) => { + let line = `[${e.level.toUpperCase()}] ${e.timestamp} [${e.context}] ${e.message}`; + if (e.data !== undefined) line += ` | data: ${formatData(e.data)}`; + if (e.error !== undefined) line += ` | error: ${formatError(e.error)}`; + return line; + }) + .join('\n'); +} diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte new file mode 100644 index 0000000..615df79 --- /dev/null +++ b/src/routes/+error.svelte @@ -0,0 +1,82 @@ +<script lang="ts"> + import { page } from "$app/stores"; + import { Button } from "$lib/components/ui"; + import { dumpLogs } from "$lib/utils/logger"; + + let showLogs = $state(false); + let logDump = $state(""); + let copied = $state(false); + + function handleShowLogs() { + logDump = dumpLogs(); + showLogs = !showLogs; + } + + async function handleCopyLogs() { + const dump = dumpLogs(); + const errorInfo = `--- Error Report --- +URL: ${$page.url.pathname} +Status: ${$page.status} +Message: ${$page.error?.message || "Unknown"} +Error ID: ${$page.error?.errorId || "N/A"} +Context: ${$page.error?.context || "N/A"} +Time: ${new Date().toISOString()} + +--- Recent Logs --- +${dump} +`; + await navigator.clipboard.writeText(errorInfo); + copied = true; + setTimeout(() => (copied = false), 2000); + } +</script> + +<div class="min-h-screen bg-night flex items-center justify-center p-4"> + <div class="max-w-lg w-full text-center space-y-6"> + <div class="space-y-2"> + <p class="text-[80px] font-heading text-primary">{$page.status}</p> + <h1 class="text-2xl font-heading text-white"> + {$page.status === 404 ? "Page not found" : "Something went wrong"} + </h1> + <p class="text-light/60 text-base"> + {$page.error?.message || "An unexpected error occurred."} + </p> + {#if $page.error?.errorId} + <p class="text-light/40 text-sm font-mono"> + Error ID: {$page.error.errorId} + </p> + {/if} + {#if $page.error?.context} + <p class="text-light/40 text-sm font-mono"> + {$page.error.context} + </p> + {/if} + </div> + + <div class="flex gap-3 justify-center flex-wrap"> + <Button onclick={() => window.location.href = "/"}> + Go Home + </Button> + <Button variant="tertiary" onclick={() => window.location.reload()}> + Retry + </Button> + <Button variant="secondary" onclick={handleCopyLogs}> + {copied ? "Copied!" : "Copy Error Report"} + </Button> + </div> + + <div> + <button + type="button" + class="text-sm text-light/40 hover:text-light/60 transition-colors underline" + onclick={handleShowLogs} + > + {showLogs ? "Hide" : "Show"} debug logs + </button> + + {#if showLogs} + <pre class="mt-4 p-4 bg-dark rounded-[16px] text-left text-xs text-light/70 overflow-auto max-h-[300px] font-mono whitespace-pre-wrap">{logDump || "No recent logs."}</pre> + {/if} + </div> + </div> +</div> diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 98bb4a0..18e1545 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -2,6 +2,7 @@ import { getContext } from "svelte"; import { Button, Card, Modal, Input } from "$lib/components/ui"; import { createOrganization, generateSlug } from "$lib/api/organizations"; + import { toasts } from "$lib/stores/toast.svelte"; import type { SupabaseClient } from "@supabase/supabase-js"; import type { Database } from "$lib/supabase/types"; @@ -24,6 +25,9 @@ const supabase = getContext<SupabaseClient<Database>>("supabase"); let organizations = $state(data.organizations); + $effect(() => { + organizations = data.organizations; + }); let showCreateModal = $state(false); let newOrgName = $state(""); let creating = $state(false); @@ -41,7 +45,9 @@ showCreateModal = false; newOrgName = ""; } catch (error) { - console.error("Failed to create organization:", error); + toasts.error( + "Failed to create organization. The name may already be taken.", + ); } finally { creating = false; } @@ -63,7 +69,7 @@ >Style Guide</a > <form method="POST" action="/auth/logout"> - <Button variant="ghost" size="sm" type="submit" + <Button variant="tertiary" size="sm" type="submit" >Sign Out</Button > </form> @@ -180,7 +186,7 @@ </p> {/if} <div class="flex justify-end gap-2 pt-2"> - <Button variant="ghost" onclick={() => (showCreateModal = false)} + <Button variant="tertiary" onclick={() => (showCreateModal = false)} >Cancel</Button > <Button diff --git a/src/routes/[orgSlug]/+layout.server.ts b/src/routes/[orgSlug]/+layout.server.ts index 24ec561..f5386c1 100644 --- a/src/routes/[orgSlug]/+layout.server.ts +++ b/src/routes/[orgSlug]/+layout.server.ts @@ -8,6 +8,7 @@ export const load: LayoutServerLoad = async ({ params, locals }) => { error(401, 'Unauthorized'); } + // Fetch org first (need org.id for subsequent queries) const { data: org, error: orgError } = await locals.supabase .from('organizations') .select('*') @@ -18,58 +19,62 @@ export const load: LayoutServerLoad = async ({ params, locals }) => { error(404, 'Organization not found'); } - const { data: membership } = await locals.supabase - .from('org_members') - .select('role') - .eq('org_id', org.id) - .eq('user_id', user.id) - .single(); + // Now fetch membership, members, and activity in parallel (all depend on org.id) + const [membershipResult, membersResult, activityResult] = await Promise.all([ + locals.supabase + .from('org_members') + .select('role') + .eq('org_id', org.id) + .eq('user_id', user.id) + .single(), + locals.supabase + .from('org_members') + .select(` + id, + user_id, + role, + profiles:user_id ( + id, + email, + full_name, + avatar_url + ) + `) + .eq('org_id', org.id) + .limit(10), + locals.supabase + .from('activity_log') + .select(` + id, + action, + entity_type, + entity_id, + entity_name, + created_at, + profiles:user_id ( + full_name, + email + ) + `) + .eq('org_id', org.id) + .order('created_at', { ascending: false }) + .limit(10) + ]); + + const { data: membership } = membershipResult; + const { data: members } = membersResult; + const { data: recentActivity } = activityResult; if (!membership) { error(403, 'You are not a member of this organization'); } - // Fetch team members for sidebar - const { data: members } = await locals.supabase - .from('org_members') - .select(` - id, - user_id, - role, - profiles:user_id ( - id, - email, - full_name, - avatar_url - ) - `) - .eq('org_id', org.id) - .limit(10); - - // Fetch recent activity - const { data: recentActivity } = await locals.supabase - .from('activity_log') - .select(` - id, - action, - entity_type, - entity_id, - entity_name, - created_at, - profiles:user_id ( - full_name, - email - ) - `) - .eq('org_id', org.id) - .order('created_at', { ascending: false }) - .limit(10); - return { org, role: membership.role, - userRole: membership.role, + userRole: membership.role, // kept for backwards compat — same as role members: members ?? [], - recentActivity: recentActivity ?? [] + recentActivity: recentActivity ?? [], + user }; }; diff --git a/src/routes/[orgSlug]/+layout.svelte b/src/routes/[orgSlug]/+layout.svelte index 34c2875..76965d0 100644 --- a/src/routes/[orgSlug]/+layout.svelte +++ b/src/routes/[orgSlug]/+layout.svelte @@ -1,6 +1,7 @@ <script lang="ts"> - import { page } from "$app/stores"; + import { page, navigating } from "$app/stores"; import type { Snippet } from "svelte"; + import { Avatar, Logo } from "$lib/components/ui"; interface Member { id: string; @@ -16,7 +17,12 @@ interface Props { data: { - org: { id: string; name: string; slug: string }; + org: { + id: string; + name: string; + slug: string; + avatar_url?: string | null; + }; role: string; userRole: string; members: Member[]; @@ -26,24 +32,25 @@ let { data, children }: Props = $props(); - let sidebarCollapsed = $state(false); - const isAdmin = $derived( data.userRole === "owner" || data.userRole === "admin", ); + // Sidebar collapses on all pages except org overview + const isOrgOverview = $derived($page.url.pathname === `/${data.org.slug}`); + let sidebarHovered = $state(false); + const sidebarCollapsed = $derived(!isOrgOverview && !sidebarHovered); + const navItems = $derived([ - { href: `/${data.org.slug}`, label: "Overview", icon: "home" }, { href: `/${data.org.slug}/documents`, - label: "Documents", - icon: "file", + label: "Files", + icon: "cloud", }, - { href: `/${data.org.slug}/kanban`, label: "Kanban", icon: "kanban" }, { href: `/${data.org.slug}/calendar`, label: "Calendar", - icon: "calendar", + icon: "calendar_today", }, // Only show settings for admins ...(isAdmin @@ -58,7 +65,7 @@ ]); function isActive(href: string): boolean { - return $page.url.pathname === href; + return $page.url.pathname.startsWith(href); } </script> @@ -66,206 +73,107 @@ <div class="flex h-screen bg-background p-4 gap-4"> <!-- Organization Module --> <aside - class="{sidebarCollapsed - ? 'w-20' - : 'w-56'} bg-night rounded-[32px] flex flex-col px-3 py-5 transition-all duration-200 overflow-hidden" + class=" + {sidebarCollapsed ? 'w-[72px]' : 'w-64'} + transition-all duration-300 + bg-night rounded-[32px] flex flex-col px-4 py-5 gap-4 overflow-hidden shrink-0 + " + onmouseenter={() => (sidebarHovered = true)} + onmouseleave={() => (sidebarHovered = false)} > <!-- Org Header --> - <div class="flex items-start gap-2 px-1 mb-2"> + <a + href="/{data.org.slug}" + class="flex items-center gap-2 p-1 rounded-[32px] hover:bg-dark transition-colors" + > <div - class="w-12 h-12 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xl font-heading shrink-0" + class="shrink-0 transition-all duration-300 {sidebarCollapsed + ? 'w-8 h-8' + : 'w-12 h-12'}" > - {data.org.name[0].toUpperCase()} + <Avatar + name={data.org.name} + src={data.org.avatar_url} + size="md" + /> </div> - {#if !sidebarCollapsed} - <div class="min-w-0 flex-1"> - <h1 class="font-heading text-xl text-light truncate"> - {data.org.name} - </h1> - <p class="text-xs text-white capitalize">{data.role}</p> - </div> - {/if} - </div> + <div + class="min-w-0 flex-1 overflow-hidden transition-all duration-300 {sidebarCollapsed + ? 'opacity-0 max-w-0' + : 'opacity-100 max-w-[200px]'}" + > + <h1 + class="font-heading text-h3 text-white truncate whitespace-nowrap" + > + {data.org.name} + </h1> + <p + class="text-body-sm text-white font-body capitalize whitespace-nowrap" + > + {data.role} + </p> + </div> + </a> <!-- Nav Items --> - <nav class="flex-1 space-y-0.5"> + <nav class="flex-1 flex flex-col gap-1"> {#each navItems as item} <a href={item.href} - class="flex items-center gap-2 pl-1 pr-2 py-1 rounded-[50px] transition-colors {isActive( + class="flex items-center gap-2 h-10 pl-1 pr-2 py-1 rounded-[32px] transition-colors {isActive( item.href, ) - ? 'bg-primary/20' - : 'hover:bg-light/5'}" + ? 'bg-primary' + : 'hover:bg-dark'}" title={sidebarCollapsed ? item.label : undefined} > - <!-- Icon circle --> <div - class="w-8 h-8 rounded-full {isActive(item.href) - ? 'bg-primary' - : 'bg-light'} flex items-center justify-center shrink-0" + class="w-8 h-8 flex items-center justify-center p-1 shrink-0" > - {#if item.icon === "home"} - <svg - class="w-4 h-4 {isActive(item.href) - ? 'text-white' - : 'text-night'}" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - > - <path - d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" - /> - <polyline points="9,22 9,12 15,12 15,22" /> - </svg> - {:else if item.icon === "file"} - <svg - class="w-4 h-4 {isActive(item.href) - ? 'text-white' - : 'text-night'}" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - > - <path - d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" - /> - <polyline points="14,2 14,8 20,8" /> - </svg> - {:else if item.icon === "kanban"} - <svg - class="w-4 h-4 {isActive(item.href) - ? 'text-white' - : 'text-night'}" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - > - <rect - x="3" - y="3" - width="18" - height="18" - rx="2" - /> - <line x1="9" y1="3" x2="9" y2="21" /> - <line x1="15" y1="3" x2="15" y2="21" /> - </svg> - {:else if item.icon === "calendar"} - <svg - class="w-4 h-4 {isActive(item.href) - ? 'text-white' - : 'text-night'}" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - > - <rect - x="3" - y="4" - width="18" - height="18" - rx="2" - /> - <line x1="16" y1="2" x2="16" y2="6" /> - <line x1="8" y1="2" x2="8" y2="6" /> - <line x1="3" y1="10" x2="21" y2="10" /> - </svg> - {:else if item.icon === "settings"} - <svg - class="w-4 h-4 {isActive(item.href) - ? 'text-white' - : 'text-night'}" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - > - <circle cx="12" cy="12" r="3" /> - <path - d="M12 1v2m0 18v2M4.2 4.2l1.4 1.4m12.8 12.8l1.4 1.4M1 12h2m18 0h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4" - /> - </svg> - {/if} - </div> - {#if !sidebarCollapsed} - <span class="font-bold text-light truncate" - >{item.label}</span + <span + class="material-symbols-rounded {isActive(item.href) + ? 'text-background' + : 'text-light'}" + style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;" > - {/if} + {item.icon} + </span> + </div> + <span + class="font-body text-body truncate whitespace-nowrap transition-all duration-300 {isActive( + item.href, + ) + ? 'text-background' + : 'text-white'} {sidebarCollapsed + ? 'opacity-0 max-w-0 overflow-hidden' + : 'opacity-100 max-w-[200px]'}">{item.label}</span + > </a> {/each} </nav> - <!-- Team Members --> - {#if !sidebarCollapsed} - <div class="mt-4 pt-4 border-t border-light/10"> - <p class="font-heading text-base text-light mb-2 px-1">Team</p> - {#if data.members && data.members.length > 0} - <div class="space-y-0.5"> - {#each data.members.slice(0, 5) as member} - <div - class="flex items-center gap-2 pl-1 pr-2 py-1 rounded-[50px] hover:bg-light/5 transition-colors" - > - <div - class="w-5 h-5 rounded-full bg-gradient-to-br from-primary to-primary/50 flex items-center justify-center text-white text-xs font-medium" - > - {(member.profiles?.full_name || - member.profiles?.email || - "?")[0].toUpperCase()} - </div> - <span - class="text-sm font-bold text-light truncate flex-1" - > - {member.profiles?.full_name || - member.profiles?.email?.split("@")[0] || - "User"} - </span> - </div> - {/each} - </div> - {:else} - <p class="text-xs text-light/40 px-1"> - No team members found - </p> - {/if} - </div> - {/if} - - <!-- Back link --> - <div class="mt-auto pt-4"> - <a - href="/" - class="flex items-center gap-2 pl-1 pr-2 py-1 rounded-[50px] text-light/50 hover:text-light hover:bg-light/5 transition-colors" - title={sidebarCollapsed ? "All Organizations" : undefined} - > - <div - class="w-5 h-5 rounded-full bg-light/20 flex items-center justify-center" - > - <svg - class="w-3 h-3" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - > - <path d="m15 18-6-6 6-6" /> - </svg> - </div> - {#if !sidebarCollapsed} - <span class="text-sm">All Organizations</span> - {/if} + <!-- Logo at bottom --> + <div class="mt-auto"> + <a href="/" title="Back to organizations"> + <Logo size={sidebarCollapsed ? "sm" : "md"} /> </a> </div> </aside> <!-- Main Content Area --> - <main class="flex-1 bg-night rounded-[32px] overflow-auto"> + <main class="flex-1 bg-night rounded-[32px] overflow-auto relative"> + {#if $navigating} + <div + class="absolute inset-0 z-10 flex items-center justify-center bg-night/80 backdrop-blur-sm" + > + <span + class="material-symbols-rounded text-primary animate-spin" + style="font-size: 40px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 40;" + > + progress_activity + </span> + </div> + {/if} {@render children()} </main> </div> diff --git a/src/routes/[orgSlug]/+page.svelte b/src/routes/[orgSlug]/+page.svelte index b005438..590992a 100644 --- a/src/routes/[orgSlug]/+page.svelte +++ b/src/routes/[orgSlug]/+page.svelte @@ -1,329 +1,21 @@ <script lang="ts"> - import { Card } from "$lib/components/ui"; - - interface ActivityItem { - id: string; - action: string; - entity_type: string; - entity_name: string | null; - created_at: string; - profiles: { full_name: string | null; email: string } | null; - } - interface Props { data: { org: { id: string; name: string; slug: string }; role: string; - members?: Array<{ - id: string; - user_id: string; - role: string; - profiles: { full_name: string | null; email: string }; - }>; - recentActivity?: ActivityItem[]; }; } let { data }: Props = $props(); - - const quickLinks = [ - { - href: `/${data.org.slug}/documents`, - label: "Documents", - description: "Collaborative docs and files", - icon: "file", - }, - { - href: `/${data.org.slug}/kanban`, - label: "Kanban", - description: "Track tasks and projects", - icon: "kanban", - }, - { - href: `/${data.org.slug}/calendar`, - label: "Calendar", - description: "Schedule events and meetings", - icon: "calendar", - }, - ]; - - // Get icon based on entity type - function getActivityIcon(entityType: string): string { - switch (entityType) { - case "document": - return "file"; - case "kanban_card": - case "kanban_board": - return "kanban"; - case "calendar_event": - return "calendar"; - case "member": - return "user"; - default: - return "activity"; - } - } - - // Format relative time - function formatRelativeTime(dateStr: string): string { - const date = new Date(dateStr); - const now = new Date(); - const diff = now.getTime() - date.getTime(); - const minutes = Math.floor(diff / 60000); - const hours = Math.floor(diff / 3600000); - const days = Math.floor(diff / 86400000); - - if (minutes < 1) return "Just now"; - if (minutes < 60) return `${minutes}m ago`; - if (hours < 24) return `${hours}h ago`; - if (days < 7) return `${days}d ago`; - return date.toLocaleDateString(); - } - - // Format action text - function formatAction(action: string, entityType: string): string { - const typeMap: Record<string, string> = { - document: "document", - kanban_card: "task", - kanban_board: "board", - calendar_event: "event", - member: "member", - }; - const type = typeMap[entityType] || entityType; - return `${action.charAt(0).toUpperCase() + action.slice(1)} ${type}`; - } </script> <svelte:head> - <title>{data.org.name} - Overview | Root + {data.org.name} | Root -
-
-

{data.org.name}

-

Organization Overview

+
+
+

{data.org.name}

+

Organization Overview

- -
- {#each quickLinks as link} - - -
-
- {#if link.icon === "file"} - - - - - {:else if link.icon === "kanban"} - - - - - - {:else if link.icon === "calendar"} - - - - - - - {/if} -
-

- {link.label} -

-

{link.description}

-
-
-
- {/each} -
- -
-

Recent Activity

- - {#if data.recentActivity && data.recentActivity.length > 0} -
- {#each data.recentActivity as activity} - {@const icon = getActivityIcon(activity.entity_type)} -
-
- {#if icon === "file"} - - - - - {:else if icon === "kanban"} - - - - - - {:else if icon === "calendar"} - - - - - - - {:else if icon === "user"} - - - - - {:else} - - - - - {/if} -
-
-

- {formatAction( - activity.action, - activity.entity_type, - )} -

-

- {activity.entity_name || "Unknown"} -

-
- {formatRelativeTime(activity.created_at)} -
- {/each} -
- {:else} -
-

No recent activity

-
- {/if} -
-
- - - {#if data.members && data.members.length > 0} -
-

Team

- -
-
- {#each data.members.slice(0, 8) as member} -
- {(member.profiles?.full_name || - member.profiles?.email || - "?")[0].toUpperCase()} -
- {/each} - {#if data.members.length > 8} -
- +{data.members.length - 8} -
- {/if} -
-

- {data.members.length} team member{data.members - .length !== 1 - ? "s" - : ""} -

-
-
-
- {/if}
diff --git a/src/routes/[orgSlug]/calendar/+page.server.ts b/src/routes/[orgSlug]/calendar/+page.server.ts index 8b805b4..b1d877c 100644 --- a/src/routes/[orgSlug]/calendar/+page.server.ts +++ b/src/routes/[orgSlug]/calendar/+page.server.ts @@ -1,4 +1,7 @@ import type { PageServerLoad } from './$types'; +import { createLogger } from '$lib/utils/logger'; + +const log = createLogger('page.calendar'); export const load: PageServerLoad = async ({ parent, locals }) => { const { org, userRole } = await parent(); @@ -9,7 +12,7 @@ export const load: PageServerLoad = async ({ parent, locals }) => { const startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1); const endDate = new Date(now.getFullYear(), now.getMonth() + 2, 0); - const { data: events } = await supabase + const { data: events, error } = await supabase .from('calendar_events') .select('*') .eq('org_id', org.id) @@ -17,6 +20,10 @@ export const load: PageServerLoad = async ({ parent, locals }) => { .lte('end_time', endDate.toISOString()) .order('start_time'); + if (error) { + log.error('Failed to load calendar events', { error, data: { orgId: org.id } }); + } + return { events: events ?? [], userRole diff --git a/src/routes/[orgSlug]/calendar/+page.svelte b/src/routes/[orgSlug]/calendar/+page.svelte index 5124a6d..9fe572d 100644 --- a/src/routes/[orgSlug]/calendar/+page.svelte +++ b/src/routes/[orgSlug]/calendar/+page.svelte @@ -1,6 +1,6 @@ - {selectedDoc ? `${selectedDoc.name} - ` : ""}Documents - {data.org - .name} | Root + Files - {data.org.name} | Root -
- - -
- {#if selectedDoc} -
-

- {selectedDoc.name} -

- -
-
- -
- {:else} -
-
- - - - -

Select a document to edit

-
-
- {/if} -
+
+
- - (showCreateModal = false)} - title="Create New" -> -
-
- - -
- - - -
- - -
-
-
- - { - showEditModal = false; - editingDoc = null; - newDocName = ""; - }} - title="Rename" -> -
- - -
- - -
-
-
diff --git a/src/routes/[orgSlug]/documents/[id]/+page.server.ts b/src/routes/[orgSlug]/documents/[id]/+page.server.ts new file mode 100644 index 0000000..96d4a94 --- /dev/null +++ b/src/routes/[orgSlug]/documents/[id]/+page.server.ts @@ -0,0 +1,31 @@ +import type { PageServerLoad } from './$types'; +import { error, redirect } from '@sveltejs/kit'; +import { createLogger } from '$lib/utils/logger'; + +const log = createLogger('page.document'); + +export const load: PageServerLoad = async ({ parent, locals, params }) => { + const { org } = await parent() as { org: { id: string; slug: string } }; + const { supabase } = locals; + const { id } = params; + + log.debug('Redirecting document by ID', { data: { id, orgId: org.id } }); + + const { data: document, error: docError } = await supabase + .from('documents') + .select('type') + .eq('org_id', org.id) + .eq('id', id) + .single(); + + if (docError || !document) { + log.error('Document not found', { error: docError, data: { id, orgId: org.id } }); + throw error(404, 'Document not found'); + } + + if (document.type === 'folder') { + throw redirect(302, `/${org.slug}/documents/folder/${id}`); + } + + throw redirect(302, `/${org.slug}/documents/file/${id}`); +}; diff --git a/src/routes/[orgSlug]/documents/[id]/+page.svelte b/src/routes/[orgSlug]/documents/[id]/+page.svelte new file mode 100644 index 0000000..1e4491f --- /dev/null +++ b/src/routes/[orgSlug]/documents/[id]/+page.svelte @@ -0,0 +1,9 @@ + +
+ + progress_activity + +
diff --git a/src/routes/[orgSlug]/documents/file/[id]/+page.server.ts b/src/routes/[orgSlug]/documents/file/[id]/+page.server.ts new file mode 100644 index 0000000..eac491c --- /dev/null +++ b/src/routes/[orgSlug]/documents/file/[id]/+page.server.ts @@ -0,0 +1,39 @@ +import type { PageServerLoad } from './$types'; +import { error, redirect } from '@sveltejs/kit'; +import { createLogger } from '$lib/utils/logger'; + +const log = createLogger('page.file'); + +export const load: PageServerLoad = async ({ parent, locals, params }) => { + const { org, user } = await parent() as { org: { id: string; slug: string }; user: { id: string } | null }; + const { supabase } = locals; + const { id } = params; + + log.debug('Loading file by ID', { data: { id, orgId: org.id } }); + + const { data: document, error: docError } = await supabase + .from('documents') + .select('*') + .eq('org_id', org.id) + .eq('id', id) + .single(); + + if (docError || !document) { + log.error('File not found', { error: docError, data: { id, orgId: org.id } }); + throw error(404, 'File not found'); + } + + if (document.type === 'folder') { + throw redirect(302, `/${org.slug}/documents/folder/${id}`); + } + + const isKanban = document.type === 'kanban'; + + return { + document, + isKanban, + isFolder: false, + children: [], + user + }; +}; diff --git a/src/routes/[orgSlug]/documents/file/[id]/+page.svelte b/src/routes/[orgSlug]/documents/file/[id]/+page.svelte new file mode 100644 index 0000000..6a65539 --- /dev/null +++ b/src/routes/[orgSlug]/documents/file/[id]/+page.svelte @@ -0,0 +1,572 @@ + + + + {data.document.name} - {data.org.name} | Root + + +
+ {#if data.isKanban} + + +
+

+ {data.document.name} +

+ + +
+ +
+
+ {#if kanbanBoard} + (showAddColumnModal = true)} + onDeleteCard={handleDeleteCard} + onDeleteColumn={handleDeleteColumn} + canEdit={true} + /> + {:else} +
+
+ + progress_activity + +

Loading board...

+
+
+ {/if} +
+
+ {:else} + + + {/if} + + + {#if isSaving} +
Saving...
+ {/if} +
+ + +{#if showCardModal} + { + showCardModal = false; + selectedCard = null; + targetColumnId = null; + }} + onUpdate={(updatedCard) => { + if (kanbanBoard) { + kanbanBoard = { + ...kanbanBoard, + columns: kanbanBoard.columns.map((col) => ({ + ...col, + cards: col.cards.map((c) => + c.id === updatedCard.id ? updatedCard : c, + ), + })), + }; + } + }} + onDelete={(cardId) => handleDeleteCard(cardId)} + columnId={targetColumnId ?? undefined} + userId={data.user?.id} + orgId={data.org.id} + onCreate={(newCard) => { + loadKanbanBoard(); + showCardModal = false; + selectedCard = null; + targetColumnId = null; + }} + /> +{/if} + + + { + showAddColumnModal = false; + newColumnName = ""; + }} + title="Add Column" +> +
+ +
+ + +
+
+
diff --git a/src/routes/[orgSlug]/documents/folder/[id]/+page.server.ts b/src/routes/[orgSlug]/documents/folder/[id]/+page.server.ts new file mode 100644 index 0000000..02d5712 --- /dev/null +++ b/src/routes/[orgSlug]/documents/folder/[id]/+page.server.ts @@ -0,0 +1,43 @@ +import type { PageServerLoad } from './$types'; +import { error } from '@sveltejs/kit'; +import { createLogger } from '$lib/utils/logger'; + +const log = createLogger('page.folder'); + +export const load: PageServerLoad = async ({ parent, locals, params }) => { + const { org, user } = await parent() as { org: { id: string; slug: string }; user: { id: string } | null }; + const { supabase } = locals; + const { id } = params; + + log.debug('Loading folder by ID', { data: { id, orgId: org.id } }); + + const { data: document, error: docError } = await supabase + .from('documents') + .select('*') + .eq('org_id', org.id) + .eq('id', id) + .single(); + + if (docError || !document) { + log.error('Folder not found', { error: docError, data: { id, orgId: org.id } }); + throw error(404, 'Folder not found'); + } + + if (document.type !== 'folder') { + log.error('Document is not a folder', { data: { id, type: document.type } }); + throw error(404, 'Not a folder'); + } + + // Load all documents in this org (for breadcrumb building and file listing) + const { data: allDocuments } = await supabase + .from('documents') + .select('*') + .eq('org_id', org.id) + .order('name'); + + return { + folder: document, + documents: allDocuments ?? [], + user + }; +}; diff --git a/src/routes/[orgSlug]/documents/folder/[id]/+page.svelte b/src/routes/[orgSlug]/documents/folder/[id]/+page.svelte new file mode 100644 index 0000000..fd38451 --- /dev/null +++ b/src/routes/[orgSlug]/documents/folder/[id]/+page.svelte @@ -0,0 +1,34 @@ + + + + {data.folder.name} - {data.org.name} | Root + + +
+ +
diff --git a/src/routes/[orgSlug]/kanban/+page.server.ts b/src/routes/[orgSlug]/kanban/+page.server.ts index b03eecd..d4305b5 100644 --- a/src/routes/[orgSlug]/kanban/+page.server.ts +++ b/src/routes/[orgSlug]/kanban/+page.server.ts @@ -1,15 +1,22 @@ import type { PageServerLoad } from './$types'; +import { createLogger } from '$lib/utils/logger'; + +const log = createLogger('page.kanban'); export const load: PageServerLoad = async ({ parent, locals }) => { const { org } = await parent(); const { supabase } = locals; - const { data: boards } = await supabase + const { data: boards, error } = await supabase .from('kanban_boards') .select('*') .eq('org_id', org.id) .order('created_at'); + if (error) { + log.error('Failed to load kanban boards', { error, data: { orgId: org.id } }); + } + return { boards: boards ?? [] }; diff --git a/src/routes/[orgSlug]/kanban/+page.svelte b/src/routes/[orgSlug]/kanban/+page.svelte index 8d4e7d9..d963af2 100644 --- a/src/routes/[orgSlug]/kanban/+page.svelte +++ b/src/routes/[orgSlug]/kanban/+page.svelte @@ -1,12 +1,22 @@ @@ -461,148 +498,40 @@ Settings - {data.org.name} | Root -
-
-

Settings

-

Manage {data.org.name}

-
- - -
- - - - - +
+ +
+
+ +

Settings

+ + + +
+ + +
+ {#each tabs as tab} + + {/each} +
{#if activeTab === "general"} -
- -
-

- Organization Details -

- -
-
- - -
-
- -
- yoursite.com/ - -
-

- Changing the slug will update all URLs for this - organization. -

-
-
- -
-
-
-
- - {#if !isOwner} - -
-

- Leave Organization -

-

- Leave this organization. You will need to be - re-invited to rejoin. -

-
- -
-
-
- {/if} - - {#if isOwner} - -
-

- Danger Zone -

-

- Permanently delete this organization and all its - data. -

-
- -
-
-
- {/if} -
+ {/if} @@ -654,18 +583,20 @@

- Copy Link - Cancel
@@ -679,7 +610,10 @@
{#each members as member} - {@const profile = member.profiles} + {@const rawProfile = member.profiles} + {@const profile = Array.isArray(rawProfile) + ? rawProfile[0] + : rawProfile}
@@ -717,10 +651,11 @@ )?.color ?? '#6366f1'}">{member.role} {#if member.user_id !== data.user?.id && member.role !== "owner"} - Edit {/if}
@@ -741,16 +676,7 @@ Create custom roles with specific permissions.

-
@@ -783,17 +709,19 @@
{#if !role.is_system || role.name !== "Owner"} - Edit {/if} {#if !role.is_system} - Delete {/if}
@@ -977,198 +905,6 @@
{/if} - - - {#if activeTab === "appearance"} -
- -
-

Theme

-

- Customize the look and feel of your workspace. -

- - -
- -
- {#each ["dark", "light", "system"] as mode} - - {/each} -
-
- - -
- -
- {#each PRESET_COLORS as color} - - {/each} -
-

- Selected: {PRESET_COLORS.find( - (c) => c.primary === $theme.primaryColor, - )?.name || "Custom"} -

-
-
-
- - -
-

- Reset Theme -

-

- Reset to the default theme settings. -

- -
-
-
- {/if} @@ -1178,44 +914,34 @@ title="Invite Member" >
-
- - -
-
- - -
+ + - - - - - -
+ - +
- (showConnectModal = false)}>Cancel

Wrong account? Sign out

@@ -177,7 +178,7 @@

-
diff --git a/src/routes/layout.css b/src/routes/layout.css index 585f73c..0044d5d 100644 --- a/src/routes/layout.css +++ b/src/routes/layout.css @@ -1,4 +1,5 @@ -@import url('https://fonts.googleapis.com/css2?family=Tilt+Warp&family=Work+Sans:wght@400;500;600;700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Tilt+Warp&family=Work+Sans:wght@400;500;600;700&family=Inter:wght@400;500;600&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap'); @import 'tailwindcss'; @plugin '@tailwindcss/forms'; @plugin '@tailwindcss/typography'; @@ -26,8 +27,26 @@ /* Typography - Figma Fonts */ --font-heading: 'Tilt Warp', sans-serif; --font-body: 'Work Sans', sans-serif; + --font-input: 'Inter', sans-serif; --font-sans: 'Work Sans', system-ui, -apple-system, sans-serif; + /* Font Sizes - Figma Text Styles (--text-* → text-* utilities) */ + /* Headings (heading font) */ + --text-h1: 32px; + --text-h2: 28px; + --text-h3: 24px; + --text-h4: 20px; + --text-h5: 16px; + --text-h6: 14px; + /* Button text (heading font) */ + --text-btn-lg: 20px; + --text-btn-md: 16px; + --text-btn-sm: 14px; + /* Body text (body font) */ + --text-body: 16px; + --text-body-md: 14px; + --text-body-sm: 12px; + /* Border Radius - Figma Design */ --radius-sm: 8px; --radius-md: 16px; @@ -37,127 +56,44 @@ --radius-circle: 128px; } -/* Base styles */ -html, body { - background-color: var(--color-background); - color: var(--color-light); - font-family: var(--font-body); - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -/* Headings */ -h1, h2, h3, h4, h5, h6 { - font-family: var(--font-heading); - font-weight: 400; -} - -/* Scrollbar styling */ -::-webkit-scrollbar { - width: 8px; - height: 8px; -} - -::-webkit-scrollbar-track { - background: transparent; -} - -::-webkit-scrollbar-thumb { - background: var(--color-night); - border-radius: var(--radius-pill); -} - -::-webkit-scrollbar-thumb:hover { - background: var(--color-dark); -} - -/* Focus styles */ -:focus-visible { - outline: 2px solid var(--color-primary); - outline-offset: 2px; -} - -/* Selection */ -::selection { - background-color: rgba(0, 163, 224, 0.3); - color: var(--color-light); -} - -/* Prose/Markdown styles */ -.prose { - line-height: 1.6; -} - -.prose p { - margin: 0.5em 0; -} - -.prose strong { - font-weight: 700; - color: var(--color-light); -} - -.prose code { - background: var(--color-night); - padding: 0.15em 0.4em; - border-radius: 4px; - font-family: 'Consolas', 'Monaco', monospace; - font-size: 0.9em; - color: var(--color-primary); -} - -.prose pre { - background: var(--color-night); - padding: 1em; - border-radius: var(--radius-sm); - overflow-x: auto; - margin: 0.5em 0; -} - -.prose pre code { - background: none; - padding: 0; - color: var(--color-light); -} - -.prose blockquote { - border-left: 3px solid var(--color-primary); - padding-left: 1em; - margin: 0.5em 0; - color: var(--color-text-muted); - font-style: italic; -} - -.prose ul, .prose ol { - padding-left: 1.5em; - margin: 0.5em 0; -} - -.prose ul { - list-style-type: disc; -} - -.prose ol { - list-style-type: decimal; -} - -.prose li { - margin: 0.25em 0; -} - -.prose h1, .prose h2, .prose h3, .prose h4 { - color: var(--color-light); - margin: 0.75em 0 0.5em; - font-family: var(--font-heading); -} - -.prose a { - color: var(--color-primary); - text-decoration: underline; -} - -.prose hr { - border: none; - border-top: 1px solid var(--color-dark); - margin: 1em 0; +/* Base layer — element defaults via Tailwind utilities */ +@layer base { + html, body { + @apply bg-background text-light font-body antialiased; + } + + h1 { @apply font-heading font-normal text-h1 leading-normal; } + h2 { @apply font-heading font-normal text-h2 leading-normal; } + h3 { @apply font-heading font-normal text-h3 leading-normal; } + h4 { @apply font-heading font-normal text-h4 leading-normal; } + h5 { @apply font-heading font-normal text-h5 leading-normal; } + h6 { @apply font-heading font-normal text-h6 leading-normal; } +} + +/* Scrollbar — no Tailwind equivalent for pseudo-elements */ +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { @apply bg-night rounded-pill; } +::-webkit-scrollbar-thumb:hover { @apply bg-dark; } + +/* Focus & Selection — pseudo-elements require raw CSS */ +:focus-visible { @apply outline-2 outline-primary outline-offset-2; } +::selection { @apply text-light; background-color: rgba(0, 163, 224, 0.3); } + +/* Prose/Markdown styles — used by the TipTap editor */ +@layer components { + .prose { @apply leading-relaxed; } + .prose p { @apply my-2; } + .prose strong { @apply font-bold text-light; } + .prose code { @apply bg-night px-1.5 py-0.5 rounded text-primary text-[0.9em]; font-family: 'Consolas', 'Monaco', monospace; } + .prose pre { @apply bg-night p-4 rounded-sm overflow-x-auto my-2; } + .prose pre code { @apply bg-transparent p-0 text-light; } + .prose blockquote { @apply border-l-3 border-primary pl-4 my-2 text-text-muted italic; } + .prose ul, .prose ol { @apply pl-6 my-2; } + .prose ul { @apply list-disc; } + .prose ol { @apply list-decimal; } + .prose li { @apply my-1; } + .prose h1, .prose h2, .prose h3, .prose h4 { @apply text-light font-heading; margin: 0.75em 0 0.5em; } + .prose a { @apply text-primary underline; } + .prose hr { @apply border-t border-dark my-4; } } diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index 2cc93b7..2f277ff 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -4,17 +4,29 @@ import { goto } from "$app/navigation"; import { page } from "$app/stores"; - let email = $state(""); + let email = $state($page.url.searchParams.get("email") || ""); let password = $state(""); let isLoading = $state(false); let error = $state(""); - let mode = $state<"login" | "signup">("login"); + let signupSuccess = $state(false); + let mode = $state<"login" | "signup">( + ($page.url.searchParams.get("tab") as "login" | "signup") || "login", + ); const supabase = createClient(); // Get redirect URL from query params (for invite flow) const redirectUrl = $derived($page.url.searchParams.get("redirect") || "/"); + // Show error from callback (e.g. OAuth failure) + const callbackError = $page.url.searchParams.get("error"); + if (callbackError) { + error = + callbackError === "auth_callback_error" + ? "Authentication failed. Please try again." + : callbackError; + } + async function handleSubmit() { if (!email || !password) { error = "Please fill in all fields"; @@ -32,17 +44,24 @@ password, }); if (authError) throw authError; + goto(redirectUrl); } else { - const { error: authError } = await supabase.auth.signUp({ - email, - password, - options: { - emailRedirectTo: `${window.location.origin}/auth/callback`, - }, - }); + const { data: signUpData, error: authError } = + await supabase.auth.signUp({ + email, + password, + options: { + emailRedirectTo: `${window.location.origin}/auth/callback?redirect=${encodeURIComponent(redirectUrl)}`, + }, + }); if (authError) throw authError; + // If email confirmation is required, session will be null + if (signUpData.session) { + goto(redirectUrl); + } else { + signupSuccess = true; + } } - goto(redirectUrl); } catch (e: unknown) { error = e instanceof Error ? e.message : "An error occurred"; } finally { @@ -79,97 +98,129 @@ -

- {mode === "login" ? "Welcome back" : "Create your account"} -

+ {#if signupSuccess} +
+
+ + mark_email_read + +
+

+ Check your email +

+

+ We've sent a confirmation link to {email}. Click the link to activate your account. +

+ +
+ {:else} +

+ {mode === "login" ? "Welcome back" : "Create your account"} +

+ + {#if error} +
+ {error} +
+ {/if} - {#if error} -
{ + e.preventDefault(); + handleSubmit(); + }} + class="space-y-4" > - {error} + + + + + + + +
+
+ or continue with +
- {/if} -
{ - e.preventDefault(); - handleSubmit(); - }} - class="space-y-4" - > - - - - - -
- -
-
- or continue with -
-
- - - -

- {#if mode === "login"} - Don't have an account? - - {:else} - Already have an account? - - {/if} -

+ +

+ {#if mode === "login"} + Don't have an account? + + {:else} + Already have an account? + + {/if} +

+ {/if}
diff --git a/src/routes/page.svelte.spec.ts b/src/routes/page.svelte.spec.ts deleted file mode 100644 index 9b564bb..0000000 --- a/src/routes/page.svelte.spec.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { page } from 'vitest/browser'; -import { describe, expect, it } from 'vitest'; -import { render } from 'vitest-browser-svelte'; -import Page from './+page.svelte'; - -describe('/+page.svelte', () => { - it('should render h1', async () => { - render(Page); - - const heading = page.getByRole('heading', { level: 1 }); - await expect.element(heading).toBeInTheDocument(); - }); -}); diff --git a/src/routes/style/+page.svelte b/src/routes/style/+page.svelte index cebdcee..66d8d58 100644 --- a/src/routes/style/+page.svelte +++ b/src/routes/style/+page.svelte @@ -11,6 +11,12 @@ Spinner, Toggle, Toast, + Chip, + ListItem, + OrgHeader, + CalendarDay, + Logo, + ContentHeader, } from "$lib/components/ui"; let inputValue = $state(""); @@ -124,7 +130,7 @@
- +
@@ -141,6 +147,18 @@ +
+

+ With Icons (Material Symbols) +

+
+ + + + +
+
+

States @@ -157,7 +175,9 @@ Full Width

- +
@@ -202,6 +222,8 @@ label="Password" placeholder="••••••••" /> + + @@ -263,28 +285,22 @@ Sizes
- -

- With Status + With Status (placeholder)

- - - - + + + +
@@ -303,6 +319,88 @@ + +
+

+ Chips +

+ +
+
+

+ Variants +

+
+ Primary + Success + Warning + Error + Default +
+
+
+
+ + +
+

+ List Items +

+ +
+ Default Item + Hover State + Active Item + Documents + Dashboard +
+
+ + +
+

+ Organization Header +

+ +
+ + + +
+
+ + +
+

+ Calendar Day +

+ +
+ + + +
+
+ + + {#snippet events()} + Meeting + {/snippet} + + +
+
+

-
-

- Heading 1 (4xl bold) -

-

- Heading 2 (3xl bold) -

-

- Heading 3 (2xl semibold) -

-

- Heading 4 (xl semibold) -

-
- Heading 5 (lg medium) -
-
- Heading 6 (base medium) -
-

- Body text (base, 80% opacity) - Lorem ipsum dolor sit amet, - consectetur adipiscing elit. Sed do eiusmod tempor - incididunt ut labore et dolore magna aliqua. -

-

- Small text (sm, 60% opacity) - Used for secondary - information and hints. -

-

- Extra small text (xs, 40% opacity) - Used for metadata and - timestamps. -

+
+ +
+

+ Headings — Tilt Warp +

+
+
+ h1 · 32 +

Heading 1

+
+
+ h2 · 28 +

Heading 2

+
+
+ h3 · 24 +

Heading 3

+
+
+ h4 · 20 +

Heading 4

+
+
+ h5 · 16 +
Heading 5
+
+
+ h6 · 14 +
Heading 6
+
+
+
+ + +
+

+ Button Text — Tilt Warp +

+
+
+ btn-lg · 20 + Button Large +
+
+ btn-md · 16 + Button Medium +
+
+ btn-sm · 14 + Button Small +
+
+
+ + +
+

+ Body — Work Sans +

+
+
+ p · 16 +

+ Body text — Lorem ipsum dolor sit amet, + consectetur adipiscing elit. +

+
+
+ p-md · 14 +

+ Body medium — Used for secondary information and + descriptions. +

+
+
+ p-sm · 12 +

+ Body small — Used for metadata, timestamps, and + hints. +

+
+
+

@@ -558,6 +750,51 @@ + +
+

+ Logo +

+

+ Brand logo component with size variants. +

+
+
+ + Small +
+
+ + Medium +
+
+
+ + +
+

+ Content Header +

+

+ Page header component with avatar, title, action button, and + more menu. +

+
+ {}} + onMore={() => {}} + /> + {}} /> + +
+
+