parent
b517bb975c
commit
d8bbfd9dc3
95 changed files with 8016 additions and 3943 deletions
@ -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) |
||||
@ -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), |
||||
}; |
||||
}; |
||||
@ -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); |
||||
} |
||||
@ -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> |
||||
@ -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> |
||||
@ -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,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"> |
||||
{#if src} |
||||
<img |
||||
{src} |
||||
alt={name} |
||||
class="{sizes[size].box} {sizes[size].radius} object-cover shrink-0" |
||||
/> |
||||
{:else} |
||||
<div |
||||
class="rounded-full flex items-center justify-center font-medium text-white overflow-hidden {sizeClasses[ |
||||
size |
||||
]} {!src ? getColorFromName(name) : 'bg-surface'}" |
||||
class="{sizes[size].box} {sizes[size] |
||||
.radius} bg-primary flex items-center justify-center shrink-0" |
||||
> |
||||
{#if src} |
||||
<img {src} alt={name} class="w-full h-full object-cover" /> |
||||
{:else} |
||||
{getInitials(name)} |
||||
{/if} |
||||
<span class="font-heading {sizes[size].text} text-night leading-none"> |
||||
{initial} |
||||
</span> |
||||
</div> |
||||
|
||||
{#if status} |
||||
<div |
||||
class="absolute bottom-0 right-0 rounded-full border-2 border-dark {statusSizes[size]} {statusColors[ |
||||
status |
||||
]}" |
||||
></div> |
||||
{/if} |
||||
</div> |
||||
{/if} |
||||
|
||||
@ -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> |
||||
|
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
|
||||
@ -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,61 +1,64 @@ |
||||
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; |
||||
|
||||
// Get org members with profiles
|
||||
const { data: members } = await locals.supabase |
||||
.from('org_members') |
||||
.select(` |
||||
id, |
||||
user_id, |
||||
role, |
||||
role_id, |
||||
created_at, |
||||
profiles:user_id ( |
||||
// Fetch all settings data in parallel
|
||||
const [membersResult, rolesResult, invitesResult, calendarResult] = await Promise.all([ |
||||
// Get org members with profiles
|
||||
locals.supabase |
||||
.from('org_members') |
||||
.select(` |
||||
id, |
||||
email, |
||||
full_name, |
||||
avatar_url |
||||
) |
||||
`)
|
||||
.eq('org_id', orgId); |
||||
|
||||
// Get org roles
|
||||
const { data: roles } = await locals.supabase |
||||
.from('org_roles') |
||||
.select('*') |
||||
.eq('org_id', orgId) |
||||
.order('position'); |
||||
|
||||
// Get pending invites
|
||||
const { data: invites } = await locals.supabase |
||||
.from('org_invites') |
||||
.select('*') |
||||
.eq('org_id', orgId) |
||||
.is('accepted_at', null) |
||||
.gt('expires_at', new Date().toISOString()); |
||||
|
||||
// Get org Google Calendar connection
|
||||
const { data: orgCalendar } = await locals.supabase |
||||
.from('org_google_calendars') |
||||
.select('*') |
||||
.eq('org_id', orgId) |
||||
.single(); |
||||
user_id, |
||||
role, |
||||
role_id, |
||||
created_at, |
||||
profiles:user_id ( |
||||
id, |
||||
email, |
||||
full_name, |
||||
avatar_url |
||||
) |
||||
`)
|
||||
.eq('org_id', orgId), |
||||
// Get org roles
|
||||
locals.supabase |
||||
.from('org_roles') |
||||
.select('*') |
||||
.eq('org_id', orgId) |
||||
.order('position'), |
||||
// Get pending invites
|
||||
locals.supabase |
||||
.from('org_invites') |
||||
.select('*') |
||||
.eq('org_id', orgId) |
||||
.is('accepted_at', null) |
||||
.gt('expires_at', new Date().toISOString()), |
||||
// Get org Google Calendar connection
|
||||
locals.supabase |
||||
.from('org_google_calendars') |
||||
.select('*') |
||||
.eq('org_id', orgId) |
||||
.single() |
||||
]); |
||||
|
||||
return { |
||||
members: members ?? [], |
||||
roles: roles ?? [], |
||||
invites: invites ?? [], |
||||
orgCalendar, |
||||
members: membersResult.data ?? [], |
||||
roles: rolesResult.data ?? [], |
||||
invites: invitesResult.data ?? [], |
||||
orgCalendar: calendarResult.data, |
||||
userRole |
||||
}; |
||||
}; |
||||
|
||||
@ -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(); |
||||
}); |
||||
}); |
||||
@ -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…
Reference in new issue