Mega push vol 4

master
AlacrisDevs 4 hours ago
parent b517bb975c
commit d8bbfd9dc3
  1. 4
      .env.example
  2. 666
      AUDIT.md
  3. 31
      package-lock.json
  4. 3
      package.json
  5. 7
      src/app.d.ts
  6. 7
      src/demo.spec.ts
  7. 19
      src/hooks.client.ts
  8. 30
      src/hooks.server.ts
  9. 32
      src/lib/api/calendar.ts
  10. 152
      src/lib/api/document-locks.ts
  11. 51
      src/lib/api/documents.ts
  12. 151
      src/lib/api/kanban.ts
  13. 52
      src/lib/api/organizations.ts
  14. 245
      src/lib/components/calendar/Calendar.svelte
  15. 105
      src/lib/components/documents/DocumentViewer.svelte
  16. 29
      src/lib/components/documents/Editor.svelte
  17. 874
      src/lib/components/documents/FileBrowser.svelte
  18. 253
      src/lib/components/documents/FileTree.svelte
  19. 3
      src/lib/components/documents/index.ts
  20. 170
      src/lib/components/kanban/CardChecklist.svelte
  21. 159
      src/lib/components/kanban/CardComments.svelte
  22. 344
      src/lib/components/kanban/CardDetailModal.svelte
  23. 90
      src/lib/components/kanban/CardMetadata.svelte
  24. 309
      src/lib/components/kanban/KanbanBoard.svelte
  25. 188
      src/lib/components/kanban/KanbanCard.svelte
  26. 3
      src/lib/components/kanban/index.ts
  27. 216
      src/lib/components/settings/SettingsGeneral.svelte
  28. 1
      src/lib/components/settings/index.ts
  29. 108
      src/lib/components/ui/AssigneePicker.svelte
  30. 99
      src/lib/components/ui/Avatar.svelte
  31. 38
      src/lib/components/ui/Badge.svelte
  32. 107
      src/lib/components/ui/Button.svelte
  33. 35
      src/lib/components/ui/CalendarDay.svelte
  34. 26
      src/lib/components/ui/Chip.svelte
  35. 42
      src/lib/components/ui/ContentHeader.svelte
  36. 64
      src/lib/components/ui/Dropdown.svelte
  37. 31
      src/lib/components/ui/DropdownItem.svelte
  38. 29
      src/lib/components/ui/EmptyState.svelte
  39. 16
      src/lib/components/ui/Icon.svelte
  40. 59
      src/lib/components/ui/IconButton.svelte
  41. 84
      src/lib/components/ui/Input.svelte
  42. 61
      src/lib/components/ui/KanbanColumn.svelte
  43. 60
      src/lib/components/ui/ListItem.svelte
  44. 39
      src/lib/components/ui/Logo.svelte
  45. 45
      src/lib/components/ui/Modal.svelte
  46. 30
      src/lib/components/ui/OrgHeader.svelte
  47. 32
      src/lib/components/ui/Select.svelte
  48. 72
      src/lib/components/ui/Skeleton.svelte
  49. 44
      src/lib/components/ui/Textarea.svelte
  50. 4
      src/lib/components/ui/ToastContainer.svelte
  51. 14
      src/lib/components/ui/index.ts
  52. 1
      src/lib/index.ts
  53. 27
      src/lib/stores/auth.svelte.ts
  54. 52
      src/lib/stores/documents.svelte.ts
  55. 2
      src/lib/stores/index.ts
  56. 59
      src/lib/stores/organizations.svelte.ts
  57. 222
      src/lib/stores/theme.ts
  58. 83
      src/lib/stores/toast.svelte.ts
  59. 48
      src/lib/stores/toast.ts
  60. 1
      src/lib/supabase/index.ts
  61. 19
      src/lib/supabase/server.ts
  62. 1471
      src/lib/supabase/types.ts
  63. 207
      src/lib/utils/logger.ts
  64. 82
      src/routes/+error.svelte
  65. 12
      src/routes/+page.svelte
  66. 37
      src/routes/[orgSlug]/+layout.server.ts
  67. 264
      src/routes/[orgSlug]/+layout.svelte
  68. 318
      src/routes/[orgSlug]/+page.svelte
  69. 9
      src/routes/[orgSlug]/calendar/+page.server.ts
  70. 66
      src/routes/[orgSlug]/calendar/+page.svelte
  71. 11
      src/routes/[orgSlug]/documents/+page.server.ts
  72. 332
      src/routes/[orgSlug]/documents/+page.svelte
  73. 31
      src/routes/[orgSlug]/documents/[id]/+page.server.ts
  74. 9
      src/routes/[orgSlug]/documents/[id]/+page.svelte
  75. 39
      src/routes/[orgSlug]/documents/file/[id]/+page.server.ts
  76. 572
      src/routes/[orgSlug]/documents/file/[id]/+page.svelte
  77. 43
      src/routes/[orgSlug]/documents/folder/[id]/+page.server.ts
  78. 34
      src/routes/[orgSlug]/documents/folder/[id]/+page.svelte
  79. 9
      src/routes/[orgSlug]/kanban/+page.server.ts
  80. 235
      src/routes/[orgSlug]/kanban/+page.svelte
  81. 39
      src/routes/[orgSlug]/settings/+page.server.ts
  82. 673
      src/routes/[orgSlug]/settings/+page.svelte
  83. 35
      src/routes/api/google-calendar/events/+server.ts
  84. 7
      src/routes/auth/callback/+server.ts
  85. 12
      src/routes/invite/[token]/+page.server.ts
  86. 7
      src/routes/invite/[token]/+page.svelte
  87. 182
      src/routes/layout.css
  88. 61
      src/routes/login/+page.svelte
  89. 13
      src/routes/page.svelte.spec.ts
  90. 319
      src/routes/style/+page.svelte
  91. 66
      supabase/migrations/013_kanban_labels.sql
  92. 101
      supabase/migrations/014_document_enhancements.sql
  93. 45
      supabase/migrations/015_migrate_kanban_to_documents.sql
  94. 41
      supabase/migrations/016_document_locks.sql
  95. 28
      supabase/migrations/017_avatars_storage.sql

@ -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

@ -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 <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);
<title>{(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)

31
package-lock.json generated

@ -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"
}
}

@ -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"
}
}

7
src/app.d.ts vendored

@ -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 {}
}

@ -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);
});
});

@ -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),
};
};

@ -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),
};
};

@ -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));
}

@ -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);
}

@ -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>,

@ -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')
.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: newPosition })
.eq('id', cardId);
.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(

@ -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 {

@ -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,117 +123,110 @@
});
</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")}
class="p-1 text-light/60 hover:text-light hover:bg-dark rounded-lg transition-colors"
onclick={prev}
aria-label="Previous"
>
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")}
<span
class="material-symbols-rounded"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>chevron_left</span
>
Week
</button>
<span
class="font-heading text-h4 text-white min-w-[200px] text-center"
>{headerTitle}</span
>
<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")}
class="p-1 text-light/60 hover:text-light hover:bg-dark rounded-lg transition-colors"
onclick={next}
aria-label="Next"
>
<span
class="material-symbols-rounded"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>chevron_right</span
>
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"
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="p-2 text-light/60 hover:text-light hover:bg-light/10 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"
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
>
<path d="m15 18-6-6 6-6" />
</svg>
</button>
<button
class="p-2 text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
onclick={next}
aria-label="Next"
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
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<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
>
<path d="m9 18 6-6-6-6" />
</svg>
</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 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="font-heading text-h4 text-white text-center"
>{day}</span
>
{day}
</div>
{/each}
</div>
{#each days as day}
<!-- 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)}
<button
class="bg-dark min-h-[80px] p-1 text-left transition-colors hover:bg-light/5"
class:opacity-40={!inMonth}
<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)}
>
<div class="flex items-center justify-center w-7 h-7 mb-1">
<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-body text-body text-white {isToday
? 'text-primary font-bold'
: ''}"
>
{day.getDate()}
</span>
</div>
<div class="space-y-0.5">
{#each dayEvents.slice(0, 3) as event}
{#each dayEvents.slice(0, 2) as event}
<button
class="w-full text-xs px-1 py-0.5 rounded truncate text-left"
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 ??
'#6366f1'}20; color: {event.color ??
'#6366f1'}"
'#00A3E0'}"
onclick={(e) => {
e.stopPropagation();
onEventClick?.(event);
@ -248,88 +235,88 @@
{event.title}
</button>
{/each}
{#if dayEvents.length > 3}
<p class="text-xs text-light/40 px-1">
+{dayEvents.length - 3} more
</p>
{#if dayEvents.length > 2}
<span
class="text-body-sm text-light/40 mt-0.5"
>+{dayEvents.length - 2} more</span
>
{/if}
</div>
</button>
{/each}
</div>
{/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"
>
<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="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 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'}"
>
{weekDayHeaders[(day.getDay() + 6) % 7]}
</div>
<div
class="text-lg font-medium {isToday
class="font-body text-body-md {isToday
? 'text-primary'
: 'text-light'}"
: 'text-light/60'}"
>
{day.getDate()}
</div>
</div>
<div class="min-h-[300px] p-1 space-y-1">
<div class="flex-1 px-2 pb-2 space-y-1 overflow-y-auto">
{#each dayEvents as event}
<button
class="w-full text-xs px-2 py-1.5 rounded text-left"
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 ??
'#6366f1'}20; color: {event.color ??
'#6366f1'}"
'#00A3E0'}"
onclick={() => onEventClick?.(event)}
>
<div class="font-medium truncate">
{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}
</div>
</div>
{/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}

@ -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>

@ -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) {
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(() => {
saveStatus = "idle";
if (isMounted) saveStatus = "idle";
}, 2000);
} catch {
saveStatus = "error";
}
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>

@ -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>

@ -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>

@ -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';

@ -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>

@ -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>

@ -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
<!-- 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="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)}
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)}
>
{#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}
{tag.name}
</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"
>
{/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
class="w-full px-3 py-2 text-left text-sm text-light/60 hover:bg-light/5 flex items-center gap-2"
onclick={() => {
assigneeId = null;
showAssigneePicker = false;
}}
type="button"
class="text-primary text-sm font-bold hover:text-primary/80"
onclick={createTag}
>
<div
class="w-6 h-6 rounded-full bg-light/10"
></div>
Unassigned
Add
</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'}"
type="button"
class="text-light/40 text-sm hover:text-light"
onclick={() => {
assigneeId = member.user_id;
showAssigneePicker = false;
showTagInput = false;
newTagName = "";
}}
>
<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}
Cancel
</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"
<!-- Assignee, Due Date, Priority Row -->
<div class="grid grid-cols-3 gap-4">
<AssigneePicker
label="Assignee"
value={assigneeId}
{members}
onchange={(id) => (assigneeId = id)}
/>
</div>
<!-- Priority -->
<div>
<label
for="priority"
class="block text-sm font-medium text-light mb-1"
>Priority</label
>
<select
id="priority"
<Input type="date" label="Due Date" bind:value={dueDate} />
<Select
label="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>
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}

@ -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}

@ -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 handleCardDragOver(e: DragEvent, columnId: string, index: number) {
e.preventDefault();
e.stopPropagation();
if (!draggedCard) return;
// 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 handleDrop(e: DragEvent, columnId: string) {
e.preventDefault();
const targetIndex = dragOverCardIndex;
dragOverColumn = null;
dragOverCardIndex = null;
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);
}
if (!column) {
draggedCard = null;
return;
}
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;
}
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();
}
} else {
newPosition = column.cards.length;
}
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));
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 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}
<span
class="text-xs text-light/50 bg-light/10 px-1.5 py-0.5 rounded"
>
{column.cards.length}
</span>
</h3>
<div class="flex items-center gap-1">
{#if column.color}
<div
class="w-3 h-3 rounded-full"
style="background-color: {column.color}"
></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"
class="bg-dark flex items-center justify-center px-1.5 py-0.5 rounded-[8px] shrink-0"
>
<svg
class="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<span class="font-heading text-h6 text-white"
>{column.cards.length}</span
>
<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"
>
{#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"
type="button"
class="p-1 hover:bg-night rounded-lg transition-colors shrink-0"
onclick={() => onDeleteColumn?.(column.id)}
aria-label="Column options"
>
<svg
class="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<span
class="material-symbols-rounded text-light/50"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
<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>
more_horiz
</span>
</button>
{/if}
{#if card.color}
</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-full h-1 rounded-full mb-2"
style="background-color: {card.color}"
class="h-1 bg-primary rounded-full mx-2 my-1 transition-all"
></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"
class="mb-2"
ondragover={(e) =>
handleCardDragOver(e, column.id, cardIndex)}
>
<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"
<KanbanCardComponent
{card}
isDragging={draggedCard?.id === card.id}
draggable={canEdit}
ondragstart={(e) => handleDragStart(e, card)}
onclick={() => onCardClick?.(card)}
ondelete={canEdit
? (id) => onDeleteCard?.(id)
: undefined}
/>
<circle cx="12" cy="7" r="4" />
</svg>
</div>
{/if}
</div>
{/if}
</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>

@ -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 handleDelete(e: MouseEvent) {
e.stopPropagation();
if (confirm("Are you sure you want to delete this card?")) {
ondelete?.(card.id);
}
}
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();
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}
function getDueDateVariant(
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));
if (days < 0) return "error";
if (days <= 2) return "warning";
return "default";
}
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>
<!-- 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}
<!-- Description -->
{#if card.description}
<p class="text-xs text-light/50 mt-1 line-clamp-2">
{card.description}
<!-- Title -->
<p class="font-body text-body text-white w-full leading-none">
{card.title}
</p>
{/if}
<!-- Footer with metadata -->
<div class="mt-3 flex items-center justify-between gap-2">
<!-- 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}
<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"
<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"
>
<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>
</span>
</div>
{/if}
<!-- 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"
<!-- 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"
>
A
{card.checklist_done ?? 0}/{card.checklist_total}
</span>
</div>
{/if}
</div>
<!-- Assignee avatar -->
{#if card.assignee_id}
<Avatar
name={card.assignee_name || "?"}
src={card.assignee_avatar}
size="sm"
/>
{/if}
</div>
{/if}
</button>

@ -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';

@ -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>

@ -0,0 +1 @@
export { default as SettingsGeneral } from './SettingsGeneral.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>

@ -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">
<div
class="rounded-full flex items-center justify-center font-medium text-white overflow-hidden {sizeClasses[
size
]} {!src ? getColorFromName(name) : 'bg-surface'}"
>
{#if src}
<img {src} alt={name} class="w-full h-full object-cover" />
<img
{src}
alt={name}
class="{sizes[size].box} {sizes[size].radius} object-cover shrink-0"
/>
{:else}
{getInitials(name)}
{/if}
</div>
{#if status}
<div
class="absolute bottom-0 right-0 rounded-full border-2 border-dark {statusSizes[size]} {statusColors[
status
]}"
></div>
{/if}
class="{sizes[size].box} {sizes[size]
.radius} bg-primary flex items-center justify-center shrink-0"
>
<span class="font-heading {sizes[size].text} text-night leading-none">
{initial}
</span>
</div>
{/if}

@ -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>

@ -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}
</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>

@ -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}

@ -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>

@ -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>

@ -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>

@ -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>

@ -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>

@ -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>

@ -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>

@ -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,14 +46,31 @@
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">
<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"
>
<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}
@ -50,51 +80,39 @@
{required}
{autocomplete}
{oninput}
{onchange}
{onkeydown}
class="w-full px-3 py-3 bg-night text-white rounded-[32px] min-w-[192px]
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"
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/40 hover:text-white transition-colors"
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"}
>
{#if showPassword}
<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: 22px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
<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}
{showPassword ? "visibility_off" : "visibility"}
</span>
</button>
{/if}
</div>
</div>
{#if error}
<p class="text-sm text-error px-3">{error}</p>

@ -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>

@ -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>

@ -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>

@ -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>

@ -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>

@ -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
{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:border-error={error}
class:placeholder-shown={!value}
class:ring-1={error}
class:ring-error={error}
>
{#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>

@ -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>

@ -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
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:border-error={error}
class:focus:border-error={error}
class:focus:ring-error={error}
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>

@ -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">

@ -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';

@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.

@ -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();

@ -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();

@ -1,2 +0,0 @@
export { auth } from './auth.svelte';
export { orgs, type OrgWithRole } from './organizations.svelte';

@ -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();

@ -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);
}
});
}

@ -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();

@ -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();

@ -1,3 +1,2 @@
export { createClient } from './client';
export { createClient as createServerClient } from './server';
export type * from './types';

@ -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: '/' });
});
}
}
});
}

File diff suppressed because it is too large Load Diff

@ -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');
}

@ -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>

@ -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

@ -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,19 +19,15 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
error(404, 'Organization not found');
}
const { data: membership } = await locals.supabase
// 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();
if (!membership) {
error(403, 'You are not a member of this organization');
}
// Fetch team members for sidebar
const { data: members } = await locals.supabase
.single(),
locals.supabase
.from('org_members')
.select(`
id,
@ -44,10 +41,8 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
)
`)
.eq('org_id', org.id)
.limit(10);
// Fetch recent activity
const { data: recentActivity } = await locals.supabase
.limit(10),
locals.supabase
.from('activity_log')
.select(`
id,
@ -63,13 +58,23 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
`)
.eq('org_id', org.id)
.order('created_at', { ascending: false })
.limit(10);
.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');
}
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
};
};

@ -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">
<div
class="w-12 h-12 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xl font-heading shrink-0"
<a
href="/{data.org.slug}"
class="flex items-center gap-2 p-1 rounded-[32px] hover:bg-dark transition-colors"
>
{data.org.name[0].toUpperCase()}
<div
class="shrink-0 transition-all duration-300 {sidebarCollapsed
? 'w-8 h-8'
: 'w-12 h-12'}"
>
<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">
<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-xs text-white capitalize">{data.role}</p>
</div>
{/if}
<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"
>
{#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"
class="w-8 h-8 flex items-center justify-center p-1 shrink-0"
>
<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"
<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;"
>
<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}
{item.icon}
</span>
</div>
{#if !sidebarCollapsed}
<span class="font-bold text-light truncate"
>{item.label}</span
<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
>
{/if}
</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}
<!-- Logo at bottom -->
<div class="mt-auto">
<a href="/" title="Back to organizations">
<Logo size={sidebarCollapsed ? "sm" : "md"} />
</a>
</div>
{/if}
</aside>
<!-- 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}
>
<!-- Main Content Area -->
<main class="flex-1 bg-night rounded-[32px] overflow-auto relative">
{#if $navigating}
<div
class="w-5 h-5 rounded-full bg-light/20 flex items-center justify-center"
class="absolute inset-0 z-10 flex items-center justify-center bg-night/80 backdrop-blur-sm"
>
<svg
class="w-3 h-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<span
class="material-symbols-rounded text-primary animate-spin"
style="font-size: 40px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 40;"
>
<path d="m15 18-6-6 6-6" />
</svg>
progress_activity
</span>
</div>
{#if !sidebarCollapsed}
<span class="text-sm">All Organizations</span>
{/if}
</a>
</div>
</aside>
<!-- Main Content Area -->
<main class="flex-1 bg-night rounded-[32px] overflow-auto">
{@render children()}
</main>
</div>

@ -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</title>
<title>{data.org.name} | Root</title>
</svelte:head>
<div class="p-8">
<header class="mb-8">
<h1 class="text-3xl font-heading text-light">{data.org.name}</h1>
<p class="text-light/50 mt-1">Organization Overview</p>
<div class="p-4 lg:p-6">
<header>
<h1 class="text-h1 font-heading text-white">{data.org.name}</h1>
<p class="text-body text-light/60 font-body">Organization Overview</p>
</header>
<section class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
{#each quickLinks as link}
<a href={link.href} class="block group">
<Card
class="h-full hover:ring-1 hover:ring-primary/50 transition-all"
>
<div class="p-6">
<div
class="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors"
>
{#if link.icon === "file"}
<svg
class="w-6 h-6 text-primary"
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 link.icon === "kanban"}
<svg
class="w-6 h-6 text-primary"
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 link.icon === "calendar"}
<svg
class="w-6 h-6 text-primary"
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>
{/if}
</div>
<h3 class="text-lg font-semibold text-light mb-1">
{link.label}
</h3>
<p class="text-sm text-light/50">{link.description}</p>
</div>
</Card>
</a>
{/each}
</section>
<section>
<h2 class="text-xl font-heading text-light mb-4">Recent Activity</h2>
<Card>
{#if data.recentActivity && data.recentActivity.length > 0}
<div class="divide-y divide-light/10">
{#each data.recentActivity as activity}
{@const icon = getActivityIcon(activity.entity_type)}
<div
class="flex items-center gap-4 p-4 hover:bg-light/5 transition-colors"
>
<div
class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center shrink-0"
>
{#if icon === "file"}
<svg
class="w-5 h-5 text-primary"
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 icon === "kanban"}
<svg
class="w-5 h-5 text-primary"
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 icon === "calendar"}
<svg
class="w-5 h-5 text-primary"
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 icon === "user"}
<svg
class="w-5 h-5 text-primary"
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>
{:else}
<svg
class="w-5 h-5 text-primary"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12,6 12,12 16,14" />
</svg>
{/if}
</div>
<div class="flex-1 min-w-0">
<p class="text-light font-medium">
{formatAction(
activity.action,
activity.entity_type,
)}
</p>
<p class="text-sm text-light/50 truncate">
{activity.entity_name || "Unknown"}
</p>
</div>
<span class="text-xs text-light/40 shrink-0"
>{formatRelativeTime(activity.created_at)}</span
>
</div>
{/each}
</div>
{:else}
<div class="p-6 text-center text-light/50">
<p>No recent activity</p>
</div>
{/if}
</Card>
</section>
<!-- Team Stats -->
{#if data.members && data.members.length > 0}
<section class="mt-8">
<h2 class="text-xl font-heading text-light mb-4">Team</h2>
<Card>
<div class="p-4">
<div class="flex items-center gap-2 flex-wrap">
{#each data.members.slice(0, 8) as member}
<div
class="w-10 h-10 rounded-full bg-gradient-to-br from-primary to-primary/50 flex items-center justify-center text-white font-medium"
title={member.profiles?.full_name ||
member.profiles?.email}
>
{(member.profiles?.full_name ||
member.profiles?.email ||
"?")[0].toUpperCase()}
</div>
{/each}
{#if data.members.length > 8}
<div
class="w-10 h-10 rounded-full bg-light/10 flex items-center justify-center text-light/50 text-sm"
>
+{data.members.length - 8}
</div>
{/if}
</div>
<p class="text-sm text-light/50 mt-3">
{data.members.length} team member{data.members
.length !== 1
? "s"
: ""}
</p>
</div>
</Card>
</section>
{/if}
</div>

@ -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

@ -1,6 +1,6 @@
<script lang="ts">
import { getContext, onMount } from "svelte";
import { Button, Modal } from "$lib/components/ui";
import { Button, Modal, Avatar } from "$lib/components/ui";
import { Calendar } from "$lib/components/calendar";
import {
getCalendarSubscribeUrl,
@ -24,6 +24,9 @@
const supabase = getContext<SupabaseClient<Database>>("supabase");
let events = $state(data.events);
$effect(() => {
events = data.events;
});
let googleEvents = $state<CalendarEvent[]>([]);
let isOrgCalendarConnected = $state(false);
let isLoadingGoogle = $state(false);
@ -133,57 +136,50 @@
<title>Calendar - {data.org.name} | Root</title>
</svelte:head>
<div class="p-6 h-full overflow-auto">
<header class="flex items-center justify-between mb-6">
<div class="flex items-center gap-4">
<h1 class="text-2xl font-bold text-light">Calendar</h1>
<div class="flex flex-col h-full p-4 lg:p-5 gap-4">
<!-- Header -->
<header class="flex items-center gap-2 p-1">
<Avatar name="Calendar" size="md" />
<h1 class="flex-1 font-heading text-h1 text-white">Calendar</h1>
{#if isOrgCalendarConnected}
<div class="flex items-center gap-2">
<span
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-blue-500/10 text-blue-400 rounded-lg"
>
<svg class="w-4 h-4" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
</svg>
{orgCalendarName ?? "Google Calendar"}
{#if isLoadingGoogle}
<span class="animate-spin"></span>
{/if}
</span>
<button
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-green-500/10 text-green-400 rounded-lg hover:bg-green-500/20 transition-colors"
type="button"
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-primary/10 text-primary rounded-[32px] hover:bg-primary/20 transition-colors"
onclick={subscribeToCalendar}
title="Add to your Google Calendar"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<span
class="material-symbols-rounded"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>
<path d="M12 5v14M5 12h14" />
</svg>
add
</span>
Subscribe
</button>
</div>
{/if}
</div>
<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>
<p class="text-light/50 text-sm mb-4">
View events from connected Google Calendar. Event creation coming soon.
</p>
<!-- Calendar Grid -->
<div class="flex-1 overflow-auto">
<Calendar
events={allEvents}
onDateClick={handleDateClick}
onEventClick={handleEventClick}
/>
</div>
</div>
<Modal
isOpen={showEventModal}

@ -1,15 +1,24 @@
import type { PageServerLoad } from './$types';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('page.documents');
export const load: PageServerLoad = async ({ parent, locals }) => {
const { org } = await parent();
const { supabase } = locals;
const { data: documents } = await supabase
const { data: documents, error } = await supabase
.from('documents')
.select('*')
.eq('org_id', org.id)
.order('name');
if (error) {
log.error('Failed to load documents', { error, data: { orgId: org.id } });
}
log.debug('Documents loaded', { data: { count: documents?.length ?? 0 } });
return {
documents: documents ?? []
};

@ -1,11 +1,6 @@
<script lang="ts">
import { getContext } from "svelte";
import { Button, Modal, Input } from "$lib/components/ui";
import { FileTree, Editor } from "$lib/components/documents";
import { buildDocumentTree } from "$lib/api/documents";
import { FileBrowser } from "$lib/components/documents";
import type { Document } from "$lib/supabase/types";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
interface Props {
data: {
@ -17,326 +12,21 @@
let { data }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
let documents = $state(data.documents);
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">("document");
let parentFolderId = $state<string | null>(null);
let isEditing = $state(false);
const documentTree = $derived(buildDocumentTree(documents));
function handleSelect(doc: Document) {
if (doc.type === "document") {
selectedDoc = doc;
}
}
function handleDoubleClick(doc: Document) {
if (doc.type === "document") {
// Open document in new window
const url = `/${data.org.slug}/documents/${doc.id}`;
window.open(url, "_blank", "width=900,height=700");
}
}
function handleAdd(folderId: string | null) {
parentFolderId = folderId;
showCreateModal = true;
}
async function handleMove(docId: string, newParentId: string | null) {
const { error } = await supabase
.from("documents")
.update({
parent_id: newParentId,
updated_at: new Date().toISOString(),
})
.eq("id", docId);
if (!error) {
documents = documents.map((d) =>
d.id === docId ? { ...d, parent_id: newParentId } : d,
);
}
}
async function handleCreate() {
if (!newDocName.trim() || !data.user) return;
const { data: newDoc, error } = await supabase
.from("documents")
.insert({
org_id: data.org.id,
name: newDocName,
type: newDocType,
parent_id: parentFolderId,
created_by: data.user.id,
content:
newDocType === "document"
? { type: "doc", content: [] }
: null,
})
.select()
.single();
if (!error && newDoc) {
documents = [...documents, newDoc];
if (newDocType === "document") {
selectedDoc = newDoc;
}
}
showCreateModal = false;
newDocName = "";
newDocType = "document";
parentFolderId = null;
}
async function handleSave(content: unknown) {
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,
);
}
function handleEdit(doc: Document) {
editingDoc = doc;
newDocName = doc.name;
showEditModal = true;
}
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;
// If deleting a folder, delete all children first
if (doc.type === "folder") {
const childIds = documents
.filter((d) => d.parent_id === doc.id)
.map((d) => d.id);
if (childIds.length > 0) {
await supabase.from("documents").delete().in("id", childIds);
}
}
const { error } = await supabase
.from("documents")
.delete()
.eq("id", doc.id);
if (!error) {
documents = documents.filter(
(d) => d.id !== doc.id && d.parent_id !== doc.id,
);
if (selectedDoc?.id === doc.id) {
selectedDoc = null;
}
}
}
$effect(() => {
documents = data.documents;
});
</script>
<svelte:head>
<title
>{selectedDoc ? `${selectedDoc.name} - ` : ""}Documents - {data.org
.name} | Root</title
>
<title>Files - {data.org.name} | Root</title>
</svelte:head>
<div class="flex h-full">
<aside class="w-72 border-r border-light/10 flex flex-col">
<div
class="p-4 border-b border-light/10 flex items-center justify-between"
>
<h2 class="font-semibold text-light">Documents</h2>
<Button size="sm" onclick={() => (showCreateModal = true)}>
<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>
</div>
<div class="flex-1 overflow-y-auto p-2">
{#if documentTree.length === 0}
<div class="text-center text-light/40 py-8 text-sm">
<p>No documents yet</p>
<p class="mt-1">Create your first document</p>
</div>
{:else}
<FileTree
items={documentTree}
selectedId={selectedDoc?.id ?? null}
onSelect={handleSelect}
onDoubleClick={handleDoubleClick}
onAdd={handleAdd}
onMove={handleMove}
onEdit={handleEdit}
onDelete={handleDelete}
/>
{/if}
</div>
</aside>
<main class="flex-1 overflow-hidden flex flex-col">
{#if selectedDoc}
<div
class="flex items-center justify-between p-4 border-b border-light/10"
>
<h2 class="text-lg font-semibold text-light">
{selectedDoc.name}
</h2>
<button
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {isEditing
? 'bg-primary text-white'
: 'bg-light/10 text-light hover:bg-light/20'}"
onclick={() => (isEditing = !isEditing)}
>
{isEditing ? "Preview" : "Edit"}
</button>
</div>
<div class="flex-1 overflow-auto">
<Editor
document={selectedDoc}
onSave={handleSave}
editable={isEditing}
/>
</div>
{:else}
<div class="h-full flex items-center justify-center text-light/40">
<div class="text-center">
<svg
class="w-16 h-16 mx-auto mb-4 opacity-50"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<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>
<p>Select a document to edit</p>
</div>
</div>
{/if}
</main>
</div>
<Modal
isOpen={showCreateModal}
onClose={() => (showCreateModal = false)}
title="Create New"
>
<div class="space-y-4">
<div class="flex gap-2">
<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")}
>
Document
</button>
<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")}
>
Folder
</button>
</div>
<Input
label="Name"
bind:value={newDocName}
placeholder={newDocType === "folder"
? "Folder name"
: "Document name"}
/>
<div class="flex justify-end gap-2 pt-2">
<Button variant="ghost" onclick={() => (showCreateModal = false)}
>Cancel</Button
>
<Button onclick={handleCreate} disabled={!newDocName.trim()}
>Create</Button
>
</div>
</div>
</Modal>
<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="h-full p-4 lg:p-5">
<FileBrowser
org={data.org}
bind:documents
currentFolderId={null}
user={data.user}
/>
<div class="flex justify-end gap-2 pt-2">
<Button
variant="ghost"
onclick={() => {
showEditModal = false;
editingDoc = null;
newDocName = "";
}}>Cancel</Button
>
<Button onclick={handleRename} disabled={!newDocName.trim()}
>Save</Button
>
</div>
</div>
</Modal>

@ -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}`);
};

@ -0,0 +1,9 @@
<!-- This route redirects to /folder/[id] or /file/[id] via +page.server.ts -->
<div class="flex items-center justify-center h-full">
<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>

@ -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
};
};

@ -0,0 +1,572 @@
<script lang="ts">
import { getContext, onDestroy, onMount } from "svelte";
import { Button, Modal, Input } from "$lib/components/ui";
import { DocumentViewer } from "$lib/components/documents";
import { KanbanBoard, CardDetailModal } from "$lib/components/kanban";
import {
fetchBoardWithColumns,
createColumn,
moveCard,
deleteCard,
deleteColumn,
subscribeToBoard,
} from "$lib/api/kanban";
import {
getLockInfo,
acquireLock,
releaseLock,
startHeartbeat,
type LockInfo,
} from "$lib/api/document-locks";
import { createLogger } from "$lib/utils/logger";
import { toasts } from "$lib/stores/toast.svelte";
import type {
RealtimeChannel,
SupabaseClient,
} from "@supabase/supabase-js";
import type { Database, KanbanCard, Document } from "$lib/supabase/types";
import type { BoardWithColumns } from "$lib/api/kanban";
const log = createLogger("page.file-viewer");
interface Props {
data: {
org: { id: string; name: string; slug: string };
document: Document;
isKanban: boolean;
isFolder: boolean;
children: any[];
user: { id: string } | null;
};
}
let { data }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
let isSaving = $state(false);
// Document lock state
let lockInfo = $state<LockInfo>({
isLocked: false,
lockedBy: null,
lockedByName: null,
isOwnLock: false,
});
let hasLock = $state(false);
let stopHeartbeat: (() => void) | null = null;
// Acquire lock for document editing (not for kanban)
onMount(async () => {
if (data.isKanban || !data.user) return;
// Check current lock status
lockInfo = await getLockInfo(supabase, data.document.id, data.user.id);
if (lockInfo.isLocked && !lockInfo.isOwnLock) {
// Someone else is editing
return;
}
// Try to acquire lock
const acquired = await acquireLock(
supabase,
data.document.id,
data.user.id,
);
if (acquired) {
hasLock = true;
stopHeartbeat = startHeartbeat(
supabase,
data.document.id,
data.user.id,
);
} else {
// Refresh lock info to get who holds it
lockInfo = await getLockInfo(
supabase,
data.document.id,
data.user.id,
);
}
});
// Kanban state
let kanbanBoard = $state<BoardWithColumns | null>(null);
let realtimeChannel = $state<RealtimeChannel | null>(null);
let showCardModal = $state(false);
let selectedCard = $state<KanbanCard | null>(null);
let targetColumnId = $state<string | null>(null);
let cardModalMode = $state<"edit" | "create">("edit");
let showAddColumnModal = $state(false);
let newColumnName = $state("");
async function handleSave(content: import("$lib/supabase/types").Json) {
isSaving = true;
try {
await supabase
.from("documents")
.update({
content,
updated_at: new Date().toISOString(),
})
.eq("id", data.document.id);
} catch (err) {
log.error("Failed to save document", { error: err });
toasts.error("Failed to save document");
}
isSaving = false;
}
// Kanban functions
async function loadKanbanBoard() {
if (!data.isKanban) return;
try {
const content = data.document.content as Record<
string,
unknown
> | null;
const boardId = (content?.board_id as string) || data.document.id;
let board = await fetchBoardWithColumns(supabase, boardId).catch(
() => null,
);
if (!board) {
log.info("Auto-creating kanban_boards entry for document", {
data: { boardId, docId: data.document.id },
});
const { data: newBoard, error: createErr } = await supabase
.from("kanban_boards")
.insert({
id: data.document.id,
org_id: data.org.id,
name: data.document.name,
})
.select()
.single();
if (createErr) {
log.error("Failed to auto-create kanban board", {
error: createErr,
});
toasts.error("Failed to load kanban board");
return;
}
await supabase.from("kanban_columns").insert([
{ board_id: data.document.id, name: "To Do", position: 0 },
{
board_id: data.document.id,
name: "In Progress",
position: 1,
},
{ board_id: data.document.id, name: "Done", position: 2 },
]);
await supabase
.from("documents")
.update({
content: {
type: "kanban",
board_id: data.document.id,
} as import("$lib/supabase/types").Json,
})
.eq("id", data.document.id);
board = await fetchBoardWithColumns(
supabase,
data.document.id,
).catch(() => null);
}
kanbanBoard = board;
} catch (err) {
log.error("Failed to load kanban board", { error: err });
toasts.error("Failed to load kanban board");
}
}
$effect(() => {
if (data.isKanban) {
loadKanbanBoard();
}
});
$effect(() => {
if (!kanbanBoard) return;
const channel = subscribeToBoard(
supabase,
kanbanBoard.id,
() => loadKanbanBoard(),
() => loadKanbanBoard(),
);
realtimeChannel = channel;
return () => {
if (channel) {
supabase.removeChannel(channel);
}
};
});
onDestroy(() => {
if (realtimeChannel) {
supabase.removeChannel(realtimeChannel);
}
// Release document lock
if (hasLock && data.user) {
stopHeartbeat?.();
releaseLock(supabase, data.document.id, data.user.id);
}
});
async function handleCardMove(
cardId: string,
toColumnId: string,
toPosition: number,
) {
try {
await moveCard(supabase, cardId, toColumnId, toPosition);
} catch (err) {
log.error("Failed to move card", { error: err });
toasts.error("Failed to move card");
}
}
function handleCardClick(card: KanbanCard) {
selectedCard = card;
cardModalMode = "edit";
showCardModal = true;
}
function handleAddCard(columnId: string) {
targetColumnId = columnId;
selectedCard = null;
cardModalMode = "create";
showCardModal = true;
}
async function handleAddColumn() {
if (!kanbanBoard || !newColumnName.trim()) return;
try {
await createColumn(
supabase,
kanbanBoard.id,
newColumnName,
kanbanBoard.columns.length,
);
newColumnName = "";
showAddColumnModal = false;
await loadKanbanBoard();
} catch (err) {
log.error("Failed to add column", { error: err });
toasts.error("Failed to add column");
}
}
async function handleDeleteColumn(columnId: string) {
if (!confirm("Delete this column and all its cards?")) return;
try {
await deleteColumn(supabase, columnId);
await loadKanbanBoard();
} catch (err) {
log.error("Failed to delete column", { error: err });
toasts.error("Failed to delete column");
}
}
async function handleDeleteCard(cardId: string) {
try {
await deleteCard(supabase, cardId);
await loadKanbanBoard();
} catch (err) {
log.error("Failed to delete card", { error: err });
toasts.error("Failed to delete card");
}
}
// JSON Import for kanban board
let fileInput = $state<HTMLInputElement | null>(null);
let isImporting = $state(false);
function triggerImport() {
fileInput?.click();
}
async function handleJsonImport(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file || !kanbanBoard) return;
isImporting = true;
try {
const text = await file.text();
const json = JSON.parse(text);
// Support two formats:
// 1. Full board export: { columns: [{ name, cards: [{ title, description, ... }] }] }
// 2. Flat card list: [{ title, description, column?, ... }]
if (Array.isArray(json)) {
// Flat card list — add all to first column
const firstCol = kanbanBoard.columns[0];
if (!firstCol) {
toasts.error("No columns exist to import cards into");
return;
}
let pos = firstCol.cards.length;
for (const card of json) {
await supabase.from("kanban_cards").insert({
column_id: firstCol.id,
title: card.title || "Untitled",
description: card.description || null,
priority: card.priority || null,
due_date: card.due_date || null,
position: pos++,
created_by: data.user?.id ?? null,
});
}
toasts.success(`Imported ${json.length} cards`);
} else if (json.columns && Array.isArray(json.columns)) {
// Full board format with columns
let colPos = kanbanBoard.columns.length;
for (const col of json.columns) {
// Check if column already exists by name
let targetCol = kanbanBoard.columns.find(
(c) =>
c.name.toLowerCase() ===
(col.name || "").toLowerCase(),
);
if (!targetCol) {
const { data: newCol, error: colErr } = await supabase
.from("kanban_columns")
.insert({
board_id: kanbanBoard.id,
name: col.name || `Column ${colPos}`,
position: colPos++,
})
.select()
.single();
if (colErr || !newCol) continue;
targetCol = { ...newCol, cards: [] };
}
if (col.cards && Array.isArray(col.cards)) {
let cardPos = targetCol.cards?.length ?? 0;
for (const card of col.cards) {
await supabase.from("kanban_cards").insert({
column_id: targetCol.id,
title: card.title || "Untitled",
description: card.description || null,
priority: card.priority || null,
due_date: card.due_date || null,
color: card.color || null,
position: cardPos++,
created_by: data.user?.id ?? null,
});
}
}
}
const totalCards = json.columns.reduce(
(sum: number, c: any) => sum + (c.cards?.length ?? 0),
0,
);
toasts.success(
`Imported ${json.columns.length} columns, ${totalCards} cards`,
);
} else {
toasts.error(
"Unrecognized JSON format. Expected { columns: [...] } or [{ title, ... }]",
);
return;
}
await loadKanbanBoard();
} catch (err) {
log.error("JSON import failed", { error: err });
toasts.error("Failed to import JSON — check file format");
} finally {
isImporting = false;
input.value = "";
}
}
function handleExportJson() {
if (!kanbanBoard) return;
const exportData = {
board: kanbanBoard.name,
columns: kanbanBoard.columns.map((col) => ({
name: col.name,
cards: col.cards.map((card) => ({
title: card.title,
description: card.description,
priority: card.priority,
due_date: card.due_date,
color: card.color,
assignee_id: card.assignee_id,
})),
})),
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${kanbanBoard.name || "board"}.json`;
a.click();
URL.revokeObjectURL(url);
toasts.success("Board exported as JSON");
}
</script>
<svelte:head>
<title>{data.document.name} - {data.org.name} | Root</title>
</svelte:head>
<div class="flex flex-col h-full p-4 lg:p-5 gap-4">
{#if data.isKanban}
<!-- Kanban: needs its own header since DocumentViewer is for documents -->
<input
type="file"
accept=".json"
class="hidden"
bind:this={fileInput}
onchange={handleJsonImport}
/>
<header class="flex items-center gap-2 p-1">
<h1 class="flex-1 font-heading text-h1 text-white truncate">
{data.document.name}
</h1>
<Button
variant="tertiary"
size="sm"
icon="upload"
onclick={triggerImport}
loading={isImporting}
>
Import JSON
</Button>
<Button
variant="tertiary"
size="sm"
icon="download"
onclick={handleExportJson}
>
Export JSON
</Button>
</header>
<div class="flex-1 overflow-auto min-h-0">
<div class="h-full">
{#if kanbanBoard}
<KanbanBoard
columns={kanbanBoard.columns}
onCardClick={handleCardClick}
onCardMove={handleCardMove}
onAddCard={handleAddCard}
onAddColumn={() => (showAddColumnModal = true)}
onDeleteCard={handleDeleteCard}
onDeleteColumn={handleDeleteColumn}
canEdit={true}
/>
{:else}
<div class="flex items-center justify-center h-full">
<div class="text-center">
<span
class="material-symbols-rounded text-light/30 animate-spin mb-4"
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48;"
>
progress_activity
</span>
<p class="text-light/50">Loading board...</p>
</div>
</div>
{/if}
</div>
</div>
{:else}
<!-- Document Editor: use shared DocumentViewer component -->
<DocumentViewer
document={data.document}
onSave={handleSave}
mode="edit"
locked={lockInfo.isLocked && !lockInfo.isOwnLock}
lockedByName={lockInfo.lockedByName}
/>
{/if}
<!-- Status Bar -->
{#if isSaving}
<div class="text-body-sm text-light/50">Saving...</div>
{/if}
</div>
<!-- Kanban Card Detail Modal -->
{#if showCardModal}
<CardDetailModal
isOpen={showCardModal}
card={selectedCard}
mode={cardModalMode}
onClose={() => {
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}
<!-- Add Column Modal -->
<Modal
isOpen={showAddColumnModal}
onClose={() => {
showAddColumnModal = false;
newColumnName = "";
}}
title="Add Column"
>
<div class="space-y-4">
<Input
label="Column Name"
bind:value={newColumnName}
placeholder="e.g., To Do, In Progress, Done"
/>
<div class="flex justify-end gap-2 pt-2">
<Button
variant="tertiary"
onclick={() => (showAddColumnModal = false)}
>
Cancel
</Button>
<Button onclick={handleAddColumn} disabled={!newColumnName.trim()}>
Add Column
</Button>
</div>
</div>
</Modal>

@ -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
};
};

@ -0,0 +1,34 @@
<script lang="ts">
import { FileBrowser } from "$lib/components/documents";
import type { Document } from "$lib/supabase/types";
interface Props {
data: {
org: { id: string; name: string; slug: string };
folder: Document;
documents: Document[];
user: { id: string } | null;
};
}
let { data }: Props = $props();
let documents = $state(data.documents);
$effect(() => {
documents = data.documents;
});
const currentFolderId = $derived(data.folder.id);
</script>
<svelte:head>
<title>{data.folder.name} - {data.org.name} | Root</title>
</svelte:head>
<div class="h-full p-4 lg:p-5">
<FileBrowser
org={data.org}
bind:documents
{currentFolderId}
user={data.user}
/>
</div>

@ -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 ?? []
};

@ -1,12 +1,22 @@
<script lang="ts">
import { getContext } from "svelte";
import { Button, Card, Modal, Input } from "$lib/components/ui";
import { getContext, onDestroy } from "svelte";
import {
Button,
Card,
Modal,
Input,
Avatar,
IconButton,
Icon,
} from "$lib/components/ui";
import { KanbanBoard, CardDetailModal } from "$lib/components/kanban";
import {
fetchBoardWithColumns,
createBoard,
moveCard,
subscribeToBoard,
} from "$lib/api/kanban";
import type { RealtimeChannel } from "@supabase/supabase-js";
import type {
KanbanBoard as KanbanBoardType,
KanbanCard,
@ -28,6 +38,9 @@
const supabase = getContext<SupabaseClient<Database>>("supabase");
let boards = $state(data.boards);
$effect(() => {
boards = data.boards;
});
let selectedBoard = $state<BoardWithColumns | null>(null);
let showCreateBoardModal = $state(false);
let showEditBoardModal = $state(false);
@ -35,15 +48,49 @@
let selectedCard = $state<KanbanCard | null>(null);
let newBoardName = $state("");
let editBoardName = $state("");
let newBoardVisibility = $state<"team" | "personal">("team");
let editBoardVisibility = $state<"team" | "personal">("team");
let targetColumnId = $state<string | null>(null);
let cardModalMode = $state<"edit" | "create">("edit");
let realtimeChannel = $state<RealtimeChannel | null>(null);
async function loadBoard(boardId: string) {
selectedBoard = await fetchBoardWithColumns(supabase, boardId);
}
// Realtime subscription with proper cleanup
$effect(() => {
const board = selectedBoard;
if (!board) return;
// Subscribe to realtime changes for this board
const channel = subscribeToBoard(
supabase,
board.id,
() => {
// Column changed - reload board data
loadBoard(board.id);
},
() => {
// Card changed - reload board data
loadBoard(board.id);
},
);
realtimeChannel = channel;
// Cleanup function - unsubscribe when board changes or component unmounts
return () => {
if (channel) {
supabase.removeChannel(channel);
}
};
});
// Additional cleanup on component destroy
onDestroy(() => {
if (realtimeChannel) {
supabase.removeChannel(realtimeChannel);
}
});
async function handleCreateBoard() {
if (!newBoardName.trim()) return;
@ -58,8 +105,6 @@
let editingBoardId = $state<string | null>(null);
let showAddColumnModal = $state(false);
let newColumnName = $state("");
let sidebarCollapsed = $state(false);
function openEditBoardModal(board: KanbanBoardType) {
editingBoardId = board.id;
editBoardName = board.name;
@ -254,127 +299,43 @@
>
</svelte:head>
<div class="flex h-full">
<aside
class="{sidebarCollapsed
? 'w-12'
: 'w-64'} border-r border-light/10 flex flex-col transition-all duration-200"
>
<div
class="p-2 border-b border-light/10 flex items-center {sidebarCollapsed
? 'justify-center'
: 'justify-between gap-2'}"
<div class="flex flex-col h-full p-4 lg:p-5 gap-4">
<!-- Header -->
<header class="flex items-center gap-2 p-1">
<Avatar name="Kanban" size="md" />
<h1 class="flex-1 font-heading text-h1 text-white">Kanban</h1>
<Button size="md" onclick={() => (showCreateBoardModal = true)}
>+ New</Button
>
{#if !sidebarCollapsed}
<h2 class="font-semibold text-light px-2">Boards</h2>
<Button size="sm" onclick={() => (showCreateBoardModal = true)}>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<IconButton
title="More options"
onclick={() => selectedBoard && openEditBoardModal(selectedBoard)}
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</Button>
{/if}
<button
class="p-1.5 rounded-lg hover:bg-light/10 text-light/50 hover:text-light transition-colors"
onclick={() => (sidebarCollapsed = !sidebarCollapsed)}
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
<svg
class="w-4 h-4 transition-transform {sidebarCollapsed
? 'rotate-180'
: ''}"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="m11 17-5-5 5-5M17 17l-5-5 5-5" />
</svg>
</button>
</div>
<Icon name="more_horiz" size={24} />
</IconButton>
</header>
<div class="flex-1 overflow-y-auto p-2 space-y-1">
{#if boards.length === 0}
<div class="text-center text-light/40 py-8 text-sm">
<p>No boards yet</p>
</div>
{:else}
<!-- Board selector (compact) -->
{#if boards.length > 1}
<div class="flex gap-2 overflow-x-auto pb-2">
{#each boards as board}
<div
class="group flex items-center gap-1 px-3 py-2 rounded-lg text-sm transition-colors cursor-pointer {selectedBoard?.id ===
<button
type="button"
class="px-4 py-2 rounded-[32px] text-sm font-body whitespace-nowrap transition-colors {selectedBoard?.id ===
board.id
? 'bg-primary text-white'
: 'text-light/70 hover:bg-light/5'}"
? 'bg-primary text-night'
: 'bg-dark text-light hover:bg-dark/80'}"
onclick={() => loadBoard(board.id)}
role="button"
tabindex="0"
>
<span class="flex-1 truncate">{board.name}</span>
<div
class="opacity-0 group-hover:opacity-100 flex items-center gap-0.5 transition-opacity"
>
<button
class="p-1 rounded hover:bg-light/20"
onclick={(e) => {
e.stopPropagation();
openEditBoardModal(board);
}}
title="Rename"
>
<svg
class="w-3.5 h-3.5"
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>
{board.name}
</button>
<button
class="p-1 rounded hover:bg-error/20 hover:text-error"
onclick={(e) => handleDeleteBoard(e, board)}
title="Delete"
>
<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>
</div>
</div>
{/each}
{/if}
</div>
</aside>
{/if}
<main class="flex-1 overflow-hidden p-6">
<!-- Kanban Board -->
<div class="flex-1 overflow-hidden">
{#if selectedBoard}
<header class="mb-6">
<h1 class="text-2xl font-bold text-light">
{selectedBoard.name}
</h1>
</header>
<KanbanBoard
columns={selectedBoard.columns}
onCardClick={handleCardClick}
@ -384,25 +345,30 @@
onDeleteCard={handleCardDelete}
onDeleteColumn={handleDeleteColumn}
/>
{:else}
{:else if boards.length === 0}
<div class="h-full flex items-center justify-center text-light/40">
<div class="text-center">
<svg
class="w-16 h-16 mx-auto mb-4 opacity-50"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
<span
class="material-symbols-rounded text-light/30 mb-4 block"
style="font-size: 64px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48;"
>
<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>
<p>Select a board or create a new one</p>
view_kanban
</span>
<p class="mb-4">Kanban boards are now managed in Files</p>
<Button
onclick={() =>
(window.location.href = `/${data.org.slug}/documents`)}
>
Go to Files
</Button>
</div>
</div>
{:else}
<div class="h-full flex items-center justify-center text-light/40">
<p>Select a board above</p>
</div>
{/if}
</main>
</div>
</div>
<Modal
@ -418,7 +384,7 @@
/>
<div class="flex justify-end gap-2">
<Button
variant="ghost"
variant="tertiary"
onclick={() => (showCreateBoardModal = false)}>Cancel</Button
>
<Button onclick={handleCreateBoard} disabled={!newBoardName.trim()}
@ -440,8 +406,9 @@
placeholder="Board name"
/>
<div class="flex justify-end gap-2">
<Button variant="ghost" onclick={() => (showEditBoardModal = false)}
>Cancel</Button
<Button
variant="tertiary"
onclick={() => (showEditBoardModal = false)}>Cancel</Button
>
<Button onclick={handleEditBoard} disabled={!editBoardName.trim()}
>Save</Button
@ -462,8 +429,9 @@
placeholder="e.g. To Do, In Progress, Done"
/>
<div class="flex justify-end gap-2">
<Button variant="ghost" onclick={() => (showAddColumnModal = false)}
>Cancel</Button
<Button
variant="tertiary"
onclick={() => (showAddColumnModal = false)}>Cancel</Button
>
<Button
onclick={handleCreateColumn}
@ -486,5 +454,6 @@
mode={cardModalMode}
columnId={targetColumnId ?? undefined}
userId={data.user?.id}
orgId={data.org.id}
onCreate={handleCardCreated}
/>

@ -1,18 +1,23 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('page.settings');
export const load: PageServerLoad = async ({ parent, locals }) => {
const { org, userRole } = await parent();
const { org, userRole } = await parent() as { org: { id: string; slug: string }; userRole: string };
// Only admins and owners can access settings
if (userRole !== 'owner' && userRole !== 'admin') {
redirect(303, `/${(org as any).slug}`);
redirect(303, `/${org.slug}`);
}
const orgId = (org as any).id;
const orgId = org.id;
// Fetch all settings data in parallel
const [membersResult, rolesResult, invitesResult, calendarResult] = await Promise.all([
// Get org members with profiles
const { data: members } = await locals.supabase
locals.supabase
.from('org_members')
.select(`
id,
@ -27,35 +32,33 @@ export const load: PageServerLoad = async ({ parent, locals }) => {
avatar_url
)
`)
.eq('org_id', orgId);
.eq('org_id', orgId),
// Get org roles
const { data: roles } = await locals.supabase
locals.supabase
.from('org_roles')
.select('*')
.eq('org_id', orgId)
.order('position');
.order('position'),
// Get pending invites
const { data: invites } = await locals.supabase
locals.supabase
.from('org_invites')
.select('*')
.eq('org_id', orgId)
.is('accepted_at', null)
.gt('expires_at', new Date().toISOString());
.gt('expires_at', new Date().toISOString()),
// Get org Google Calendar connection
const { data: orgCalendar } = await locals.supabase
locals.supabase
.from('org_google_calendars')
.select('*')
.eq('org_id', orgId)
.single();
.single()
]);
return {
members: members ?? [],
roles: roles ?? [],
invites: invites ?? [],
orgCalendar,
members: membersResult.data ?? [],
roles: rolesResult.data ?? [],
invites: invitesResult.data ?? [],
orgCalendar: calendarResult.data,
userRole
};
};

@ -2,12 +2,22 @@
import { getContext, onMount } from "svelte";
import { page } from "$app/stores";
import { invalidateAll } from "$app/navigation";
import { Button, Modal, Card, Input } from "$lib/components/ui";
import {
Button,
Modal,
Card,
Input,
Select,
Icon,
Avatar,
IconButton,
} from "$lib/components/ui";
import { SettingsGeneral } from "$lib/components/settings";
import {
extractCalendarId,
getCalendarSubscribeUrl,
} from "$lib/api/google-calendar";
import { theme, PRESET_COLORS, type ThemeMode } from "$lib/stores/theme";
import { toasts } from "$lib/stores/toast.svelte";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
@ -18,18 +28,20 @@
calendar_name: string | null;
}
interface ProfileData {
id: string;
email: string;
full_name: string | null;
avatar_url: string | null;
}
interface Member {
id: string;
user_id: string;
role: string;
role_id: string | null;
created_at: string;
profiles: {
id: string;
email: string;
full_name: string | null;
avatar_url: string | null;
};
profiles: ProfileData | ProfileData[] | null;
}
interface OrgRole {
@ -55,7 +67,12 @@
interface Props {
data: {
org: { id: string; name: string; slug: string };
org: {
id: string;
name: string;
slug: string;
avatar_url?: string | null;
};
user: { id: string; email?: string } | null;
userRole: string;
members: Member[];
@ -70,14 +87,16 @@
const supabase = getContext<SupabaseClient<Database>>("supabase");
// Active tab
let activeTab = $state<
"general" | "members" | "roles" | "integrations" | "appearance"
>("general");
let activeTab = $state<"general" | "members" | "roles" | "integrations">(
"general",
);
// General settings state
let orgName = $state(data.org.name);
let orgSlug = $state(data.org.slug);
let isSavingGeneral = $state(false);
const tabs: { id: typeof activeTab; label: string }[] = [
{ id: "general", label: "General" },
{ id: "members", label: "Members" },
{ id: "roles", label: "Roles" },
{ id: "integrations", label: "Integrations" },
];
// Members state
let members = $state<Member[]>(data.members as Member[]);
@ -176,18 +195,45 @@
}
});
// General settings functions
async function saveGeneralSettings() {
isSavingGeneral = true;
async function deleteOrganization() {
if (!isOwner) return;
const confirmText = prompt(
`Type "${data.org.name}" to confirm deletion:`,
);
if (confirmText !== data.org.name) return;
const { error } = await supabase
.from("organizations")
.update({ name: orgName, slug: orgSlug })
.delete()
.eq("id", data.org.id);
if (error) {
toasts.error("Failed to delete organization.");
return;
}
window.location.href = "/";
}
if (!error && orgSlug !== data.org.slug) {
window.location.href = `/${orgSlug}/settings`;
async function leaveOrganization() {
if (isOwner) {
toasts.error(
"Owners cannot leave. Transfer ownership first or delete the organization.",
);
return;
}
isSavingGeneral = false;
if (!confirm(`Are you sure you want to leave ${data.org.name}?`))
return;
const { error } = await supabase
.from("org_members")
.delete()
.eq("org_id", data.org.id)
.eq("user_id", data.user!.id);
if (error) {
toasts.error("Failed to leave organization.");
return;
}
window.location.href = "/";
}
// Member functions
@ -219,13 +265,12 @@
.single();
if (!error && invite) {
// Remove old invite from UI if exists
invites = invites.filter((i) => i.email !== email);
invites = [...invites, invite as Invite];
inviteEmail = "";
showInviteModal = false;
} else if (error) {
alert("Failed to send invite: " + error.message);
toasts.error("Failed to send invite: " + error.message);
}
isSendingInvite = false;
}
@ -243,11 +288,15 @@
async function updateMemberRole() {
if (!selectedMember) return;
await supabase
const { error } = await supabase
.from("org_members")
.update({ role: selectedMemberRole })
.eq("id", selectedMember.id);
if (error) {
toasts.error("Failed to update role.");
return;
}
members = members.map((m) =>
m.id === selectedMember!.id
? { ...m, role: selectedMemberRole }
@ -258,14 +307,23 @@
async function removeMember() {
if (!selectedMember) return;
const rp = selectedMember.profiles;
const prof = Array.isArray(rp) ? rp[0] : rp;
if (
!confirm(
`Remove ${selectedMember.profiles.full_name || selectedMember.profiles.email} from the organization?`,
`Remove ${prof?.full_name || prof?.email || "this member"} from the organization?`,
)
)
return;
await supabase.from("org_members").delete().eq("id", selectedMember.id);
const { error } = await supabase
.from("org_members")
.delete()
.eq("id", selectedMember.id);
if (error) {
toasts.error("Failed to remove member.");
return;
}
members = members.filter((m) => m.id !== selectedMember!.id);
showMemberModal = false;
}
@ -348,7 +406,14 @@
)
return;
await supabase.from("org_roles").delete().eq("id", role.id);
const { error } = await supabase
.from("org_roles")
.delete()
.eq("id", role.id);
if (error) {
toasts.error("Failed to delete role.");
return;
}
roles = roles.filter((r) => r.id !== role.id);
}
@ -417,43 +482,15 @@
async function disconnectOrgCalendar() {
if (!confirm("Disconnect Google Calendar?")) return;
await supabase
const { error } = await supabase
.from("org_google_calendars")
.delete()
.eq("org_id", data.org.id);
orgCalendar = null;
}
async function deleteOrganization() {
if (!isOwner) return;
const confirmText = prompt(
`Type "${data.org.name}" to confirm deletion:`,
);
if (confirmText !== data.org.name) return;
await supabase.from("organizations").delete().eq("id", data.org.id);
window.location.href = "/";
}
async function leaveOrganization() {
if (isOwner) {
alert(
"Owners cannot leave. Transfer ownership first or delete the organization.",
);
return;
}
if (!confirm(`Are you sure you want to leave ${data.org.name}?`))
if (error) {
toasts.error("Failed to disconnect calendar.");
return;
const { error } = await supabase
.from("org_members")
.delete()
.eq("org_id", data.org.id)
.eq("user_id", data.user?.id);
if (!error) {
window.location.href = "/";
}
orgCalendar = null;
}
</script>
@ -461,148 +498,40 @@
<title>Settings - {data.org.name} | Root</title>
</svelte:head>
<div class="p-6 h-full overflow-auto">
<header class="mb-6">
<h1 class="text-2xl font-bold text-light">Settings</h1>
<p class="text-light/50 mt-1">Manage {data.org.name}</p>
<div class="flex flex-col h-full p-4 lg:p-5 gap-4 overflow-auto">
<!-- Header -->
<div class="flex flex-col gap-4">
<header class="flex flex-wrap items-center gap-2 p-1 rounded-[32px]">
<Avatar name="Settings" size="md" />
<h1 class="flex-1 font-heading text-h1 text-white">Settings</h1>
<IconButton title="More options">
<Icon name="more_horiz" size={24} />
</IconButton>
</header>
<!-- Tabs -->
<div class="flex gap-1 mb-6 border-b border-light/10">
<button
class="px-4 py-2 text-sm font-medium transition-colors {activeTab ===
'general'
? 'text-primary border-b-2 border-primary'
: 'text-light/50 hover:text-light'}"
onclick={() => (activeTab = "general")}>General</button
>
<button
class="px-4 py-2 text-sm font-medium transition-colors {activeTab ===
'members'
? 'text-primary border-b-2 border-primary'
: 'text-light/50 hover:text-light'}"
onclick={() => (activeTab = "members")}>Members</button
>
<button
class="px-4 py-2 text-sm font-medium transition-colors {activeTab ===
'roles'
? 'text-primary border-b-2 border-primary'
: 'text-light/50 hover:text-light'}"
onclick={() => (activeTab = "roles")}>Roles</button
>
<button
class="px-4 py-2 text-sm font-medium transition-colors {activeTab ===
'integrations'
? 'text-primary border-b-2 border-primary'
: 'text-light/50 hover:text-light'}"
onclick={() => (activeTab = "integrations")}>Integrations</button
>
<button
class="px-4 py-2 text-sm font-medium transition-colors {activeTab ===
'appearance'
? 'text-primary border-b-2 border-primary'
: 'text-light/50 hover:text-light'}"
onclick={() => (activeTab = "appearance")}>Appearance</button
<!-- Pill Tab Navigation -->
<div class="flex flex-wrap gap-4">
{#each tabs as tab}
<Button
variant={activeTab === tab.id ? "primary" : "secondary"}
size="md"
onclick={() => (activeTab = tab.id)}
>
{tab.label}
</Button>
{/each}
</div>
</div>
<!-- General Tab -->
{#if activeTab === "general"}
<div class="space-y-6 max-w-2xl">
<Card>
<div class="p-6">
<h2 class="text-lg font-semibold text-light mb-4">
Organization Details
</h2>
<div class="space-y-4">
<div>
<label
for="org-name"
class="block text-sm font-medium text-light mb-1"
>Name</label
>
<input
id="org-name"
type="text"
bind:value={orgName}
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-light focus:outline-none focus:border-primary"
<SettingsGeneral
{supabase}
org={data.org}
{isOwner}
onLeave={leaveOrganization}
onDelete={deleteOrganization}
/>
</div>
<div>
<label
for="org-slug"
class="block text-sm font-medium text-light mb-1"
>URL Slug</label
>
<div class="flex items-center gap-2">
<span class="text-light/40 text-sm"
>yoursite.com/</span
>
<input
id="org-slug"
type="text"
bind:value={orgSlug}
class="flex-1 px-3 py-2 bg-dark border border-light/20 rounded-lg text-light font-mono text-sm focus:outline-none focus:border-primary"
/>
</div>
<p class="text-xs text-light/40 mt-1">
Changing the slug will update all URLs for this
organization.
</p>
</div>
<div class="pt-2">
<Button
onclick={saveGeneralSettings}
loading={isSavingGeneral}>Save Changes</Button
>
</div>
</div>
</div>
</Card>
{#if !isOwner}
<Card>
<div class="p-6 border-l-4 border-warning">
<h2 class="text-lg font-semibold text-warning">
Leave Organization
</h2>
<p class="text-sm text-light/50 mt-1">
Leave this organization. You will need to be
re-invited to rejoin.
</p>
<div class="mt-4">
<Button
variant="secondary"
onclick={leaveOrganization}
>Leave {data.org.name}</Button
>
</div>
</div>
</Card>
{/if}
{#if isOwner}
<Card>
<div class="p-6 border-l-4 border-error">
<h2 class="text-lg font-semibold text-error">
Danger Zone
</h2>
<p class="text-sm text-light/50 mt-1">
Permanently delete this organization and all its
data.
</p>
<div class="mt-4">
<Button
variant="danger"
onclick={deleteOrganization}
>Delete Organization</Button
>
</div>
</div>
</Card>
{/if}
</div>
{/if}
<!-- Members Tab -->
@ -654,18 +583,20 @@
</p>
</div>
<div class="flex items-center gap-2">
<button
class="text-xs text-light/50 hover:text-light"
<Button
variant="tertiary"
size="sm"
onclick={() =>
navigator.clipboard.writeText(
`${window.location.origin}/invite/${invite.token}`,
)}>Copy Link</button
)}>Copy Link</Button
>
<button
class="text-xs text-error hover:text-error/80"
<Button
variant="danger"
size="sm"
onclick={() =>
cancelInvite(invite.id)}
>Cancel</button
>Cancel</Button
>
</div>
</div>
@ -679,7 +610,10 @@
<Card>
<div class="divide-y divide-light/10">
{#each members as member}
{@const profile = member.profiles}
{@const rawProfile = member.profiles}
{@const profile = Array.isArray(rawProfile)
? rawProfile[0]
: rawProfile}
<div
class="flex items-center justify-between p-4 hover:bg-light/5 transition-colors"
>
@ -717,10 +651,11 @@
)?.color ?? '#6366f1'}">{member.role}</span
>
{#if member.user_id !== data.user?.id && member.role !== "owner"}
<button
class="text-sm text-light/50 hover:text-light"
<Button
variant="tertiary"
size="sm"
onclick={() => openMemberModal(member)}
>Edit</button
>Edit</Button
>
{/if}
</div>
@ -741,16 +676,7 @@
Create custom roles with specific permissions.
</p>
</div>
<Button onclick={() => openRoleModal()}>
<svg
class="w-4 h-4 mr-2"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 5v14M5 12h14" />
</svg>
<Button onclick={() => openRoleModal()} icon="add">
Create Role
</Button>
</div>
@ -783,17 +709,19 @@
</div>
<div class="flex items-center gap-2">
{#if !role.is_system || role.name !== "Owner"}
<button
class="text-sm text-light/50 hover:text-light"
<Button
variant="tertiary"
size="sm"
onclick={() => openRoleModal(role)}
>Edit</button
>Edit</Button
>
{/if}
{#if !role.is_system}
<button
class="text-sm text-error/70 hover:text-error"
<Button
variant="danger"
size="sm"
onclick={() => deleteRole(role)}
>Delete</button
>Delete</Button
>
{/if}
</div>
@ -977,198 +905,6 @@
</Card>
</div>
{/if}
<!-- Appearance Tab -->
{#if activeTab === "appearance"}
<div class="space-y-6 max-w-2xl">
<Card>
<div class="p-6">
<h2 class="text-lg font-semibold text-light mb-4">Theme</h2>
<p class="text-sm text-light/50 mb-6">
Customize the look and feel of your workspace.
</p>
<!-- Mode Selector -->
<div class="mb-6">
<label class="block text-sm font-medium text-light mb-3"
>Mode</label
>
<div class="flex gap-2">
{#each ["dark", "light", "system"] as mode}
<button
class="flex-1 px-4 py-3 rounded-lg border transition-all {$theme.mode ===
mode
? 'border-primary bg-primary/10 text-primary'
: 'border-light/20 text-light/60 hover:border-light/40'}"
onclick={() =>
theme.setMode(mode as ThemeMode)}
>
<div
class="flex flex-col items-center gap-1"
>
{#if mode === "dark"}
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"
/>
</svg>
{:else if mode === "light"}
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="5" />
<line
x1="12"
y1="1"
x2="12"
y2="3"
/>
<line
x1="12"
y1="21"
x2="12"
y2="23"
/>
<line
x1="4.22"
y1="4.22"
x2="5.64"
y2="5.64"
/>
<line
x1="18.36"
y1="18.36"
x2="19.78"
y2="19.78"
/>
<line
x1="1"
y1="12"
x2="3"
y2="12"
/>
<line
x1="21"
y1="12"
x2="23"
y2="12"
/>
<line
x1="4.22"
y1="19.78"
x2="5.64"
y2="18.36"
/>
<line
x1="18.36"
y1="5.64"
x2="19.78"
y2="4.22"
/>
</svg>
{:else}
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect
x="2"
y="3"
width="20"
height="14"
rx="2"
/>
<line
x1="8"
y1="21"
x2="16"
y2="21"
/>
<line
x1="12"
y1="17"
x2="12"
y2="21"
/>
</svg>
{/if}
<span class="text-xs capitalize"
>{mode}</span
>
</div>
</button>
{/each}
</div>
</div>
<!-- Accent Color -->
<div>
<label class="block text-sm font-medium text-light mb-3"
>Accent Color</label
>
<div class="grid grid-cols-4 gap-3">
{#each PRESET_COLORS as color}
<button
class="group relative h-12 rounded-lg transition-all {$theme.primaryColor ===
color.primary
? 'ring-2 ring-offset-2 ring-offset-dark ring-white'
: 'hover:scale-105'}"
style="background-color: {color.primary}"
onclick={() =>
theme.setPrimaryColor(color.primary)}
title={color.name}
>
{#if $theme.primaryColor === color.primary}
<svg
class="absolute inset-0 m-auto w-5 h-5 text-white"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
>
<polyline points="20,6 9,17 4,12" />
</svg>
{/if}
</button>
{/each}
</div>
<p class="text-xs text-light/40 mt-3">
Selected: {PRESET_COLORS.find(
(c) => c.primary === $theme.primaryColor,
)?.name || "Custom"}
</p>
</div>
</div>
</Card>
<Card>
<div class="p-6">
<h2 class="text-lg font-semibold text-light mb-2">
Reset Theme
</h2>
<p class="text-sm text-light/50 mb-4">
Reset to the default theme settings.
</p>
<Button variant="secondary" onclick={() => theme.reset()}>
Reset to Default
</Button>
</div>
</Card>
</div>
{/if}
</div>
<!-- Invite Member Modal -->
@ -1178,44 +914,34 @@
title="Invite Member"
>
<div class="space-y-4">
<div>
<label
for="invite-email"
class="block text-sm font-medium text-light mb-1"
>Email address</label
>
<input
id="invite-email"
<Input
type="email"
label="Email address"
bind:value={inviteEmail}
placeholder="colleague@example.com"
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-light focus:outline-none focus:border-primary"
/>
</div>
<div>
<label
for="invite-role"
class="block text-sm font-medium text-light mb-1">Role</label
>
<select
id="invite-role"
<Select
label="Role"
bind:value={inviteRole}
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-light focus:outline-none focus:border-primary"
>
<option value="viewer">Viewer - Can view content</option>
<option value="commenter"
>Commenter - Can view and comment</option
>
<option value="editor"
>Editor - Can create and edit content</option
>
<option value="admin"
>Admin - Can manage members and settings</option
>
</select>
</div>
placeholder=""
options={[
{ value: "viewer", label: "Viewer - Can view content" },
{
value: "commenter",
label: "Commenter - Can view and comment",
},
{
value: "editor",
label: "Editor - Can create and edit content",
},
{
value: "admin",
label: "Admin - Can manage members and settings",
},
]}
/>
<div class="flex justify-end gap-2 pt-2">
<Button variant="ghost" onclick={() => (showInviteModal = false)}
<Button variant="tertiary" onclick={() => (showInviteModal = false)}
>Cancel</Button
>
<Button
@ -1234,48 +960,44 @@
title="Edit Member"
>
{#if selectedMember}
{@const rawP = selectedMember.profiles}
{@const memberProfile = Array.isArray(rawP) ? rawP[0] : rawP}
<div class="space-y-4">
<div class="flex items-center gap-3 p-3 bg-light/5 rounded-lg">
<div
class="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium"
>
{(selectedMember.profiles.full_name ||
selectedMember.profiles.email ||
{(memberProfile?.full_name ||
memberProfile?.email ||
"?")[0].toUpperCase()}
</div>
<div>
<p class="text-light font-medium">
{selectedMember.profiles.full_name || "No name"}
{memberProfile?.full_name || "No name"}
</p>
<p class="text-sm text-light/50">
{selectedMember.profiles.email}
{memberProfile?.email || "No email"}
</p>
</div>
</div>
<div>
<label
for="member-role"
class="block text-sm font-medium text-light mb-1"
>Role</label
>
<select
id="member-role"
<Select
label="Role"
bind:value={selectedMemberRole}
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-light focus:outline-none focus:border-primary"
>
<option value="viewer">Viewer</option>
<option value="commenter">Commenter</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
</div>
placeholder=""
options={[
{ value: "viewer", label: "Viewer" },
{ value: "commenter", label: "Commenter" },
{ value: "editor", label: "Editor" },
{ value: "admin", label: "Admin" },
]}
/>
<div class="flex items-center justify-between pt-2">
<Button variant="danger" onclick={removeMember}
>Remove from Org</Button
>
<div class="flex gap-2">
<Button
variant="ghost"
variant="tertiary"
onclick={() => (showMemberModal = false)}>Cancel</Button
>
<Button onclick={updateMemberRole}>Save</Button>
@ -1292,20 +1014,12 @@
title={editingRole ? "Edit Role" : "Create Role"}
>
<div class="space-y-4">
<div>
<label
for="role-name"
class="block text-sm font-medium text-light mb-1">Name</label
>
<input
id="role-name"
type="text"
<Input
label="Name"
bind:value={newRoleName}
placeholder="e.g., Moderator"
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-light focus:outline-none focus:border-primary"
disabled={editingRole?.is_system}
/>
</div>
<div>
<label class="block text-sm font-medium text-light mb-2"
>Color</label
@ -1357,7 +1071,7 @@
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<Button variant="ghost" onclick={() => (showRoleModal = false)}
<Button variant="tertiary" onclick={() => (showRoleModal = false)}
>Cancel</Button
>
<Button
@ -1412,8 +1126,9 @@
{/if}
<div class="flex justify-end gap-2 pt-2">
<Button variant="ghost" onclick={() => (showConnectModal = false)}
>Cancel</Button
<Button
variant="tertiary"
onclick={() => (showConnectModal = false)}>Cancel</Button
>
<Button
onclick={handleSaveOrgCalendar}

@ -2,8 +2,10 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { GOOGLE_API_KEY } from '$env/static/private';
import { fetchPublicCalendarEvents } from '$lib/api/google-calendar';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api:google-calendar');
// Fetch events from a public Google Calendar
export const GET: RequestHandler = async ({ url, locals }) => {
const orgId = url.searchParams.get('org_id');
@ -11,6 +13,23 @@ export const GET: RequestHandler = async ({ url, locals }) => {
return json({ error: 'org_id required' }, { status: 400 });
}
// Auth check — must be logged in and a member of this org
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 });
}
if (!GOOGLE_API_KEY) {
return json({ error: 'Google API key not configured' }, { status: 500 });
}
@ -24,7 +43,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
.single();
if (dbError) {
console.error('DB error fetching calendar:', dbError);
log.error('DB error fetching calendar', { data: { orgId }, error: dbError });
return json({ error: 'No calendar connected', events: [] }, { status: 404 });
}
@ -32,7 +51,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
return json({ error: 'No calendar connected', events: [] }, { status: 404 });
}
console.log('Fetching events for calendar:', (orgCal as any).calendar_id);
log.debug('Fetching events for calendar', { data: { calendarId: orgCal.calendar_id } });
// Fetch events for the next 3 months
const now = new Date();
@ -40,21 +59,21 @@ export const GET: RequestHandler = async ({ url, locals }) => {
const timeMax = new Date(now.getFullYear(), now.getMonth() + 3, 0);
const events = await fetchPublicCalendarEvents(
(orgCal as any).calendar_id,
orgCal.calendar_id,
GOOGLE_API_KEY,
timeMin,
timeMax
);
console.log('Fetched', events.length, 'events');
log.debug('Fetched events', { data: { count: events.length } });
return json({
events,
calendar_id: (orgCal as any).calendar_id,
calendar_name: (orgCal as any).calendar_name
calendar_id: orgCal.calendar_id,
calendar_name: orgCal.calendar_name
});
} catch (err) {
console.error('Failed to fetch calendar events:', err);
log.error('Failed to fetch calendar events', { data: { orgId }, error: err });
return json({ error: 'Failed to fetch events. Make sure the calendar is public.', events: [] }, { status: 500 });
}
};

@ -1,9 +1,14 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
function safeRedirect(target: string): string {
if (target.startsWith('/') && !target.startsWith('//')) return target;
return '/';
}
export const GET: RequestHandler = async ({ url, locals }) => {
const code = url.searchParams.get('code');
const next = url.searchParams.get('next') ?? url.searchParams.get('redirect') ?? '/';
const next = safeRedirect(url.searchParams.get('next') ?? url.searchParams.get('redirect') ?? '/');
if (code) {
const { error } = await locals.supabase.auth.exchangeCodeForSession(code);

@ -21,10 +21,8 @@ export const load: PageServerLoad = async ({ params, locals }) => {
};
}
const inv = invite as any;
// Check if invite is expired
if (new Date(inv.expires_at) < new Date()) {
if (invite.expires_at && new Date(invite.expires_at) < new Date()) {
return {
error: 'This invite has expired',
token
@ -36,10 +34,10 @@ export const load: PageServerLoad = async ({ params, locals }) => {
return {
invite: {
id: inv.id,
email: inv.email,
role: inv.role,
org: inv.organizations
id: invite.id,
email: invite.email,
role: invite.role,
org: (invite as any).organizations // join not typed
},
user,
token

@ -46,6 +46,7 @@
org_id: data.invite.org.id,
user_id: data.user.id,
role: data.invite.role,
joined_at: new Date().toISOString(),
});
if (memberError) {
@ -84,7 +85,7 @@
function goToSignup() {
const returnUrl = `/invite/${data.token}`;
goto(
`/signup?redirect=${encodeURIComponent(returnUrl)}&email=${encodeURIComponent(data.invite?.email || "")}`,
`/login?tab=signup&redirect=${encodeURIComponent(returnUrl)}&email=${encodeURIComponent(data.invite?.email || "")}`,
);
}
</script>
@ -166,7 +167,7 @@
</div>
<p class="text-light/40 text-xs mt-3">
Wrong account? <a
href="/logout"
href="/auth/logout"
class="text-primary hover:underline">Sign out</a
>
</p>
@ -177,7 +178,7 @@
</p>
<div class="flex flex-col gap-2">
<Button onclick={goToLogin}>Sign In</Button>
<Button onclick={goToSignup} variant="ghost"
<Button onclick={goToSignup} variant="tertiary"
>Create Account</Button
>
</div>

@ -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 */
/* Base layer — element defaults via Tailwind utilities */
@layer base {
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;
@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; }
}

@ -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({
const { data: signUpData, error: authError } =
await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`,
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;
}
}
} catch (e: unknown) {
error = e instanceof Error ? e.message : "An error occurred";
} finally {
@ -79,6 +98,37 @@
</div>
<Card variant="elevated" padding="lg">
{#if signupSuccess}
<div class="text-center py-4">
<div
class="w-16 h-16 mx-auto mb-4 rounded-full bg-success/20 flex items-center justify-center"
>
<span
class="material-symbols-rounded text-success"
style="font-size: 32px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 32;"
>
mark_email_read
</span>
</div>
<h2 class="text-xl font-semibold text-light mb-2">
Check your email
</h2>
<p class="text-light/60 text-sm mb-4">
We've sent a confirmation link to <strong
class="text-light">{email}</strong
>. Click the link to activate your account.
</p>
<Button
variant="tertiary"
onclick={() => {
signupSuccess = false;
mode = "login";
}}
>
Back to Login
</Button>
</div>
{:else}
<h2 class="text-xl font-semibold text-light mb-6">
{mode === "login" ? "Welcome back" : "Create your account"}
</h2>
@ -170,6 +220,7 @@
</button>
{/if}
</p>
{/if}
</Card>
</div>
</div>

@ -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();
});
});

@ -11,6 +11,12 @@
Spinner,
Toggle,
Toast,
Chip,
ListItem,
OrgHeader,
CalendarDay,
Logo,
ContentHeader,
} from "$lib/components/ui";
let inputValue = $state("");
@ -124,7 +130,7 @@
<div class="flex flex-wrap gap-3">
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="tertiary">Tertiary</Button>
<Button variant="danger">Danger</Button>
<Button variant="success">Success</Button>
</div>
@ -141,6 +147,18 @@
</div>
</div>
<div>
<h3 class="text-lg font-medium text-light/80 mb-3">
With Icons (Material Symbols)
</h3>
<div class="flex flex-wrap items-center gap-3">
<Button icon="add">Add Item</Button>
<Button variant="secondary" icon="edit">Edit</Button>
<Button variant="tertiary" icon="delete">Delete</Button>
<Button icon="send">Send</Button>
</div>
</div>
<div>
<h3 class="text-lg font-medium text-light/80 mb-3">
States
@ -157,7 +175,9 @@
Full Width
</h3>
<div class="max-w-sm">
<Button fullWidth>Full Width Button</Button>
<Button fullWidth icon="rocket_launch"
>Full Width Button</Button
>
</div>
</div>
</div>
@ -202,6 +222,8 @@
label="Password"
placeholder="••••••••"
/>
<Input placeholder="Message input with icon..." icon="add" />
<Input label="Search" placeholder="Search..." icon="search" />
</div>
</section>
@ -263,28 +285,22 @@
Sizes
</h3>
<div class="flex items-end gap-4">
<Avatar name="John Doe" size="xs" />
<Avatar name="John Doe" size="sm" />
<Avatar name="John Doe" size="md" />
<Avatar name="John Doe" size="lg" />
<Avatar name="John Doe" size="xl" />
<Avatar name="John Doe" size="2xl" />
</div>
</div>
<div>
<h3 class="text-lg font-medium text-light/80 mb-3">
With Status
With Status (placeholder)
</h3>
<div class="flex items-center gap-4">
<Avatar name="Online User" size="lg" status="online" />
<Avatar name="Away User" size="lg" status="away" />
<Avatar name="Busy User" size="lg" status="busy" />
<Avatar
name="Offline User"
size="lg"
status="offline"
/>
<Avatar name="Online User" size="lg" />
<Avatar name="Away User" size="lg" />
<Avatar name="Busy User" size="lg" />
<Avatar name="Offline User" size="lg" />
</div>
</div>
@ -303,6 +319,88 @@
</div>
</section>
<!-- Chips -->
<section class="space-y-4">
<h2
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
>
Chips
</h2>
<div class="space-y-6">
<div>
<h3 class="text-lg font-medium text-light/80 mb-3">
Variants
</h3>
<div class="flex flex-wrap gap-3">
<Chip variant="primary">Primary</Chip>
<Chip variant="success">Success</Chip>
<Chip variant="warning">Warning</Chip>
<Chip variant="error">Error</Chip>
<Chip variant="default">Default</Chip>
</div>
</div>
</div>
</section>
<!-- List Items -->
<section class="space-y-4">
<h2
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
>
List Items
</h2>
<div class="max-w-[240px] space-y-2">
<ListItem icon="info">Default Item</ListItem>
<ListItem icon="settings" variant="hover">Hover State</ListItem>
<ListItem icon="check_circle" variant="active"
>Active Item</ListItem
>
<ListItem icon="folder">Documents</ListItem>
<ListItem icon="dashboard">Dashboard</ListItem>
</div>
</section>
<!-- Org Header -->
<section class="space-y-4">
<h2
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
>
Organization Header
</h2>
<div class="max-w-[240px] space-y-4">
<OrgHeader name="Acme Corp" role="Admin" />
<OrgHeader name="Design Team" role="Editor" isHover />
<OrgHeader name="Small" size="sm" />
</div>
</section>
<!-- Calendar Day -->
<section class="space-y-4">
<h2
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
>
Calendar Day
</h2>
<div class="flex gap-1 max-w-[720px]">
<CalendarDay day="Mon" isHeader />
<CalendarDay day="Tue" isHeader />
<CalendarDay day="Wed" isHeader />
</div>
<div class="flex gap-1 max-w-[720px]">
<CalendarDay day="1" />
<CalendarDay day="2">
{#snippet events()}
<Chip>Meeting</Chip>
{/snippet}
</CalendarDay>
<CalendarDay day="3" isPast />
</div>
</section>
<!-- Badges -->
<section class="space-y-4">
<h2
@ -492,39 +590,133 @@
Typography
</h2>
<div class="space-y-4">
<h1 class="text-4xl font-bold text-light">
Heading 1 (4xl bold)
</h1>
<h2 class="text-3xl font-bold text-light">
Heading 2 (3xl bold)
</h2>
<h3 class="text-2xl font-semibold text-light">
Heading 3 (2xl semibold)
<div class="space-y-6">
<!-- Headings (Tilt Warp) -->
<div>
<h3 class="text-lg font-medium text-light/80 mb-3">
Headings &mdash; Tilt Warp
</h3>
<div class="space-y-3">
<div class="flex items-baseline gap-4">
<span
class="text-body-sm text-light/40 w-16 shrink-0"
>h1 · 32</span
>
<h1 class="text-light">Heading 1</h1>
</div>
<div class="flex items-baseline gap-4">
<span
class="text-body-sm text-light/40 w-16 shrink-0"
>h2 · 28</span
>
<h2 class="text-light">Heading 2</h2>
</div>
<div class="flex items-baseline gap-4">
<span
class="text-body-sm text-light/40 w-16 shrink-0"
>h3 · 24</span
>
<h3 class="text-light">Heading 3</h3>
</div>
<div class="flex items-baseline gap-4">
<span
class="text-body-sm text-light/40 w-16 shrink-0"
>h4 · 20</span
>
<h4 class="text-light">Heading 4</h4>
</div>
<div class="flex items-baseline gap-4">
<span
class="text-body-sm text-light/40 w-16 shrink-0"
>h5 · 16</span
>
<h5 class="text-light">Heading 5</h5>
</div>
<div class="flex items-baseline gap-4">
<span
class="text-body-sm text-light/40 w-16 shrink-0"
>h6 · 14</span
>
<h6 class="text-light">Heading 6</h6>
</div>
</div>
</div>
<!-- Button Text (Tilt Warp) -->
<div>
<h3 class="text-lg font-medium text-light/80 mb-3">
Button Text &mdash; Tilt Warp
</h3>
<h4 class="text-xl font-semibold text-light">
Heading 4 (xl semibold)
</h4>
<h5 class="text-lg font-medium text-light">
Heading 5 (lg medium)
</h5>
<h6 class="text-base font-medium text-light">
Heading 6 (base medium)
</h6>
<p class="text-base text-light/80">
Body text (base, 80% opacity) - Lorem ipsum dolor sit amet,
consectetur adipiscing elit. Sed do eiusmod tempor
incididunt ut labore et dolore magna aliqua.
<div class="space-y-3">
<div class="flex items-baseline gap-4">
<span
class="text-body-sm text-light/40 w-16 shrink-0"
>btn-lg · 20</span
>
<span class="font-heading text-btn-lg text-light"
>Button Large</span
>
</div>
<div class="flex items-baseline gap-4">
<span
class="text-body-sm text-light/40 w-16 shrink-0"
>btn-md · 16</span
>
<span class="font-heading text-btn-md text-light"
>Button Medium</span
>
</div>
<div class="flex items-baseline gap-4">
<span
class="text-body-sm text-light/40 w-16 shrink-0"
>btn-sm · 14</span
>
<span class="font-heading text-btn-sm text-light"
>Button Small</span
>
</div>
</div>
</div>
<!-- Body Text (Work Sans) -->
<div>
<h3 class="text-lg font-medium text-light/80 mb-3">
Body &mdash; Work Sans
</h3>
<div class="space-y-3">
<div class="flex items-baseline gap-4">
<span
class="text-body-sm text-light/40 w-16 shrink-0"
>p · 16</span
>
<p class="text-body text-light">
Body text — Lorem ipsum dolor sit amet,
consectetur adipiscing elit.
</p>
<p class="text-sm text-light/60">
Small text (sm, 60% opacity) - Used for secondary
information and hints.
</div>
<div class="flex items-baseline gap-4">
<span
class="text-body-sm text-light/40 w-16 shrink-0"
>p-md · 14</span
>
<p class="text-body-md text-light/80">
Body medium — Used for secondary information and
descriptions.
</p>
<p class="text-xs text-light/40">
Extra small text (xs, 40% opacity) - Used for metadata and
timestamps.
</div>
<div class="flex items-baseline gap-4">
<span
class="text-body-sm text-light/40 w-16 shrink-0"
>p-sm · 12</span
>
<p class="text-body-sm text-light/60">
Body small — Used for metadata, timestamps, and
hints.
</p>
</div>
</div>
</div>
</div>
</section>
<!-- Toasts -->
@ -558,6 +750,51 @@
</div>
</section>
<!-- Logo -->
<section class="space-y-4">
<h2
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
>
Logo
</h2>
<p class="text-light/60">
Brand logo component with size variants.
</p>
<div class="flex items-center gap-8 bg-night p-6 rounded-xl">
<div class="flex flex-col items-center gap-2">
<Logo size="sm" />
<span class="text-xs text-light/60">Small</span>
</div>
<div class="flex flex-col items-center gap-2">
<Logo size="md" />
<span class="text-xs text-light/60">Medium</span>
</div>
</div>
</section>
<!-- ContentHeader -->
<section class="space-y-4">
<h2
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
>
Content Header
</h2>
<p class="text-light/60">
Page header component with avatar, title, action button, and
more menu.
</p>
<div class="bg-night p-6 rounded-xl space-y-4">
<ContentHeader
title="Page Title"
actionLabel="+ New"
onAction={() => {}}
onMore={() => {}}
/>
<ContentHeader title="Without Action" onMore={() => {}} />
<ContentHeader title="Simple Header" />
</div>
</section>
<!-- Footer -->
<footer class="text-center py-8 border-t border-light/10">
<p class="text-light/40 text-sm">

@ -0,0 +1,66 @@
-- Kanban Labels System
-- Adds label/tag support to kanban cards for categorization and filtering
-- Labels table (org-scoped)
CREATE TABLE IF NOT EXISTS kanban_labels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#6366f1',
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(org_id, name)
);
-- Card-Label junction table (many-to-many)
CREATE TABLE IF NOT EXISTS card_labels (
card_id UUID NOT NULL REFERENCES kanban_cards(id) ON DELETE CASCADE,
label_id UUID NOT NULL REFERENCES kanban_labels(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (card_id, label_id)
);
-- Enable RLS
ALTER TABLE kanban_labels ENABLE ROW LEVEL SECURITY;
ALTER TABLE card_labels ENABLE ROW LEVEL SECURITY;
-- Labels accessible to org members
CREATE POLICY "Labels accessible to org members" ON kanban_labels
FOR ALL USING (
EXISTS (
SELECT 1 FROM org_members m
WHERE m.org_id = kanban_labels.org_id
AND m.user_id = auth.uid()
)
);
-- Card labels inherit card access
CREATE POLICY "Card labels inherit card access" ON card_labels
FOR ALL USING (
EXISTS (
SELECT 1 FROM kanban_cards c
JOIN kanban_columns col ON c.column_id = col.id
JOIN kanban_boards b ON col.board_id = b.id
JOIN org_members m ON b.org_id = m.org_id
WHERE c.id = card_labels.card_id
AND m.user_id = auth.uid()
)
);
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_kanban_labels_org ON kanban_labels(org_id);
CREATE INDEX IF NOT EXISTS idx_card_labels_card ON card_labels(card_id);
CREATE INDEX IF NOT EXISTS idx_card_labels_label ON card_labels(label_id);
-- Seed default labels for existing organizations
INSERT INTO kanban_labels (org_id, name, color)
SELECT DISTINCT o.id, label.name, label.color
FROM organizations o
CROSS JOIN (
VALUES
('Bug', '#ef4444'),
('Feature', '#22c55e'),
('Enhancement', '#3b82f6'),
('Documentation', '#a855f7'),
('Urgent', '#f97316')
) AS label(name, color)
ON CONFLICT (org_id, name) DO NOTHING;

@ -0,0 +1,101 @@
-- Migration: Document enhancements for file paths, positions, and kanban type
-- Adds path column for Google Drive-like unique paths
-- Adds position column for file ordering within folders
-- Extends type enum to include 'kanban' for kanban boards as files
-- Add path column (computed from parent hierarchy)
ALTER TABLE documents ADD COLUMN IF NOT EXISTS path TEXT;
-- Add position column for ordering files within a folder
ALTER TABLE documents ADD COLUMN IF NOT EXISTS position INTEGER DEFAULT 0;
-- Update the type constraint to allow 'kanban'
-- First drop the existing constraint if it exists
ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_type_check;
-- Add new constraint with kanban type
ALTER TABLE documents ADD CONSTRAINT documents_type_check
CHECK (type IN ('folder', 'document', 'kanban'));
-- Function to compute and update document path
CREATE OR REPLACE FUNCTION compute_document_path(doc_id UUID)
RETURNS TEXT AS $$
DECLARE
result TEXT := '';
current_doc RECORD;
path_parts TEXT[] := '{}';
BEGIN
-- Start with the document itself
SELECT * INTO current_doc FROM documents WHERE id = doc_id;
IF NOT FOUND THEN
RETURN NULL;
END IF;
-- Build path from document up to root
WHILE current_doc IS NOT NULL LOOP
path_parts := array_prepend(current_doc.name, path_parts);
IF current_doc.parent_id IS NULL THEN
EXIT;
END IF;
SELECT * INTO current_doc FROM documents WHERE id = current_doc.parent_id;
END LOOP;
-- Join with '/' separator
result := '/' || array_to_string(path_parts, '/');
RETURN result;
END;
$$ LANGUAGE plpgsql;
-- Function to update path on insert/update
CREATE OR REPLACE FUNCTION update_document_path()
RETURNS TRIGGER AS $$
BEGIN
NEW.path := compute_document_path(NEW.id);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger to auto-update path
DROP TRIGGER IF EXISTS documents_path_trigger ON documents;
CREATE TRIGGER documents_path_trigger
BEFORE INSERT OR UPDATE OF name, parent_id ON documents
FOR EACH ROW
EXECUTE FUNCTION update_document_path();
-- Function to get next position in folder
CREATE OR REPLACE FUNCTION get_next_document_position(folder_id UUID)
RETURNS INTEGER AS $$
DECLARE
max_pos INTEGER;
BEGIN
SELECT COALESCE(MAX(position), -1) + 1 INTO max_pos
FROM documents
WHERE parent_id IS NOT DISTINCT FROM folder_id;
RETURN max_pos;
END;
$$ LANGUAGE plpgsql;
-- Update existing documents to have computed paths
UPDATE documents SET path = compute_document_path(id) WHERE path IS NULL;
-- Update existing documents to have sequential positions within their folders
WITH numbered AS (
SELECT id, ROW_NUMBER() OVER (PARTITION BY parent_id ORDER BY created_at) - 1 AS new_pos
FROM documents
WHERE position IS NULL OR position = 0
)
UPDATE documents d
SET position = n.new_pos
FROM numbered n
WHERE d.id = n.id;
-- Create index on path for faster lookups
CREATE INDEX IF NOT EXISTS idx_documents_path ON documents(path);
-- Create index on parent_id and position for faster ordering
CREATE INDEX IF NOT EXISTS idx_documents_parent_position ON documents(parent_id, position);

@ -0,0 +1,45 @@
-- Migration: Move existing kanban boards to documents table
-- This creates document entries for each kanban_board with type 'kanban'
-- The content field stores reference to the board_id for backwards compatibility
-- Insert existing kanban boards as documents
INSERT INTO documents (id, org_id, parent_id, type, name, path, position, content, created_by, created_at, updated_at)
SELECT
kb.id,
kb.org_id,
NULL as parent_id,
'kanban' as type,
kb.name,
'/' || kb.name as path,
COALESCE((
SELECT COUNT(*) FROM documents d
WHERE d.org_id = kb.org_id AND d.parent_id IS NULL
), 0) as position,
jsonb_build_object(
'type', 'kanban',
'board_id', kb.id
) as content,
COALESCE(kb.created_by, (
SELECT user_id FROM org_members
WHERE org_id = kb.org_id
ORDER BY invited_at ASC
LIMIT 1
)) as created_by,
kb.created_at,
kb.created_at as updated_at
FROM kanban_boards kb
WHERE NOT EXISTS (
SELECT 1 FROM documents d
WHERE d.id = kb.id
);
-- Update any duplicate paths by appending board ID
UPDATE documents
SET path = path || '-' || SUBSTRING(id::text, 1, 8)
WHERE type = 'kanban'
AND id IN (
SELECT d1.id
FROM documents d1
JOIN documents d2 ON d1.path = d2.path AND d1.id != d2.id
WHERE d1.type = 'kanban'
);

@ -0,0 +1,41 @@
-- Document locks: track who is currently editing a document
-- Uses a heartbeat model: editors must refresh their lock periodically
-- Stale locks (no heartbeat for 60s) are considered expired
CREATE TABLE document_locks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
locked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_heartbeat TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(document_id)
);
-- Index for fast lookups
CREATE INDEX idx_document_locks_document ON document_locks(document_id);
CREATE INDEX idx_document_locks_heartbeat ON document_locks(last_heartbeat);
-- RLS
ALTER TABLE document_locks ENABLE ROW LEVEL SECURITY;
-- Anyone in the org can view locks (to see who's editing)
CREATE POLICY "Org members can view document locks" ON document_locks FOR SELECT
USING (EXISTS (
SELECT 1 FROM documents d
JOIN org_members om ON d.org_id = om.org_id
WHERE d.id = document_locks.document_id AND om.user_id = auth.uid()
));
-- Users can manage their own locks
CREATE POLICY "Users can insert their own locks" ON document_locks FOR INSERT
WITH CHECK (user_id = auth.uid());
CREATE POLICY "Users can update their own locks" ON document_locks FOR UPDATE
USING (user_id = auth.uid());
CREATE POLICY "Users can delete their own locks" ON document_locks FOR DELETE
USING (user_id = auth.uid());
-- Allow taking over expired locks (heartbeat older than 60 seconds)
CREATE POLICY "Anyone can delete expired locks" ON document_locks FOR DELETE
USING (last_heartbeat < now() - interval '60 seconds');

@ -0,0 +1,28 @@
-- Create storage bucket for avatars
INSERT INTO storage.buckets (id, name, public)
VALUES ('avatars', 'avatars', true)
ON CONFLICT (id) DO NOTHING;
-- Allow authenticated users to upload to org-avatars folder
CREATE POLICY "Authenticated users can upload org avatars"
ON storage.objects FOR INSERT
TO authenticated
WITH CHECK (bucket_id = 'avatars' AND (storage.foldername(name))[1] = 'org-avatars');
-- Allow authenticated users to update (upsert) their org avatars
CREATE POLICY "Authenticated users can update org avatars"
ON storage.objects FOR UPDATE
TO authenticated
USING (bucket_id = 'avatars' AND (storage.foldername(name))[1] = 'org-avatars');
-- Allow public read access to all avatars
CREATE POLICY "Public read access for avatars"
ON storage.objects FOR SELECT
TO public
USING (bucket_id = 'avatars');
-- Allow authenticated users to delete org avatars
CREATE POLICY "Authenticated users can delete org avatars"
ON storage.objects FOR DELETE
TO authenticated
USING (bucket_id = 'avatars' AND (storage.foldername(name))[1] = 'org-avatars');
Loading…
Cancel
Save