Mega push vol 4
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
VITE_SUPABASE_URL=your_supabase_url
|
PUBLIC_SUPABASE_URL=your_supabase_url
|
||||||
VITE_SUPABASE_ANON_KEY=your_supabase_anon_key
|
PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||||
|
|
||||||
GOOGLE_API_KEY=your_google_api_key
|
GOOGLE_API_KEY=your_google_api_key
|
||||||
|
|||||||
666
AUDIT.md
Normal file
666
AUDIT.md
Normal file
@@ -0,0 +1,666 @@
|
|||||||
|
# Comprehensive Codebase Audit Report (v2)
|
||||||
|
|
||||||
|
**Project:** root-org (SvelteKit + Supabase + Tailwind v4)
|
||||||
|
**Date:** 2026-02-06 (updated)
|
||||||
|
**Auditor:** Cascade
|
||||||
|
|
||||||
|
> **Changes since v1:** Dead stores (auth, organizations, documents, kanban, theme) deleted. `OrgWithRole` moved to `$lib/api/organizations.ts`. `FileTree` removed. Documents pages refactored into shared `FileBrowser` component. Document locking added (`document-locks` API + migration). Calendar `$derived` bugs fixed. `buildDocumentTree`/`DocumentWithChildren` removed. Editor CSS typo fixed. Invite page routes corrected. KanbanBoard button label fixed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Security
|
||||||
|
|
||||||
|
### S-1 · **CRITICAL** — Real credentials committed to `.env` in git
|
||||||
|
|
||||||
|
**File:** `.env:1-4`
|
||||||
|
|
||||||
|
```
|
||||||
|
PUBLIC_SUPABASE_URL=https://zlworzrghsrokdkuckez.supabase.co
|
||||||
|
PUBLIC_SUPABASE_ANON_KEY=sb_publishable_UDoCgcmpUeE5d-jocBSdVw_TWzDxK3x
|
||||||
|
GOOGLE_API_KEY=AIzaSyAn2LnXkgwyLcTHQPt3nbFhBwnYWosmMT0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** The `.env` file contains real Supabase and Google API keys and is tracked by git. Anyone with repo access (or if the repo is public) has full access to your Supabase project and Google API quota.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
1. Add `.env` to `.gitignore` (it's already there — but the file was committed before the gitignore rule was added).
|
||||||
|
2. Run `git rm --cached .env` to untrack it.
|
||||||
|
3. **Rotate all three keys immediately** — they are compromised the moment they entered git history.
|
||||||
|
4. Use `git filter-branch` or `git-filter-repo` to purge `.env` from history.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### S-2 · **CRITICAL** — Google Calendar API route has no authentication check
|
||||||
|
|
||||||
|
**File:** `src/routes/api/google-calendar/events/+server.ts:7-9`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||||
|
const orgId = url.searchParams.get('org_id');
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** The endpoint accepts any `org_id` parameter and fetches calendar data without verifying the requesting user is a member of that org. An unauthenticated user can enumerate org IDs and read all connected Google Calendar events.
|
||||||
|
|
||||||
|
**Fix:** Add session + membership check:
|
||||||
|
```ts
|
||||||
|
const { session, user } = await locals.safeGetSession();
|
||||||
|
if (!session || !user) return json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const { data: membership } = await locals.supabase
|
||||||
|
.from('org_members')
|
||||||
|
.select('id')
|
||||||
|
.eq('org_id', orgId)
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!membership) return json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### S-3 · **HIGH** — Auth callback open redirect vulnerability
|
||||||
|
|
||||||
|
**File:** `src/routes/auth/callback/+server.ts:6`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const next = url.searchParams.get('next') ?? url.searchParams.get('redirect') ?? '/';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** The `next`/`redirect` parameter is used directly in `redirect(303, next)` without validating it's a relative URL. An attacker can craft `?next=https://evil.com` to redirect users after login.
|
||||||
|
|
||||||
|
**Fix:** Validate the redirect target is a relative path:
|
||||||
|
```ts
|
||||||
|
function safeRedirect(target: string): string {
|
||||||
|
if (target.startsWith('/') && !target.startsWith('//')) return target;
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
const next = safeRedirect(url.searchParams.get('next') ?? url.searchParams.get('redirect') ?? '/');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### S-4 · **HIGH** — Settings page performs destructive operations without server-side authorization
|
||||||
|
|
||||||
|
**File:** `src/routes/[orgSlug]/settings/+page.svelte:186-463`
|
||||||
|
|
||||||
|
**Problem:** All settings mutations (update org, delete org, invite/remove members, manage roles, connect calendar) are done via direct Supabase client calls from the browser. The only server-side check is the page load guard (`owner`/`admin`). If RLS policies are not perfectly configured, any authenticated user could call these mutations directly.
|
||||||
|
|
||||||
|
**Fix:** Move destructive operations to SvelteKit form actions or API routes with explicit server-side authorization checks, rather than relying solely on Supabase RLS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### S-5 · **MEDIUM** — `.env.example` uses wrong variable prefix
|
||||||
|
|
||||||
|
**File:** `.env.example:1-2`
|
||||||
|
|
||||||
|
```
|
||||||
|
VITE_SUPABASE_URL=your_supabase_url
|
||||||
|
VITE_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** The example uses `VITE_` prefix but the actual app uses `PUBLIC_` prefix (SvelteKit convention). A developer copying `.env.example` would have a broken setup.
|
||||||
|
|
||||||
|
**Fix:** Update `.env.example`:
|
||||||
|
```
|
||||||
|
PUBLIC_SUPABASE_URL=your_supabase_url
|
||||||
|
PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||||
|
GOOGLE_API_KEY=your_google_api_key
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### S-6 · **MEDIUM** — Document lock RLS allows any user to delete expired locks
|
||||||
|
|
||||||
|
**File:** `supabase/migrations/016_document_locks.sql:40-41`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE POLICY "Anyone can delete expired locks" ON document_locks FOR DELETE
|
||||||
|
USING (last_heartbeat < now() - interval '60 seconds');
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** Any authenticated user can delete any expired lock. While the intent is correct (cleanup), this could be exploited to race-condition a lock takeover. The `acquireLock` function in `document-locks.ts:75-79` also deletes expired locks client-side, creating two competing cleanup paths.
|
||||||
|
|
||||||
|
**Fix:** Remove the client-side expired lock cleanup from `acquireLock()` and rely solely on the RLS policy, or vice versa. Consider using a server-side cron/function for lock cleanup instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Dead & Unused Code
|
||||||
|
|
||||||
|
### ~~D-1~~ RESOLVED — Dead stores deleted
|
||||||
|
### ~~D-4~~ RESOLVED — `FileTree` removed
|
||||||
|
### ~~D-5~~ RESOLVED — `$lib/stores/index.ts` and `auth.svelte.ts` deleted
|
||||||
|
|
||||||
|
### D-2 · **HIGH** — `$lib/utils/api-helpers.ts` is never imported
|
||||||
|
|
||||||
|
**File:** `src/lib/utils/api-helpers.ts` (96 lines)
|
||||||
|
|
||||||
|
**Problem:** `unwrap()` and `safeCall()` are well-designed helpers but are never used anywhere. All API modules use manual `if (error) throw error` patterns instead.
|
||||||
|
|
||||||
|
**Fix:** Either delete the file, or migrate API modules to use `unwrap()` (recommended — see finding A-2).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### D-3 · **HIGH** — Entire `layout/` component directory is never imported
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/lib/components/layout/PageContainer.svelte`
|
||||||
|
- `src/lib/components/layout/PageHeader.svelte`
|
||||||
|
- `src/lib/components/layout/ResponsiveGrid.svelte`
|
||||||
|
- `src/lib/components/layout/SplitPane.svelte`
|
||||||
|
- `src/lib/components/layout/index.ts`
|
||||||
|
|
||||||
|
**Problem:** Zero imports of any layout component anywhere in the codebase.
|
||||||
|
|
||||||
|
**Fix:** Delete the entire `src/lib/components/layout/` directory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### D-6 · **MEDIUM** — Unused variables in kanban page
|
||||||
|
|
||||||
|
**File:** `src/routes/[orgSlug]/kanban/+page.svelte:48-49,107`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
let newBoardVisibility = $state<"team" | "personal">("team");
|
||||||
|
let editBoardVisibility = $state<"team" | "personal">("team");
|
||||||
|
let sidebarCollapsed = $state(false);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** These three reactive variables are declared but never read or written to anywhere in the template or logic.
|
||||||
|
|
||||||
|
**Fix:** Remove all three declarations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### D-7 · **MEDIUM** — `$lib/index.ts` is an empty placeholder
|
||||||
|
|
||||||
|
**File:** `src/lib/index.ts`
|
||||||
|
|
||||||
|
**Problem:** Empty file with only a comment. Serves no purpose.
|
||||||
|
|
||||||
|
**Fix:** Delete the file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### D-8 · **MEDIUM** — `$lib/supabase/server.ts` is never imported
|
||||||
|
|
||||||
|
**File:** `src/lib/supabase/server.ts` (20 lines)
|
||||||
|
|
||||||
|
**Problem:** Re-exported as `createServerClient` from `$lib/supabase/index.ts`, but never imported anywhere. All server routes use `locals.supabase` from `hooks.server.ts`.
|
||||||
|
|
||||||
|
**Fix:** Delete `src/lib/supabase/server.ts` and remove the re-export from `src/lib/supabase/index.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### D-9 · **LOW** — Demo test and scaffold test are placeholders
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/demo.spec.ts` — `it('adds 1 + 2 to equal 3')`
|
||||||
|
- `src/routes/page.svelte.spec.ts`
|
||||||
|
|
||||||
|
**Fix:** Delete or replace with real smoke tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Type Safety & Correctness
|
||||||
|
|
||||||
|
### T-1 · **HIGH** — Supabase types are stale, causing 66 `as any` casts
|
||||||
|
|
||||||
|
**Files:** 15 files with `as any` casts, concentrated in:
|
||||||
|
- `src/lib/components/kanban/CardDetailModal.svelte` (9)
|
||||||
|
- `src/lib/api/document-locks.ts` (6)
|
||||||
|
- `src/lib/components/documents/FileBrowser.svelte` (6)
|
||||||
|
- `src/routes/[orgSlug]/documents/[id]/+page.server.ts` (6)
|
||||||
|
- `src/routes/[orgSlug]/documents/file/[id]/+page.server.ts` (6)
|
||||||
|
- `src/routes/[orgSlug]/documents/folder/[id]/+page.server.ts` (6)
|
||||||
|
- `src/routes/[orgSlug]/documents/file/[id]/+page.svelte` (5)
|
||||||
|
- `src/lib/components/kanban/CardComments.svelte` (4)
|
||||||
|
- `src/lib/components/kanban/KanbanBoard.svelte` (4)
|
||||||
|
- `src/routes/api/google-calendar/events/+server.ts` (4)
|
||||||
|
|
||||||
|
**Problem:** The `Database` type in `src/lib/supabase/types.ts` is out of sync with the actual database schema. Missing tables (`document_locks`, `org_google_calendars`, `org_invites`, `org_roles`, `activity_log`), missing columns on existing tables, and incorrect optionality force `as any` everywhere.
|
||||||
|
|
||||||
|
**Fix:** Regenerate types with `supabase gen types typescript --project-id <id> > src/lib/supabase/types.ts`. This single fix will eliminate most of the 66 `as any` casts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T-2 · **MEDIUM** — `user: any` in root page Props
|
||||||
|
|
||||||
|
**File:** `src/routes/+page.svelte:19`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
user: any; // ← should be typed
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix:** Use `import type { User } from '@supabase/supabase-js'` and type as `user: User | null`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T-3 · **MEDIUM** — Local `OrgWithRole` interface duplicates the API type
|
||||||
|
|
||||||
|
**File:** `src/routes/+page.svelte:9-14`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface OrgWithRole {
|
||||||
|
id: string; name: string; slug: string; role: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** This is a simplified duplicate of `$lib/api/organizations.OrgWithRole` (which now extends `Organization`). The server load returns full org fields, but this interface drops `avatar_url`, `created_at`, `updated_at`.
|
||||||
|
|
||||||
|
**Fix:** Import and use the canonical type: `import type { OrgWithRole } from '$lib/api/organizations'`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T-4 · **MEDIUM** — `org` from parent layout is untyped, causing `(org as any).id` everywhere
|
||||||
|
|
||||||
|
**Files:** All `+page.server.ts` files under `[orgSlug]/documents/`, `[orgSlug]/settings/`
|
||||||
|
|
||||||
|
**Problem:** The parent layout returns `org` as the raw Supabase row, but child loads access it as `(org as any).id`, `(org as any).slug` because TypeScript can't infer the parent return type.
|
||||||
|
|
||||||
|
**Fix:** Create a shared type for the org layout data:
|
||||||
|
```ts
|
||||||
|
// $lib/types/layout.ts
|
||||||
|
export interface OrgLayoutData {
|
||||||
|
org: Organization;
|
||||||
|
role: string;
|
||||||
|
userRole: string;
|
||||||
|
user: User;
|
||||||
|
members: any[];
|
||||||
|
recentActivity: any[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Then use it in child loads: `const { org } = await parent() as OrgLayoutData;`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T-5 · **MEDIUM** — `folder: any` in folder page Props
|
||||||
|
|
||||||
|
**File:** `src/routes/[orgSlug]/documents/folder/[id]/+page.svelte:8,17,21`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
folder: any;
|
||||||
|
const currentFolderId = $derived((data.folder as any).id as string);
|
||||||
|
<title>{(data.folder as any).name}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** The folder prop is typed as `any`, requiring double casts. This would be resolved by T-1 (regenerating Supabase types) combined with T-4 (typing parent data).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Code Duplication & Redundancy
|
||||||
|
|
||||||
|
### ~~R-1~~ RESOLVED — `FileBrowser` component extracted, both pages are now thin wrappers (~30 lines each)
|
||||||
|
|
||||||
|
### R-2 · **MEDIUM** — Server loads for documents still repeat the same `as any` pattern
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/routes/[orgSlug]/documents/+page.server.ts`
|
||||||
|
- `src/routes/[orgSlug]/documents/folder/[id]/+page.server.ts`
|
||||||
|
- `src/routes/[orgSlug]/documents/file/[id]/+page.server.ts`
|
||||||
|
- `src/routes/[orgSlug]/documents/[id]/+page.server.ts`
|
||||||
|
|
||||||
|
**Problem:** All four files repeat: get parent data, cast `org as any`, query documents table, handle errors. The `as any` casts are identical across all four.
|
||||||
|
|
||||||
|
**Fix:** Create a shared `getOrgFromParent(parent)` utility that types the parent data once. Resolving T-1 + T-4 would also eliminate this.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### R-3 · **MEDIUM** — `$lib/supabase/server.ts` duplicates `hooks.server.ts`
|
||||||
|
|
||||||
|
**Problem:** `server.ts` creates a server Supabase client from cookies, but `hooks.server.ts` already does this and puts it on `locals.supabase`. The `server.ts` export is never imported.
|
||||||
|
|
||||||
|
**Fix:** Delete `src/lib/supabase/server.ts` and remove the `createServerClient` re-export from `src/lib/supabase/index.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### R-4 · **LOW** — `role` and `userRole` are the same value returned from org layout
|
||||||
|
|
||||||
|
**File:** `src/routes/[orgSlug]/+layout.server.ts:74-75`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
role: membership.role,
|
||||||
|
userRole: membership.role, // kept for backwards compat — same as role
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** Both contain `membership.role`. The layout uses `data.role`, child pages use `data.userRole`. The comment acknowledges the duplication.
|
||||||
|
|
||||||
|
**Fix:** Migrate all consumers to `userRole` and remove `role`, or vice versa.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Error Handling & Resilience
|
||||||
|
|
||||||
|
### E-1 · **HIGH** — Settings page uses `alert()` and swallows errors
|
||||||
|
|
||||||
|
**File:** `src/routes/[orgSlug]/settings/+page.svelte`
|
||||||
|
|
||||||
|
- Line 234: `alert("Failed to send invite: " + error.message)`
|
||||||
|
- Line 447: `alert("Owners cannot leave...")`
|
||||||
|
- Lines 240, 274, 357, 426, 440: No error handling on `await supabase.from(...).delete()` — errors are silently ignored.
|
||||||
|
|
||||||
|
**Fix:** Use the existing `toasts` store for user feedback. Add error handling to all mutations:
|
||||||
|
```ts
|
||||||
|
const { error } = await supabase.from('org_members').delete().eq('id', memberId);
|
||||||
|
if (error) { toasts.error('Failed to remove member'); return; }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### E-2 · **HIGH** — `console.error` used instead of structured logger in 6 files
|
||||||
|
|
||||||
|
**Files (11 instances):**
|
||||||
|
- `src/routes/api/google-calendar/events/+server.ts` — 4 instances (`console.error`, `console.log`)
|
||||||
|
- `src/routes/[orgSlug]/calendar/+page.svelte` — 3 instances
|
||||||
|
- `src/routes/invite/[token]/+page.svelte` — 2 instances
|
||||||
|
- `src/lib/api/google-calendar.ts` — 1 instance
|
||||||
|
- `src/routes/[orgSlug]/kanban/+page.svelte` — 1 instance
|
||||||
|
|
||||||
|
**Problem:** The project has a well-designed `createLogger()` system but these modules bypass it with raw `console.*` calls, losing structured context, timestamps, and the ring buffer for error reports.
|
||||||
|
|
||||||
|
**Fix:** Replace all `console.error/log/warn` with `createLogger()` calls.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### E-3 · **MEDIUM** — Calendar page `handleDateClick` is a no-op
|
||||||
|
|
||||||
|
**File:** `src/routes/[orgSlug]/calendar/+page.svelte:41-43`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function handleDateClick(_date: Date) {
|
||||||
|
// Event creation disabled
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix:** Either implement event creation or remove the click handler from the Calendar component to avoid misleading UX.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### E-4 · **MEDIUM** — `data` captured by value causes stale state in multiple pages
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/routes/[orgSlug]/calendar/+page.svelte:26` — `let events = $state(data.events)`
|
||||||
|
- `src/routes/[orgSlug]/documents/+page.svelte:15` — `let documents = $state(data.documents)`
|
||||||
|
- `src/routes/[orgSlug]/documents/folder/[id]/+page.svelte:16` — `let documents = $state(data.documents)`
|
||||||
|
- `src/routes/[orgSlug]/kanban/+page.svelte:40` — `let boards = $state(data.boards)`
|
||||||
|
- `src/routes/+page.svelte:27` — `let organizations = $state(data.organizations)`
|
||||||
|
|
||||||
|
**Problem:** Svelte warns: "This reference only captures the initial value of `data`." If `data` changes (e.g., via `invalidateAll()`), the local state won't update.
|
||||||
|
|
||||||
|
**Fix:** Add `$effect` blocks to sync:
|
||||||
|
```ts
|
||||||
|
let events = $state(data.events);
|
||||||
|
$effect(() => { events = data.events; });
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### E-5 · **MEDIUM** — `releaseLock` called in `onDestroy` may not complete
|
||||||
|
|
||||||
|
**File:** `src/routes/[orgSlug]/documents/file/[id]/+page.svelte:222-225`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
onDestroy(() => {
|
||||||
|
// ...
|
||||||
|
releaseLock(supabase, data.document.id, data.user.id);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** `releaseLock` is async but not awaited. In `onDestroy`, the component is being torn down and the async call may not complete before the page unloads (especially on navigation). The lock would then rely on heartbeat expiry (60s) to be cleaned up.
|
||||||
|
|
||||||
|
**Fix:** Use `beforeunload` event for page unloads and `navigator.sendBeacon` for reliable cleanup:
|
||||||
|
```ts
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
navigator.sendBeacon(`/api/release-lock`, JSON.stringify({ documentId, userId }));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Architecture & Structure
|
||||||
|
|
||||||
|
### A-1 · **HIGH** — Settings page is a 1205-line god component
|
||||||
|
|
||||||
|
**File:** `src/routes/[orgSlug]/settings/+page.svelte` (1205 lines)
|
||||||
|
|
||||||
|
**Problem:** This single file handles 5 tabs (General, Members, Roles, Integrations, Appearance), each with their own state, modals, and API calls. It contains ~15 async functions, ~25 state variables, and 6 modals.
|
||||||
|
|
||||||
|
**Fix:** Extract each tab into its own component:
|
||||||
|
- `SettingsGeneral.svelte`
|
||||||
|
- `SettingsMembers.svelte`
|
||||||
|
- `SettingsRoles.svelte`
|
||||||
|
- `SettingsIntegrations.svelte`
|
||||||
|
- `SettingsAppearance.svelte`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A-2 · **MEDIUM** — Inconsistent data fetching patterns
|
||||||
|
|
||||||
|
**Problem:** The codebase uses three different patterns for Supabase calls:
|
||||||
|
1. **API modules** (`$lib/api/*.ts`) — used by kanban page, org overview, document locks
|
||||||
|
2. **Direct `supabase.from()` in components** — used by settings, FileBrowser, card detail modal, comments
|
||||||
|
3. **Server loads** — used by all `+page.server.ts` files
|
||||||
|
|
||||||
|
Pattern 2 is problematic because it bypasses the API layer's logging and error handling. The new `FileBrowser` component (872 lines) does all mutations via direct Supabase calls despite `$lib/api/documents.ts` having `createDocument`, `updateDocument`, `moveDocument`, `deleteDocument` functions.
|
||||||
|
|
||||||
|
**Fix:** Migrate `FileBrowser` to use the documents API module. Add missing `kanban` type support to `createDocument`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A-3 · **MEDIUM** — `createDocument` API doesn't support `kanban` type
|
||||||
|
|
||||||
|
**File:** `src/lib/api/documents.ts:30`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type: 'folder' | 'document', // missing 'kanban'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** The function signature only accepts `'folder' | 'document'` but the database and UI support `'kanban'` as a document type. `FileBrowser.handleCreate()` calls Supabase directly to create kanban documents.
|
||||||
|
|
||||||
|
**Fix:** Update the type to `'folder' | 'document' | 'kanban'` and add kanban board creation logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Performance
|
||||||
|
|
||||||
|
### P-1 · **HIGH** — Folder page loads ALL org documents on every visit
|
||||||
|
|
||||||
|
**File:** `src/routes/[orgSlug]/documents/folder/[id]/+page.server.ts`
|
||||||
|
|
||||||
|
**Problem:** Every folder page load fetches every document in the entire org (including content blobs via `select('*')`). For orgs with many documents, this is wasteful — content is fetched but only names/types are displayed in the file browser.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
1. Select only needed columns: `.select('id, name, type, parent_id, created_at, updated_at')`
|
||||||
|
2. Consider fetching only the current folder's children + ancestors for breadcrumbs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P-2 · **HIGH** — `fetchBoardWithColumns` makes 3 sequential queries
|
||||||
|
|
||||||
|
**File:** `src/lib/api/kanban.ts:33-86`
|
||||||
|
|
||||||
|
**Problem:** Fetches board, then columns, then cards in 3 separate queries. The columns and cards queries could run in parallel.
|
||||||
|
|
||||||
|
**Fix:** After fetching the board, run columns and cards queries in parallel with `Promise.all`, or use a single query with joins.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P-3 · **MEDIUM** — `moveCard` in API makes N individual UPDATE queries
|
||||||
|
|
||||||
|
**File:** `src/lib/api/kanban.ts:260-271`
|
||||||
|
|
||||||
|
**Problem:** Moving a card fires one UPDATE query per card in the target column. A column with 20 cards = 20 queries.
|
||||||
|
|
||||||
|
**Fix:** Use a Supabase RPC function for batch position updates, or only update cards whose position actually changed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P-4 · **MEDIUM** — Realtime subscription reloads entire board on any change
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/routes/[orgSlug]/kanban/+page.svelte:64-74`
|
||||||
|
- `src/routes/[orgSlug]/documents/file/[id]/+page.svelte:199-215`
|
||||||
|
|
||||||
|
**Problem:** Any card or column change (even by the current user) triggers a full board reload (3 queries). Combined with optimistic updates, this can cause UI flicker and wasted bandwidth.
|
||||||
|
|
||||||
|
**Fix:** Use the realtime payload to apply incremental updates instead of full reloads.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Dependency Health
|
||||||
|
|
||||||
|
### DEP-1 · **MEDIUM** — `lucide-svelte` is installed but never imported
|
||||||
|
|
||||||
|
**File:** `package.json:40`
|
||||||
|
|
||||||
|
```json
|
||||||
|
"lucide-svelte": "^0.563.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** Zero imports of `lucide-svelte` anywhere in the codebase. The app uses Google Material Symbols for all icons.
|
||||||
|
|
||||||
|
**Fix:** `npm uninstall lucide-svelte` — saves bundle size.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### DEP-2 · **LOW** — `@tailwindcss/forms` may be unused
|
||||||
|
|
||||||
|
**Problem:** The plugin is listed as a devDependency and imported in `layout.css`, but all form elements are custom-styled via the `Input`, `Select`, `Textarea` components. The forms plugin may be adding base styles that are immediately overridden.
|
||||||
|
|
||||||
|
**Fix:** Test removing `@tailwindcss/forms` to see if anything breaks. If not, remove it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Maintainability & Readability
|
||||||
|
|
||||||
|
### M-1 · **MEDIUM** — Magic numbers for invite expiry
|
||||||
|
|
||||||
|
**File:** `src/routes/[orgSlug]/settings/+page.svelte:220-222`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix:** Extract to a constant: `const INVITE_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; // 7 days`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-2 · **MEDIUM** — Inconsistent error feedback patterns
|
||||||
|
|
||||||
|
**Problem:** The codebase uses 4 different patterns for user-facing errors:
|
||||||
|
1. `alert()` — settings page
|
||||||
|
2. `toasts.error()` — documents, file viewer
|
||||||
|
3. `console.error()` only (silent to user) — kanban, calendar, invite pages
|
||||||
|
4. Inline `error` state variable — login page, invite page
|
||||||
|
|
||||||
|
**Fix:** Standardize on `toasts` for all user-facing error feedback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-3 · **LOW** — Inline SVG icons in multiple places
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/routes/+page.svelte:88-98,105-116` — inline SVG for plus icon, people icon
|
||||||
|
- `src/routes/[orgSlug]/settings/+page.svelte` — inline SVGs for invite, Google, Discord, Slack icons
|
||||||
|
- `src/routes/invite/[token]/+page.svelte:103-136` — inline SVGs for error/invite icons
|
||||||
|
|
||||||
|
**Fix:** Use `<Icon name="..." />` for Material icons. For brand logos, create a `BrandIcon` component or use static SVG imports.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Future-Proofing
|
||||||
|
|
||||||
|
### F-1 · **MEDIUM** — No permission enforcement beyond role checks
|
||||||
|
|
||||||
|
**Problem:** The settings page defines a granular permission system (`documents.view`, `kanban.create`, `members.manage`, etc.) and roles can have custom permissions. But no code anywhere actually checks these permissions — only `role === 'owner' || role === 'admin'` checks exist.
|
||||||
|
|
||||||
|
**Fix:** Create a `hasPermission(userRole, roles, permission)` utility and use it to gate actions throughout the app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### F-2 · **MEDIUM** — Kanban board subscription listens to ALL card changes
|
||||||
|
|
||||||
|
**File:** `src/lib/api/kanban.ts:291`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
.on('postgres_changes', { event: '*', schema: 'public', table: 'kanban_cards' }, onCardChange)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** No filter on `column_id` or board. This subscription fires for every card change across all boards in the entire database.
|
||||||
|
|
||||||
|
**Fix:** Add a filter or use a Supabase function to scope the subscription to the current board's column IDs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### F-3 · **LOW** — `@tailwindcss/typography` prose styles may conflict with editor
|
||||||
|
|
||||||
|
**Problem:** The TipTap editor content is wrapped in `.prose` which applies Tailwind Typography styles. These may conflict with the editor's own styling.
|
||||||
|
|
||||||
|
**Fix:** Audit the `.prose` overrides in `layout.css` and consider scoping them more tightly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
### Issues by Severity
|
||||||
|
|
||||||
|
| Severity | Count |
|
||||||
|
|----------|-------|
|
||||||
|
| **Critical** | 2 |
|
||||||
|
| **High** | 7 |
|
||||||
|
| **Medium** | 17 |
|
||||||
|
| **Low** | 4 |
|
||||||
|
| **Total** | **30** |
|
||||||
|
|
||||||
|
### Resolved Since v1
|
||||||
|
|
||||||
|
| ID | Issue | Resolution |
|
||||||
|
|----|-------|------------|
|
||||||
|
| D-1 | 3 dead stores (660 lines) | Deleted (auth, organizations, documents, kanban, theme) |
|
||||||
|
| D-4 | Unused `FileTree` component | Removed from index and codebase |
|
||||||
|
| D-5 | Dead `$lib/stores/index.ts` + `auth.svelte.ts` | Deleted |
|
||||||
|
| R-1 | Documents page duplication (~1900 lines) | Extracted `FileBrowser` component; both pages now ~30 lines |
|
||||||
|
| R-4 | Kanban store vs inline optimistic updates | Store deleted; inline implementation kept |
|
||||||
|
| R-6 | `Promise.resolve(null)` placeholder | Layout load refactored |
|
||||||
|
| — | Calendar `$derived` vs `$derived.by` bug | Fixed (`weeks`, `headerTitle`) |
|
||||||
|
| — | `buildDocumentTree`/`DocumentWithChildren` dead code | Removed |
|
||||||
|
| — | Editor CSS typo (`py-1.5bg-background`) | Fixed to `py-1.5 bg-background` |
|
||||||
|
| — | Invite page wrong routes (`/signup`, `/logout`) | Fixed to `/login?tab=signup`, `/auth/logout` |
|
||||||
|
| — | KanbanBoard "Add column" mislabel | Fixed to "Add card" |
|
||||||
|
|
||||||
|
### Resolved Since v2
|
||||||
|
|
||||||
|
| ID | Issue | Resolution |
|
||||||
|
|----|-------|------------|
|
||||||
|
| S-2 | Google Calendar API no auth | Added session + org membership check; returns 401/403 |
|
||||||
|
| S-3 | Auth callback open redirect | Added `safeRedirect()` validator — only allows relative paths |
|
||||||
|
| S-5 | `.env.example` wrong prefix | Fixed `VITE_` → `PUBLIC_` |
|
||||||
|
| D-2 | Unused `api-helpers.ts` (96 lines) | Deleted |
|
||||||
|
| D-3 | Unused `layout/` directory (5 files) | Deleted entire directory |
|
||||||
|
| D-6 | Unused kanban vars (`newBoardVisibility`, `editBoardVisibility`, `sidebarCollapsed`) | Removed |
|
||||||
|
| D-7 | Empty `$lib/index.ts` | Deleted |
|
||||||
|
| D-8 | Unused `$lib/supabase/server.ts` | Deleted; removed re-export from index |
|
||||||
|
| D-9 | Placeholder tests (`demo.spec.ts`, `page.svelte.spec.ts`) | Deleted |
|
||||||
|
| DEP-1 | Unused `lucide-svelte` dependency | Uninstalled |
|
||||||
|
| E-1 | Settings page `alert()` calls | Replaced with `toasts` store |
|
||||||
|
| E-2 | `console.*` in Google Calendar API route | Replaced with `createLogger()` |
|
||||||
|
| E-4 | Stale `$state(data.*)` in 5 pages | Added `$effect` sync blocks |
|
||||||
|
| A-1 | Settings page god component (partial) | Extracted `SettingsGeneral` component; Figma-style pill tab nav |
|
||||||
|
| R-3 | `server.ts` duplicates `hooks.server.ts` | Deleted (same as D-8) |
|
||||||
|
|
||||||
|
### Top 3 Highest-Impact Remaining Improvements
|
||||||
|
|
||||||
|
1. **Rotate credentials & secure `.env`** (S-1) — Immediate security risk. Takes 15 minutes but prevents catastrophic data breach. **Must be done manually.**
|
||||||
|
|
||||||
|
2. **Regenerate Supabase types** (T-1) — Single command that eliminates 66 `as any` casts, all `never` type errors, and makes the entire codebase type-safe. Also resolves T-4, T-5, and most of R-2.
|
||||||
|
|
||||||
|
3. **Continue splitting settings page** (A-1) — Members, Roles, and Integrations tabs still inline. Extract each into its own component.
|
||||||
|
|
||||||
|
### Suggested Order of Operations (updated)
|
||||||
|
|
||||||
|
1. **Immediate (security):** S-1 (rotate keys — manual), S-4 (server-side auth for settings mutations)
|
||||||
|
2. **Type safety (1 hour):** T-1 (regenerate Supabase types), T-2→T-5 (fix remaining type issues)
|
||||||
|
3. **Architecture (1-2 days):** A-1 (finish splitting settings tabs), A-2 (migrate FileBrowser to use API modules), A-3 (add kanban type to createDocument)
|
||||||
|
4. **Performance (1 day):** P-1 (select only needed columns), P-2 (parallelize kanban queries), P-4 (incremental realtime updates)
|
||||||
|
5. **Polish:** E-5 (reliable lock release), M-1→M-3 (constants, consistent patterns), F-1 (permission enforcement), F-2 (scoped subscriptions)
|
||||||
31
package-lock.json
generated
31
package-lock.json
generated
@@ -13,8 +13,7 @@
|
|||||||
"@tiptap/core": "^3.19.0",
|
"@tiptap/core": "^3.19.0",
|
||||||
"@tiptap/extension-placeholder": "^3.19.0",
|
"@tiptap/extension-placeholder": "^3.19.0",
|
||||||
"@tiptap/pm": "^3.19.0",
|
"@tiptap/pm": "^3.19.0",
|
||||||
"@tiptap/starter-kit": "^3.19.0",
|
"@tiptap/starter-kit": "^3.19.0"
|
||||||
"lucide-svelte": "^0.563.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-node": "^5.5.2",
|
"@sveltejs/adapter-node": "^5.5.2",
|
||||||
@@ -480,6 +479,7 @@
|
|||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
@@ -490,6 +490,7 @@
|
|||||||
"version": "2.3.5",
|
"version": "2.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/gen-mapping": "^0.3.5",
|
"@jridgewell/gen-mapping": "^0.3.5",
|
||||||
@@ -500,6 +501,7 @@
|
|||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
@@ -509,12 +511,14 @@
|
|||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/trace-mapping": {
|
"node_modules/@jridgewell/trace-mapping": {
|
||||||
"version": "0.3.31",
|
"version": "0.3.31",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/resolve-uri": "^3.1.0",
|
"@jridgewell/resolve-uri": "^3.1.0",
|
||||||
@@ -1097,6 +1101,7 @@
|
|||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz",
|
||||||
"integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==",
|
"integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"acorn": "^8.9.0"
|
"acorn": "^8.9.0"
|
||||||
@@ -1932,6 +1937,7 @@
|
|||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/linkify-it": {
|
"node_modules/@types/linkify-it": {
|
||||||
@@ -2160,6 +2166,7 @@
|
|||||||
"version": "8.15.0",
|
"version": "8.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -2179,6 +2186,7 @@
|
|||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
||||||
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
|
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -2198,6 +2206,7 @@
|
|||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||||
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
|
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -2233,6 +2242,7 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -2298,6 +2308,7 @@
|
|||||||
"version": "5.6.2",
|
"version": "5.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz",
|
||||||
"integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==",
|
"integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
@@ -2391,12 +2402,14 @@
|
|||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
|
||||||
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
|
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/esrap": {
|
"node_modules/esrap": {
|
||||||
"version": "2.2.2",
|
"version": "2.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.2.tgz",
|
||||||
"integrity": "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==",
|
"integrity": "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.15"
|
"@jridgewell/sourcemap-codec": "^1.4.15"
|
||||||
@@ -2824,21 +2837,14 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||||
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lucide-svelte": {
|
|
||||||
"version": "0.563.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.563.0.tgz",
|
|
||||||
"integrity": "sha512-pjZKw7TpQcamfQrx7YdbOHgmrcNeKiGGMD0tKZQaVktwSsbqw28CsKc2Q97ttwjytiCWkJyOa8ij2Q+Og0nPfQ==",
|
|
||||||
"license": "ISC",
|
|
||||||
"peerDependencies": {
|
|
||||||
"svelte": "^3 || ^4 || ^5.0.0-next.42"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
@@ -3443,6 +3449,7 @@
|
|||||||
"version": "5.49.1",
|
"version": "5.49.1",
|
||||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.49.1.tgz",
|
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.49.1.tgz",
|
||||||
"integrity": "sha512-jj95WnbKbXsXXngYj28a4zx8jeZx50CN/J4r0CEeax2pbfdsETv/J1K8V9Hbu3DCXnpHz5qAikICuxEooi7eNQ==",
|
"integrity": "sha512-jj95WnbKbXsXXngYj28a4zx8jeZx50CN/J4r0CEeax2pbfdsETv/J1K8V9Hbu3DCXnpHz5qAikICuxEooi7eNQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3494,6 +3501,7 @@
|
|||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||||
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
|
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "^1.0.6"
|
"@types/estree": "^1.0.6"
|
||||||
@@ -3870,6 +3878,7 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||||
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,6 @@
|
|||||||
"@tiptap/core": "^3.19.0",
|
"@tiptap/core": "^3.19.0",
|
||||||
"@tiptap/extension-placeholder": "^3.19.0",
|
"@tiptap/extension-placeholder": "^3.19.0",
|
||||||
"@tiptap/pm": "^3.19.0",
|
"@tiptap/pm": "^3.19.0",
|
||||||
"@tiptap/starter-kit": "^3.19.0",
|
"@tiptap/starter-kit": "^3.19.0"
|
||||||
"lucide-svelte": "^0.563.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
7
src/app.d.ts
vendored
7
src/app.d.ts
vendored
@@ -11,7 +11,12 @@ declare global {
|
|||||||
session: Session | null;
|
session: Session | null;
|
||||||
user: User | null;
|
user: User | null;
|
||||||
}
|
}
|
||||||
// interface Error {}
|
interface Error {
|
||||||
|
message: string;
|
||||||
|
context?: string;
|
||||||
|
code?: string;
|
||||||
|
errorId?: string;
|
||||||
|
}
|
||||||
// interface PageState {}
|
// interface PageState {}
|
||||||
// interface Platform {}
|
// interface Platform {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
|
|
||||||
describe('sum test', () => {
|
|
||||||
it('adds 1 + 2 to equal 3', () => {
|
|
||||||
expect(1 + 2).toBe(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
19
src/hooks.client.ts
Normal file
19
src/hooks.client.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { HandleClientError } from '@sveltejs/kit';
|
||||||
|
import { createLogger } from '$lib/utils/logger';
|
||||||
|
|
||||||
|
const log = createLogger('client.error');
|
||||||
|
|
||||||
|
export const handleError: HandleClientError = async ({ error, status, message }) => {
|
||||||
|
const errorId = crypto.randomUUID().slice(0, 8);
|
||||||
|
|
||||||
|
log.error(`Unhandled client error [${errorId}]`, {
|
||||||
|
error,
|
||||||
|
data: { errorId, status, message },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: message || 'An unexpected error occurred',
|
||||||
|
errorId,
|
||||||
|
code: String(status),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { createServerClient } from '@supabase/ssr';
|
import { createServerClient } from '@supabase/ssr';
|
||||||
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
|
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
|
||||||
import type { Handle } from '@sveltejs/kit';
|
import type { Handle, HandleServerError } from '@sveltejs/kit';
|
||||||
|
import type { Database } from '$lib/supabase/types';
|
||||||
|
import { createLogger } from '$lib/utils/logger';
|
||||||
|
|
||||||
export const handle: Handle = async ({ event, resolve }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
event.locals.supabase = createServerClient<Database>(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
||||||
cookies: {
|
cookies: {
|
||||||
getAll() {
|
getAll() {
|
||||||
return event.cookies.getAll();
|
return event.cookies.getAll();
|
||||||
@@ -43,3 +45,27 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const serverLog = createLogger('server.error');
|
||||||
|
|
||||||
|
export const handleError: HandleServerError = async ({ error, event, status, message }) => {
|
||||||
|
const errorId = crypto.randomUUID().slice(0, 8);
|
||||||
|
|
||||||
|
serverLog.error(`Unhandled server error [${errorId}]`, {
|
||||||
|
error,
|
||||||
|
data: {
|
||||||
|
errorId,
|
||||||
|
status,
|
||||||
|
message,
|
||||||
|
url: event.url.pathname,
|
||||||
|
method: event.request.method,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: message || 'An unexpected error occurred',
|
||||||
|
errorId,
|
||||||
|
context: `${event.request.method} ${event.url.pathname}`,
|
||||||
|
code: String(status),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
import type { Database, CalendarEvent } from '$lib/supabase/types';
|
import type { Database, CalendarEvent } from '$lib/supabase/types';
|
||||||
|
import { createLogger } from '$lib/utils/logger';
|
||||||
|
|
||||||
|
const log = createLogger('api.calendar');
|
||||||
|
|
||||||
export async function fetchEvents(
|
export async function fetchEvents(
|
||||||
supabase: SupabaseClient<Database>,
|
supabase: SupabaseClient<Database>,
|
||||||
@@ -15,7 +18,10 @@ export async function fetchEvents(
|
|||||||
.lte('end_time', endDate.toISOString())
|
.lte('end_time', endDate.toISOString())
|
||||||
.order('start_time');
|
.order('start_time');
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) {
|
||||||
|
log.error('fetchEvents failed', { error, data: { orgId } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
return data ?? [];
|
return data ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +53,10 @@ export async function createEvent(
|
|||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) {
|
||||||
|
log.error('createEvent failed', { error, data: { orgId, title: event.title } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +66,10 @@ export async function updateEvent(
|
|||||||
updates: Partial<Pick<CalendarEvent, 'title' | 'description' | 'start_time' | 'end_time' | 'all_day' | 'color'>>
|
updates: Partial<Pick<CalendarEvent, 'title' | 'description' | 'start_time' | 'end_time' | 'all_day' | 'color'>>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { error } = await supabase.from('calendar_events').update(updates).eq('id', id);
|
const { error } = await supabase.from('calendar_events').update(updates).eq('id', id);
|
||||||
if (error) throw error;
|
if (error) {
|
||||||
|
log.error('updateEvent failed', { error, data: { id, updates } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteEvent(
|
export async function deleteEvent(
|
||||||
@@ -65,7 +77,10 @@ export async function deleteEvent(
|
|||||||
id: string
|
id: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { error } = await supabase.from('calendar_events').delete().eq('id', id);
|
const { error } = await supabase.from('calendar_events').delete().eq('id', id);
|
||||||
if (error) throw error;
|
if (error) {
|
||||||
|
log.error('deleteEvent failed', { error, data: { id } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function subscribeToEvents(
|
export function subscribeToEvents(
|
||||||
@@ -85,8 +100,11 @@ export function getMonthDays(year: number, month: number): Date[] {
|
|||||||
const lastDay = new Date(year, month + 1, 0);
|
const lastDay = new Date(year, month + 1, 0);
|
||||||
const days: Date[] = [];
|
const days: Date[] = [];
|
||||||
|
|
||||||
|
// Week starts on Monday (0=Mon, 6=Sun)
|
||||||
|
let startDayOfWeek = firstDay.getDay() - 1;
|
||||||
|
if (startDayOfWeek < 0) startDayOfWeek = 6; // Sunday becomes 6
|
||||||
|
|
||||||
// Add days from previous month to fill first week
|
// Add days from previous month to fill first week
|
||||||
const startDayOfWeek = firstDay.getDay();
|
|
||||||
for (let i = startDayOfWeek - 1; i >= 0; i--) {
|
for (let i = startDayOfWeek - 1; i >= 0; i--) {
|
||||||
days.push(new Date(year, month, -i));
|
days.push(new Date(year, month, -i));
|
||||||
}
|
}
|
||||||
@@ -96,8 +114,8 @@ export function getMonthDays(year: number, month: number): Date[] {
|
|||||||
days.push(new Date(year, month, i));
|
days.push(new Date(year, month, i));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add days from next month to fill last week
|
// Add days from next month to fill last week (up to 6 rows)
|
||||||
const remainingDays = 42 - days.length; // 6 weeks * 7 days
|
const remainingDays = 42 - days.length;
|
||||||
for (let i = 1; i <= remainingDays; i++) {
|
for (let i = 1; i <= remainingDays; i++) {
|
||||||
days.push(new Date(year, month + 1, i));
|
days.push(new Date(year, month + 1, i));
|
||||||
}
|
}
|
||||||
|
|||||||
152
src/lib/api/document-locks.ts
Normal file
152
src/lib/api/document-locks.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
import type { Database } from '$lib/supabase/types';
|
||||||
|
import { createLogger } from '$lib/utils/logger';
|
||||||
|
|
||||||
|
const log = createLogger('api.document-locks');
|
||||||
|
|
||||||
|
const LOCK_EXPIRY_SECONDS = 60;
|
||||||
|
const HEARTBEAT_INTERVAL_MS = 30_000; // 30 seconds
|
||||||
|
|
||||||
|
export interface LockInfo {
|
||||||
|
isLocked: boolean;
|
||||||
|
lockedBy: string | null;
|
||||||
|
lockedByName: string | null;
|
||||||
|
isOwnLock: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current lock status for a document.
|
||||||
|
* Only returns active locks (heartbeat within LOCK_EXPIRY_SECONDS).
|
||||||
|
*/
|
||||||
|
export async function getLockInfo(
|
||||||
|
supabase: SupabaseClient<Database>,
|
||||||
|
documentId: string,
|
||||||
|
currentUserId: string
|
||||||
|
): Promise<LockInfo> {
|
||||||
|
const cutoff = new Date(Date.now() - LOCK_EXPIRY_SECONDS * 1000).toISOString();
|
||||||
|
|
||||||
|
const { data: lock } = await supabase
|
||||||
|
.from('document_locks')
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
document_id,
|
||||||
|
user_id,
|
||||||
|
locked_at,
|
||||||
|
last_heartbeat,
|
||||||
|
profiles:user_id (full_name, email)
|
||||||
|
`)
|
||||||
|
.eq('document_id', documentId)
|
||||||
|
.gt('last_heartbeat', cutoff)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!lock) {
|
||||||
|
return { isLocked: false, lockedBy: null, lockedByName: null, isOwnLock: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = (lock as any).profiles; // join type not inferred by Supabase
|
||||||
|
return {
|
||||||
|
isLocked: true,
|
||||||
|
lockedBy: lock.user_id,
|
||||||
|
lockedByName: profile?.full_name || profile?.email || 'Someone',
|
||||||
|
isOwnLock: lock.user_id === currentUserId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acquire a lock on a document. Cleans up expired locks first.
|
||||||
|
* Returns true if lock was acquired, false if someone else holds it.
|
||||||
|
*/
|
||||||
|
export async function acquireLock(
|
||||||
|
supabase: SupabaseClient<Database>,
|
||||||
|
documentId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const cutoff = new Date(Date.now() - LOCK_EXPIRY_SECONDS * 1000).toISOString();
|
||||||
|
|
||||||
|
// Delete expired locks for this document
|
||||||
|
await supabase
|
||||||
|
.from('document_locks')
|
||||||
|
.delete()
|
||||||
|
.eq('document_id', documentId)
|
||||||
|
.lt('last_heartbeat', cutoff);
|
||||||
|
|
||||||
|
// Try to insert our lock
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('document_locks')
|
||||||
|
.insert({
|
||||||
|
document_id: documentId,
|
||||||
|
user_id: userId,
|
||||||
|
locked_at: new Date().toISOString(),
|
||||||
|
last_heartbeat: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (error.code === '23505') {
|
||||||
|
// Unique constraint violation — someone else holds the lock
|
||||||
|
log.debug('Lock already held', { data: { documentId } });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
log.error('acquireLock failed', { error, data: { documentId } });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('Lock acquired', { data: { documentId, userId } });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a heartbeat to keep the lock alive.
|
||||||
|
*/
|
||||||
|
export async function heartbeatLock(
|
||||||
|
supabase: SupabaseClient<Database>,
|
||||||
|
documentId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('document_locks')
|
||||||
|
.update({ last_heartbeat: new Date().toISOString() })
|
||||||
|
.eq('document_id', documentId)
|
||||||
|
.eq('user_id', userId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
log.error('heartbeatLock failed', { error, data: { documentId } });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release a lock on a document.
|
||||||
|
*/
|
||||||
|
export async function releaseLock(
|
||||||
|
supabase: SupabaseClient<Database>,
|
||||||
|
documentId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('document_locks')
|
||||||
|
.delete()
|
||||||
|
.eq('document_id', documentId)
|
||||||
|
.eq('user_id', userId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
log.error('releaseLock failed', { error, data: { documentId } });
|
||||||
|
} else {
|
||||||
|
log.info('Lock released', { data: { documentId, userId } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a heartbeat interval. Returns a cleanup function.
|
||||||
|
*/
|
||||||
|
export function startHeartbeat(
|
||||||
|
supabase: SupabaseClient<Database>,
|
||||||
|
documentId: string,
|
||||||
|
userId: string
|
||||||
|
): () => void {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
heartbeatLock(supabase, documentId, userId);
|
||||||
|
}, HEARTBEAT_INTERVAL_MS);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
import type { Database, Document } from '$lib/supabase/types';
|
import type { Database, Document } from '$lib/supabase/types';
|
||||||
|
import { createLogger } from '$lib/utils/logger';
|
||||||
|
|
||||||
export interface DocumentWithChildren extends Document {
|
const log = createLogger('api.documents');
|
||||||
children?: DocumentWithChildren[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchDocuments(
|
export async function fetchDocuments(
|
||||||
supabase: SupabaseClient<Database>,
|
supabase: SupabaseClient<Database>,
|
||||||
@@ -16,7 +15,11 @@ export async function fetchDocuments(
|
|||||||
.order('type', { ascending: false }) // folders first
|
.order('type', { ascending: false }) // folders first
|
||||||
.order('name');
|
.order('name');
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) {
|
||||||
|
log.error('fetchDocuments failed', { error, data: { orgId } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
log.debug('fetchDocuments ok', { data: { count: data?.length ?? 0 } });
|
||||||
return data ?? [];
|
return data ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +44,11 @@ export async function createDocument(
|
|||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) {
|
||||||
|
log.error('createDocument failed', { error, data: { orgId, name, type, parentId } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
log.info('createDocument ok', { data: { id: data.id, name, type } });
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +64,10 @@ export async function updateDocument(
|
|||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) {
|
||||||
|
log.error('updateDocument failed', { error, data: { id, updates } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +76,10 @@ export async function deleteDocument(
|
|||||||
id: string
|
id: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { error } = await supabase.from('documents').delete().eq('id', id);
|
const { error } = await supabase.from('documents').delete().eq('id', id);
|
||||||
if (error) throw error;
|
if (error) {
|
||||||
|
log.error('deleteDocument failed', { error, data: { id } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function moveDocument(
|
export async function moveDocument(
|
||||||
@@ -79,30 +92,12 @@ export async function moveDocument(
|
|||||||
.update({ parent_id: newParentId, updated_at: new Date().toISOString() })
|
.update({ parent_id: newParentId, updated_at: new Date().toISOString() })
|
||||||
.eq('id', id);
|
.eq('id', id);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) {
|
||||||
|
log.error('moveDocument failed', { error, data: { id, newParentId } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDocumentTree(documents: Document[]): DocumentWithChildren[] {
|
|
||||||
const map = new Map<string, DocumentWithChildren>();
|
|
||||||
const roots: DocumentWithChildren[] = [];
|
|
||||||
|
|
||||||
// First pass: create map
|
|
||||||
documents.forEach((doc) => {
|
|
||||||
map.set(doc.id, { ...doc, children: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Second pass: build tree
|
|
||||||
documents.forEach((doc) => {
|
|
||||||
const node = map.get(doc.id)!;
|
|
||||||
if (doc.parent_id && map.has(doc.parent_id)) {
|
|
||||||
map.get(doc.parent_id)!.children!.push(node);
|
|
||||||
} else {
|
|
||||||
roots.push(node);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return roots;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function subscribeToDocuments(
|
export function subscribeToDocuments(
|
||||||
supabase: SupabaseClient<Database>,
|
supabase: SupabaseClient<Database>,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
import type { Database, KanbanBoard, KanbanColumn, KanbanCard } from '$lib/supabase/types';
|
import type { Database, KanbanBoard, KanbanColumn, KanbanCard } from '$lib/supabase/types';
|
||||||
|
import { createLogger } from '$lib/utils/logger';
|
||||||
|
|
||||||
|
const log = createLogger('api.kanban');
|
||||||
|
|
||||||
export interface ColumnWithCards extends KanbanColumn {
|
export interface ColumnWithCards extends KanbanColumn {
|
||||||
cards: KanbanCard[];
|
cards: KanbanCard[];
|
||||||
@@ -19,7 +22,11 @@ export async function fetchBoards(
|
|||||||
.eq('org_id', orgId)
|
.eq('org_id', orgId)
|
||||||
.order('created_at');
|
.order('created_at');
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) {
|
||||||
|
log.error('fetchBoards failed', { error, data: { orgId } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
log.debug('fetchBoards ok', { data: { count: data?.length ?? 0 } });
|
||||||
return data ?? [];
|
return data ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +40,10 @@ export async function fetchBoardWithColumns(
|
|||||||
.eq('id', boardId)
|
.eq('id', boardId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (boardError) throw boardError;
|
if (boardError) {
|
||||||
|
log.error('fetchBoardWithColumns failed (board)', { error: boardError, data: { boardId } });
|
||||||
|
throw boardError;
|
||||||
|
}
|
||||||
if (!board) return null;
|
if (!board) return null;
|
||||||
|
|
||||||
const { data: columns, error: colError } = await supabase
|
const { data: columns, error: colError } = await supabase
|
||||||
@@ -42,22 +52,55 @@ export async function fetchBoardWithColumns(
|
|||||||
.eq('board_id', boardId)
|
.eq('board_id', boardId)
|
||||||
.order('position');
|
.order('position');
|
||||||
|
|
||||||
if (colError) throw colError;
|
if (colError) {
|
||||||
|
log.error('fetchBoardWithColumns failed (columns)', { error: colError, data: { boardId } });
|
||||||
|
throw colError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnIds = (columns ?? []).map((c) => c.id);
|
||||||
|
|
||||||
const { data: cards, error: cardError } = await supabase
|
const { data: cards, error: cardError } = await supabase
|
||||||
.from('kanban_cards')
|
.from('kanban_cards')
|
||||||
.select('*')
|
.select('*')
|
||||||
.in('column_id', (columns ?? []).map((c) => c.id))
|
.in('column_id', columnIds)
|
||||||
.order('position');
|
.order('position');
|
||||||
|
|
||||||
if (cardError) throw cardError;
|
if (cardError) {
|
||||||
|
log.error('fetchBoardWithColumns failed (cards)', { error: cardError, data: { boardId } });
|
||||||
|
throw cardError;
|
||||||
|
}
|
||||||
|
|
||||||
const cardsByColumn = new Map<string, KanbanCard[]>();
|
// Fetch tags for all cards in one query
|
||||||
|
const cardIds = (cards ?? []).map((c) => c.id);
|
||||||
|
let cardTagsMap = new Map<string, { id: string; name: string; color: string | null }[]>();
|
||||||
|
|
||||||
|
if (cardIds.length > 0) {
|
||||||
|
const { data: cardTags } = await supabase
|
||||||
|
.from('card_tags')
|
||||||
|
.select('card_id, tags:tag_id (id, name, color)')
|
||||||
|
.in('card_id', cardIds);
|
||||||
|
|
||||||
|
(cardTags ?? []).forEach((ct: any) => {
|
||||||
|
const tag = Array.isArray(ct.tags) ? ct.tags[0] : ct.tags;
|
||||||
|
if (!tag) return;
|
||||||
|
if (!cardTagsMap.has(ct.card_id)) {
|
||||||
|
cardTagsMap.set(ct.card_id, []);
|
||||||
|
}
|
||||||
|
cardTagsMap.get(ct.card_id)!.push(tag);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardsByColumn = new Map<string, (KanbanCard & { tags?: { id: string; name: string; color: string | null }[] })[]>();
|
||||||
(cards ?? []).forEach((card) => {
|
(cards ?? []).forEach((card) => {
|
||||||
if (!cardsByColumn.has(card.column_id)) {
|
const colId = card.column_id;
|
||||||
cardsByColumn.set(card.column_id, []);
|
if (!colId) return;
|
||||||
|
if (!cardsByColumn.has(colId)) {
|
||||||
|
cardsByColumn.set(colId, []);
|
||||||
}
|
}
|
||||||
cardsByColumn.get(card.column_id)!.push(card);
|
cardsByColumn.get(colId)!.push({
|
||||||
|
...card,
|
||||||
|
tags: cardTagsMap.get(card.id) ?? []
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -74,13 +117,17 @@ export async function createBoard(
|
|||||||
orgId: string,
|
orgId: string,
|
||||||
name: string
|
name: string
|
||||||
): Promise<KanbanBoard> {
|
): Promise<KanbanBoard> {
|
||||||
|
log.info('createBoard', { data: { orgId, name } });
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('kanban_boards')
|
.from('kanban_boards')
|
||||||
.insert({ org_id: orgId, name })
|
.insert({ org_id: orgId, name })
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) {
|
||||||
|
log.error('createBoard failed', { error, data: { orgId, name } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
// Create default columns
|
// Create default columns
|
||||||
const defaultColumns = ['To Do', 'In Progress', 'Done'];
|
const defaultColumns = ['To Do', 'In Progress', 'Done'];
|
||||||
@@ -101,7 +148,10 @@ export async function updateBoard(
|
|||||||
name: string
|
name: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { error } = await supabase.from('kanban_boards').update({ name }).eq('id', id);
|
const { error } = await supabase.from('kanban_boards').update({ name }).eq('id', id);
|
||||||
if (error) throw error;
|
if (error) {
|
||||||
|
log.error('updateBoard failed', { error, data: { id, name } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteBoard(
|
export async function deleteBoard(
|
||||||
@@ -109,7 +159,10 @@ export async function deleteBoard(
|
|||||||
id: string
|
id: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { error } = await supabase.from('kanban_boards').delete().eq('id', id);
|
const { error } = await supabase.from('kanban_boards').delete().eq('id', id);
|
||||||
if (error) throw error;
|
if (error) {
|
||||||
|
log.error('deleteBoard failed', { error, data: { id } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createColumn(
|
export async function createColumn(
|
||||||
@@ -124,7 +177,10 @@ export async function createColumn(
|
|||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) {
|
||||||
|
log.error('createColumn failed', { error, data: { boardId, name, position } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +190,10 @@ export async function updateColumn(
|
|||||||
updates: Partial<Pick<KanbanColumn, 'name' | 'position' | 'color'>>
|
updates: Partial<Pick<KanbanColumn, 'name' | 'position' | 'color'>>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { error } = await supabase.from('kanban_columns').update(updates).eq('id', id);
|
const { error } = await supabase.from('kanban_columns').update(updates).eq('id', id);
|
||||||
if (error) throw error;
|
if (error) {
|
||||||
|
log.error('updateColumn failed', { error, data: { id, updates } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteColumn(
|
export async function deleteColumn(
|
||||||
@@ -142,7 +201,10 @@ export async function deleteColumn(
|
|||||||
id: string
|
id: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { error } = await supabase.from('kanban_columns').delete().eq('id', id);
|
const { error } = await supabase.from('kanban_columns').delete().eq('id', id);
|
||||||
if (error) throw error;
|
if (error) {
|
||||||
|
log.error('deleteColumn failed', { error, data: { id } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createCard(
|
export async function createCard(
|
||||||
@@ -163,7 +225,10 @@ export async function createCard(
|
|||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) {
|
||||||
|
log.error('createCard failed', { error, data: { columnId, title, position } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +238,10 @@ export async function updateCard(
|
|||||||
updates: Partial<Pick<KanbanCard, 'title' | 'description' | 'column_id' | 'position' | 'due_date' | 'color'>>
|
updates: Partial<Pick<KanbanCard, 'title' | 'description' | 'column_id' | 'position' | 'due_date' | 'color'>>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { error } = await supabase.from('kanban_cards').update(updates).eq('id', id);
|
const { error } = await supabase.from('kanban_cards').update(updates).eq('id', id);
|
||||||
if (error) throw error;
|
if (error) {
|
||||||
|
log.error('updateCard failed', { error, data: { id, updates } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteCard(
|
export async function deleteCard(
|
||||||
@@ -181,7 +249,10 @@ export async function deleteCard(
|
|||||||
id: string
|
id: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { error } = await supabase.from('kanban_cards').delete().eq('id', id);
|
const { error } = await supabase.from('kanban_cards').delete().eq('id', id);
|
||||||
if (error) throw error;
|
if (error) {
|
||||||
|
log.error('deleteCard failed', { error, data: { id } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function moveCard(
|
export async function moveCard(
|
||||||
@@ -190,12 +261,48 @@ export async function moveCard(
|
|||||||
newColumnId: string,
|
newColumnId: string,
|
||||||
newPosition: number
|
newPosition: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { error } = await supabase
|
// Fetch all cards in the target column (ordered by position)
|
||||||
|
const { data: targetCards, error: fetchErr } = await supabase
|
||||||
.from('kanban_cards')
|
.from('kanban_cards')
|
||||||
.update({ column_id: newColumnId, position: newPosition })
|
.select('id, position')
|
||||||
.eq('id', cardId);
|
.eq('column_id', newColumnId)
|
||||||
|
.order('position');
|
||||||
|
|
||||||
if (error) throw error;
|
if (fetchErr) {
|
||||||
|
log.error('moveCard: failed to fetch target column cards', { error: fetchErr });
|
||||||
|
throw fetchErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the moved card from the list if it's already in this column
|
||||||
|
const otherCards = (targetCards ?? []).filter((c) => c.id !== cardId);
|
||||||
|
|
||||||
|
// Insert at the new position and reassign sequential positions
|
||||||
|
const reordered = [
|
||||||
|
...otherCards.slice(0, newPosition),
|
||||||
|
{ id: cardId },
|
||||||
|
...otherCards.slice(newPosition),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Batch update: move card to column + set position, then update siblings
|
||||||
|
const updates = reordered.map((c, i) => {
|
||||||
|
if (c.id === cardId) {
|
||||||
|
return supabase
|
||||||
|
.from('kanban_cards')
|
||||||
|
.update({ column_id: newColumnId, position: i })
|
||||||
|
.eq('id', c.id);
|
||||||
|
}
|
||||||
|
return supabase
|
||||||
|
.from('kanban_cards')
|
||||||
|
.update({ position: i })
|
||||||
|
.eq('id', c.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.all(updates);
|
||||||
|
const failed = results.find((r) => r.error);
|
||||||
|
if (failed?.error) {
|
||||||
|
log.error('moveCard failed', { error: failed.error, data: { cardId, newColumnId, newPosition } });
|
||||||
|
throw failed.error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function subscribeToBoard(
|
export function subscribeToBoard(
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
import type { Database, Organization, MemberRole } from '$lib/supabase/types';
|
import type { Database, Organization, MemberRole } from '$lib/supabase/types';
|
||||||
import type { OrgWithRole } from '$lib/stores/organizations.svelte';
|
import { createLogger } from '$lib/utils/logger';
|
||||||
|
|
||||||
|
export interface OrgWithRole extends Organization {
|
||||||
|
role: MemberRole;
|
||||||
|
memberCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const log = createLogger('api.organizations');
|
||||||
|
|
||||||
export async function fetchUserOrganizations(
|
export async function fetchUserOrganizations(
|
||||||
supabase: SupabaseClient<Database>
|
supabase: SupabaseClient<Database>
|
||||||
@@ -20,7 +27,10 @@ export async function fetchUserOrganizations(
|
|||||||
`)
|
`)
|
||||||
.not('joined_at', 'is', null);
|
.not('joined_at', 'is', null);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) {
|
||||||
|
log.error('fetchUserOrganizations failed', { error });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
return (data ?? [])
|
return (data ?? [])
|
||||||
.filter((item) => item.organizations)
|
.filter((item) => item.organizations)
|
||||||
@@ -35,13 +45,17 @@ export async function createOrganization(
|
|||||||
name: string,
|
name: string,
|
||||||
slug: string
|
slug: string
|
||||||
): Promise<Organization> {
|
): Promise<Organization> {
|
||||||
|
log.info('createOrganization', { data: { name, slug } });
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('organizations')
|
.from('organizations')
|
||||||
.insert({ name, slug })
|
.insert({ name, slug })
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) {
|
||||||
|
log.error('createOrganization failed', { error, data: { name, slug } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +71,10 @@ export async function updateOrganization(
|
|||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) {
|
||||||
|
log.error('updateOrganization failed', { error, data: { id, updates } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +83,10 @@ export async function deleteOrganization(
|
|||||||
id: string
|
id: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { error } = await supabase.from('organizations').delete().eq('id', id);
|
const { error } = await supabase.from('organizations').delete().eq('id', id);
|
||||||
if (error) throw error;
|
if (error) {
|
||||||
|
log.error('deleteOrganization failed', { error, data: { id } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchOrgMembers(
|
export async function fetchOrgMembers(
|
||||||
@@ -90,7 +110,10 @@ export async function fetchOrgMembers(
|
|||||||
`)
|
`)
|
||||||
.eq('org_id', orgId);
|
.eq('org_id', orgId);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) {
|
||||||
|
log.error('fetchOrgMembers failed', { error, data: { orgId } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
return data ?? [];
|
return data ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +131,7 @@ export async function inviteMember(
|
|||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (profileError || !profile) {
|
if (profileError || !profile) {
|
||||||
|
log.warn('inviteMember: user not found', { data: { email } });
|
||||||
throw new Error('User not found. They need to sign up first.');
|
throw new Error('User not found. They need to sign up first.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +144,7 @@ export async function inviteMember(
|
|||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
log.warn('inviteMember: already a member', { data: { email, orgId } });
|
||||||
throw new Error('User is already a member of this organization.');
|
throw new Error('User is already a member of this organization.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +156,10 @@ export async function inviteMember(
|
|||||||
joined_at: new Date().toISOString() // Auto-join for now
|
joined_at: new Date().toISOString() // Auto-join for now
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) {
|
||||||
|
log.error('inviteMember failed', { error, data: { orgId, email, role } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateMemberRole(
|
export async function updateMemberRole(
|
||||||
@@ -144,7 +172,10 @@ export async function updateMemberRole(
|
|||||||
.update({ role })
|
.update({ role })
|
||||||
.eq('id', memberId);
|
.eq('id', memberId);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) {
|
||||||
|
log.error('updateMemberRole failed', { error, data: { memberId, role } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeMember(
|
export async function removeMember(
|
||||||
@@ -152,7 +183,10 @@ export async function removeMember(
|
|||||||
memberId: string
|
memberId: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { error } = await supabase.from('org_members').delete().eq('id', memberId);
|
const { error } = await supabase.from('org_members').delete().eq('id', memberId);
|
||||||
if (error) throw error;
|
if (error) {
|
||||||
|
log.error('removeMember failed', { error, data: { memberId } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateSlug(name: string): string {
|
export function generateSlug(name: string): string {
|
||||||
|
|||||||
@@ -22,31 +22,20 @@
|
|||||||
let currentView = $state<ViewType>(initialView);
|
let currentView = $state<ViewType>(initialView);
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
|
|
||||||
const weekDays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
const weekDayHeaders = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||||
|
|
||||||
const days = $derived(
|
const days = $derived(
|
||||||
getMonthDays(currentDate.getFullYear(), currentDate.getMonth()),
|
getMonthDays(currentDate.getFullYear(), currentDate.getMonth()),
|
||||||
);
|
);
|
||||||
|
|
||||||
function prevMonth() {
|
// Group days into weeks (rows of 7)
|
||||||
currentDate = new Date(
|
const weeks = $derived.by(() => {
|
||||||
currentDate.getFullYear(),
|
const result: Date[][] = [];
|
||||||
currentDate.getMonth() - 1,
|
for (let i = 0; i < days.length; i += 7) {
|
||||||
1,
|
result.push(days.slice(i, i + 7));
|
||||||
);
|
}
|
||||||
}
|
return result;
|
||||||
|
});
|
||||||
function nextMonth() {
|
|
||||||
currentDate = new Date(
|
|
||||||
currentDate.getFullYear(),
|
|
||||||
currentDate.getMonth() + 1,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToToday() {
|
|
||||||
currentDate = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEventsForDay(date: Date): CalendarEvent[] {
|
function getEventsForDay(date: Date): CalendarEvent[] {
|
||||||
return events.filter((event) => {
|
return events.filter((event) => {
|
||||||
@@ -66,10 +55,12 @@
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get week days for week view
|
// Get week days for week view (Mon-Sun)
|
||||||
function getWeekDays(date: Date): Date[] {
|
function getWeekDays(date: Date): Date[] {
|
||||||
const startOfWeek = new Date(date);
|
const startOfWeek = new Date(date);
|
||||||
startOfWeek.setDate(date.getDate() - date.getDay());
|
const dayOfWeek = startOfWeek.getDay();
|
||||||
|
const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
|
||||||
|
startOfWeek.setDate(date.getDate() + mondayOffset);
|
||||||
return Array.from({ length: 7 }, (_, i) => {
|
return Array.from({ length: 7 }, (_, i) => {
|
||||||
const d = new Date(startOfWeek);
|
const d = new Date(startOfWeek);
|
||||||
d.setDate(startOfWeek.getDate() + i);
|
d.setDate(startOfWeek.getDate() + i);
|
||||||
@@ -79,7 +70,6 @@
|
|||||||
|
|
||||||
const weekDates = $derived(getWeekDays(currentDate));
|
const weekDates = $derived(getWeekDays(currentDate));
|
||||||
|
|
||||||
// Navigation functions for different views
|
|
||||||
function prev() {
|
function prev() {
|
||||||
if (currentView === "month") {
|
if (currentView === "month") {
|
||||||
currentDate = new Date(
|
currentDate = new Date(
|
||||||
@@ -112,7 +102,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const headerTitle = $derived(() => {
|
function goToToday() {
|
||||||
|
currentDate = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerTitle = $derived.by(() => {
|
||||||
if (currentView === "day") {
|
if (currentView === "day") {
|
||||||
return currentDate.toLocaleDateString("en-US", {
|
return currentDate.toLocaleDateString("en-US", {
|
||||||
weekday: "long",
|
weekday: "long",
|
||||||
@@ -129,207 +123,200 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="bg-surface rounded-xl p-4">
|
<div class="flex flex-col h-full gap-2">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<!-- Navigation bar -->
|
||||||
<h2 class="text-xl font-semibold text-light">{headerTitle()}</h2>
|
<div class="flex items-center justify-between px-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<!-- View Switcher -->
|
|
||||||
<div class="flex bg-dark rounded-lg p-0.5">
|
|
||||||
<button
|
|
||||||
class="px-3 py-1 text-sm rounded-md transition-colors {currentView ===
|
|
||||||
'day'
|
|
||||||
? 'bg-primary text-white'
|
|
||||||
: 'text-light/60 hover:text-light'}"
|
|
||||||
onclick={() => (currentView = "day")}
|
|
||||||
>
|
|
||||||
Day
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-3 py-1 text-sm rounded-md transition-colors {currentView ===
|
|
||||||
'week'
|
|
||||||
? 'bg-primary text-white'
|
|
||||||
: 'text-light/60 hover:text-light'}"
|
|
||||||
onclick={() => (currentView = "week")}
|
|
||||||
>
|
|
||||||
Week
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-3 py-1 text-sm rounded-md transition-colors {currentView ===
|
|
||||||
'month'
|
|
||||||
? 'bg-primary text-white'
|
|
||||||
: 'text-light/60 hover:text-light'}"
|
|
||||||
onclick={() => (currentView = "month")}
|
|
||||||
>
|
|
||||||
Month
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
class="px-3 py-1.5 text-sm text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
|
class="p-1 text-light/60 hover:text-light hover:bg-dark rounded-lg transition-colors"
|
||||||
|
onclick={prev}
|
||||||
|
aria-label="Previous"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded"
|
||||||
|
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||||
|
>chevron_left</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
class="font-heading text-h4 text-white min-w-[200px] text-center"
|
||||||
|
>{headerTitle}</span
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="p-1 text-light/60 hover:text-light hover:bg-dark rounded-lg transition-colors"
|
||||||
|
onclick={next}
|
||||||
|
aria-label="Next"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded"
|
||||||
|
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||||
|
>chevron_right</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-3 py-1 text-body-md font-body text-light/60 hover:text-white hover:bg-dark rounded-[32px] transition-colors ml-2"
|
||||||
onclick={goToToday}
|
onclick={goToToday}
|
||||||
>
|
>
|
||||||
Today
|
Today
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex bg-dark rounded-[32px] p-0.5">
|
||||||
<button
|
<button
|
||||||
class="p-2 text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
|
class="px-3 py-1 text-body-md font-body rounded-[32px] transition-colors {currentView ===
|
||||||
onclick={prev}
|
'day'
|
||||||
aria-label="Previous"
|
? 'bg-primary text-night'
|
||||||
|
: 'text-light/60 hover:text-light'}"
|
||||||
|
onclick={() => (currentView = "day")}>Day</button
|
||||||
>
|
>
|
||||||
<svg
|
|
||||||
class="w-5 h-5"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path d="m15 18-6-6 6-6" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
class="p-2 text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
|
class="px-3 py-1 text-body-md font-body rounded-[32px] transition-colors {currentView ===
|
||||||
onclick={next}
|
'week'
|
||||||
aria-label="Next"
|
? 'bg-primary text-night'
|
||||||
|
: 'text-light/60 hover:text-light'}"
|
||||||
|
onclick={() => (currentView = "week")}>Week</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="px-3 py-1 text-body-md font-body rounded-[32px] transition-colors {currentView ===
|
||||||
|
'month'
|
||||||
|
? 'bg-primary text-night'
|
||||||
|
: 'text-light/60 hover:text-light'}"
|
||||||
|
onclick={() => (currentView = "month")}>Month</button
|
||||||
>
|
>
|
||||||
<svg
|
|
||||||
class="w-5 h-5"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path d="m9 18 6-6-6-6" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Month View -->
|
<!-- Month View -->
|
||||||
{#if currentView === "month"}
|
{#if currentView === "month"}
|
||||||
<div
|
<div
|
||||||
class="grid grid-cols-7 gap-px bg-light/10 rounded-lg overflow-hidden"
|
class="flex flex-col flex-1 gap-2 min-h-0 bg-background rounded-xl p-2"
|
||||||
>
|
>
|
||||||
{#each weekDays as day}
|
<!-- Day Headers -->
|
||||||
<div
|
<div class="grid grid-cols-7 gap-2">
|
||||||
class="bg-dark px-2 py-2 text-center text-sm font-medium text-light/50"
|
{#each weekDayHeaders as day}
|
||||||
>
|
<div class="flex items-center justify-center py-2 px-2">
|
||||||
{day}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#each days as day}
|
|
||||||
{@const dayEvents = getEventsForDay(day)}
|
|
||||||
{@const isToday = isSameDay(day, today)}
|
|
||||||
{@const inMonth = isCurrentMonth(day)}
|
|
||||||
<button
|
|
||||||
class="bg-dark min-h-[80px] p-1 text-left transition-colors hover:bg-light/5"
|
|
||||||
class:opacity-40={!inMonth}
|
|
||||||
onclick={() => onDateClick?.(day)}
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-center w-7 h-7 mb-1">
|
|
||||||
<span
|
<span
|
||||||
class="text-sm {isToday
|
class="font-heading text-h4 text-white text-center"
|
||||||
? 'bg-primary text-white rounded-full w-7 h-7 flex items-center justify-center'
|
>{day}</span
|
||||||
: 'text-light/80'}"
|
|
||||||
>
|
>
|
||||||
{day.getDate()}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-0.5">
|
{/each}
|
||||||
{#each dayEvents.slice(0, 3) as event}
|
</div>
|
||||||
<button
|
|
||||||
class="w-full text-xs px-1 py-0.5 rounded truncate text-left"
|
<!-- Calendar Grid -->
|
||||||
style="background-color: {event.color ??
|
<div class="flex-1 flex flex-col gap-2 min-h-0">
|
||||||
'#6366f1'}20; color: {event.color ??
|
{#each weeks as week}
|
||||||
'#6366f1'}"
|
<div class="grid grid-cols-7 gap-2 flex-1">
|
||||||
onclick={(e) => {
|
{#each week as day}
|
||||||
e.stopPropagation();
|
{@const dayEvents = getEventsForDay(day)}
|
||||||
onEventClick?.(event);
|
{@const isToday = isSameDay(day, today)}
|
||||||
}}
|
{@const inMonth = isCurrentMonth(day)}
|
||||||
|
<div
|
||||||
|
class="bg-night rounded-none flex flex-col items-start px-4 py-5 overflow-hidden transition-colors hover:bg-dark/50 min-h-0 cursor-pointer
|
||||||
|
{!inMonth ? 'opacity-50' : ''}"
|
||||||
|
onclick={() => onDateClick?.(day)}
|
||||||
>
|
>
|
||||||
{event.title}
|
<span
|
||||||
</button>
|
class="font-body text-body text-white {isToday
|
||||||
|
? 'text-primary font-bold'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
{day.getDate()}
|
||||||
|
</span>
|
||||||
|
{#each dayEvents.slice(0, 2) as event}
|
||||||
|
<button
|
||||||
|
class="w-full mt-1 px-2 py-0.5 rounded-[4px] text-body-sm font-bold font-body text-night truncate text-left"
|
||||||
|
style="background-color: {event.color ??
|
||||||
|
'#00A3E0'}"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEventClick?.(event);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{event.title}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#if dayEvents.length > 2}
|
||||||
|
<span
|
||||||
|
class="text-body-sm text-light/40 mt-0.5"
|
||||||
|
>+{dayEvents.length - 2} more</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{#if dayEvents.length > 3}
|
|
||||||
<p class="text-xs text-light/40 px-1">
|
|
||||||
+{dayEvents.length - 3} more
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
{/each}
|
||||||
{/each}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Week View -->
|
<!-- Week View -->
|
||||||
{#if currentView === "week"}
|
{#if currentView === "week"}
|
||||||
<div
|
<div
|
||||||
class="grid grid-cols-7 gap-px bg-light/10 rounded-lg overflow-hidden"
|
class="flex flex-col flex-1 gap-2 min-h-0 bg-background rounded-xl p-2"
|
||||||
>
|
>
|
||||||
{#each weekDates as day}
|
<div class="grid grid-cols-7 gap-2 flex-1">
|
||||||
{@const dayEvents = getEventsForDay(day)}
|
{#each weekDates as day}
|
||||||
{@const isToday = isSameDay(day, today)}
|
{@const dayEvents = getEventsForDay(day)}
|
||||||
<div class="bg-dark">
|
{@const isToday = isSameDay(day, today)}
|
||||||
<div class="px-2 py-2 text-center border-b border-light/10">
|
<div class="flex flex-col overflow-hidden">
|
||||||
<div class="text-xs text-light/50">
|
<div class="px-4 py-3 text-center">
|
||||||
{weekDays[day.getDay()]}
|
<div
|
||||||
</div>
|
class="font-heading text-h4 {isToday
|
||||||
<div
|
? 'text-primary'
|
||||||
class="text-lg font-medium {isToday
|
: 'text-white'}"
|
||||||
? 'text-primary'
|
|
||||||
: 'text-light'}"
|
|
||||||
>
|
|
||||||
{day.getDate()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="min-h-[300px] p-1 space-y-1">
|
|
||||||
{#each dayEvents as event}
|
|
||||||
<button
|
|
||||||
class="w-full text-xs px-2 py-1.5 rounded text-left"
|
|
||||||
style="background-color: {event.color ??
|
|
||||||
'#6366f1'}20; color: {event.color ??
|
|
||||||
'#6366f1'}"
|
|
||||||
onclick={() => onEventClick?.(event)}
|
|
||||||
>
|
>
|
||||||
<div class="font-medium truncate">
|
{weekDayHeaders[(day.getDay() + 6) % 7]}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="font-body text-body-md {isToday
|
||||||
|
? 'text-primary'
|
||||||
|
: 'text-light/60'}"
|
||||||
|
>
|
||||||
|
{day.getDate()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 px-2 pb-2 space-y-1 overflow-y-auto">
|
||||||
|
{#each dayEvents as event}
|
||||||
|
<button
|
||||||
|
class="w-full px-2 py-1.5 rounded-[4px] text-body-sm font-bold font-body text-night truncate text-left"
|
||||||
|
style="background-color: {event.color ??
|
||||||
|
'#00A3E0'}"
|
||||||
|
onclick={() => onEventClick?.(event)}
|
||||||
|
>
|
||||||
{event.title}
|
{event.title}
|
||||||
</div>
|
</button>
|
||||||
<div class="text-[10px] opacity-70">
|
{/each}
|
||||||
{new Date(
|
</div>
|
||||||
event.start_time,
|
|
||||||
).toLocaleTimeString("en-US", {
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/each}
|
||||||
{/each}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Day View -->
|
<!-- Day View -->
|
||||||
{#if currentView === "day"}
|
{#if currentView === "day"}
|
||||||
{@const dayEvents = getEventsForDay(currentDate)}
|
{@const dayEvents = getEventsForDay(currentDate)}
|
||||||
<div class="bg-dark rounded-lg p-4 min-h-[400px]">
|
<div class="flex-1 bg-night px-4 py-5 min-h-0 overflow-auto">
|
||||||
{#if dayEvents.length === 0}
|
{#if dayEvents.length === 0}
|
||||||
<div class="text-center text-light/40 py-12">
|
<div class="text-center text-light/40 py-12">
|
||||||
<p>No events for this day</p>
|
<p class="font-body text-body">No events for this day</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
{#each dayEvents as event}
|
{#each dayEvents as event}
|
||||||
<button
|
<button
|
||||||
class="w-full text-left p-3 rounded-lg transition-colors hover:opacity-80"
|
class="w-full text-left p-3 rounded-[8px] transition-colors hover:opacity-80"
|
||||||
style="background-color: {event.color ??
|
style="background-color: {event.color ??
|
||||||
'#6366f1'}20; border-left: 3px solid {event.color ??
|
'#00A3E0'}20; border-left: 3px solid {event.color ??
|
||||||
'#6366f1'}"
|
'#00A3E0'}"
|
||||||
onclick={() => onEventClick?.(event)}
|
onclick={() => onEventClick?.(event)}
|
||||||
>
|
>
|
||||||
<div class="font-medium text-light">
|
<div class="font-heading text-h5 text-white">
|
||||||
{event.title}
|
{event.title}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-light/60 mt-1">
|
<div
|
||||||
|
class="font-body text-body-md text-light/60 mt-1"
|
||||||
|
>
|
||||||
{new Date(event.start_time).toLocaleTimeString(
|
{new Date(event.start_time).toLocaleTimeString(
|
||||||
"en-US",
|
"en-US",
|
||||||
{ hour: "numeric", minute: "2-digit" },
|
{ hour: "numeric", minute: "2-digit" },
|
||||||
@@ -340,7 +327,9 @@
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{#if event.description}
|
{#if event.description}
|
||||||
<div class="text-sm text-light/50 mt-2">
|
<div
|
||||||
|
class="font-body text-body-md text-light/50 mt-2"
|
||||||
|
>
|
||||||
{event.description}
|
{event.description}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
105
src/lib/components/documents/DocumentViewer.svelte
Normal file
105
src/lib/components/documents/DocumentViewer.svelte
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { Button } from "$lib/components/ui";
|
||||||
|
import { Editor } from "$lib/components/documents";
|
||||||
|
import type { Document, Json } from "$lib/supabase/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
document: Document;
|
||||||
|
onSave?: (content: Json) => void;
|
||||||
|
/** "preview" = read-only with Edit button that navigates to editUrl. "edit" = editable inline. */
|
||||||
|
mode?: "preview" | "edit";
|
||||||
|
/** URL to navigate to when clicking "+ Edit" in preview mode */
|
||||||
|
editUrl?: string;
|
||||||
|
/** Whether the document is locked by another user */
|
||||||
|
locked?: boolean;
|
||||||
|
/** Name of the user who holds the lock */
|
||||||
|
lockedByName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
document,
|
||||||
|
onSave,
|
||||||
|
mode = "preview",
|
||||||
|
editUrl,
|
||||||
|
locked = false,
|
||||||
|
lockedByName = null,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let isEditing = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
isEditing = mode === "edit" && !locked;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleEditClick() {
|
||||||
|
if (locked) return;
|
||||||
|
if (mode === "preview" && editUrl) {
|
||||||
|
goto(editUrl);
|
||||||
|
} else {
|
||||||
|
isEditing = !isEditing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="bg-night rounded-[32px] overflow-hidden flex flex-col min-w-0 h-full"
|
||||||
|
>
|
||||||
|
<!-- Lock Banner -->
|
||||||
|
{#if locked}
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 px-4 py-2.5 bg-warning/10 border-b border-warning/20"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded text-warning"
|
||||||
|
style="font-size: 20px; font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
|
||||||
|
>
|
||||||
|
lock
|
||||||
|
</span>
|
||||||
|
<span class="text-body-sm text-warning">
|
||||||
|
{lockedByName || "Someone"} is currently editing this document. View-only
|
||||||
|
mode.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="flex items-center gap-2 px-4 py-5">
|
||||||
|
<h2 class="flex-1 font-heading text-h1 text-white truncate">
|
||||||
|
{document.name}
|
||||||
|
</h2>
|
||||||
|
{#if locked}
|
||||||
|
<Button size="md" disabled>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded mr-1"
|
||||||
|
style="font-size: 16px; font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
|
||||||
|
>lock</span
|
||||||
|
>
|
||||||
|
Locked
|
||||||
|
</Button>
|
||||||
|
{:else if mode === "edit"}
|
||||||
|
<Button size="md" onclick={handleEditClick}>
|
||||||
|
{isEditing ? "Preview" : "Edit"}
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<Button size="md" onclick={handleEditClick}>Edit</Button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="p-1 hover:bg-dark rounded-lg transition-colors"
|
||||||
|
aria-label="More options"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded text-light"
|
||||||
|
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||||
|
>
|
||||||
|
more_horiz
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Editor Area -->
|
||||||
|
<div class="flex-1 bg-background rounded-[32px] mx-4 mb-4 overflow-auto">
|
||||||
|
<Editor {document} {onSave} editable={isEditing} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -3,15 +3,15 @@
|
|||||||
import { Editor } from "@tiptap/core";
|
import { Editor } from "@tiptap/core";
|
||||||
import StarterKit from "@tiptap/starter-kit";
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
import Placeholder from "@tiptap/extension-placeholder";
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
import type { Document } from "$lib/supabase/types";
|
import type { Document, Json } from "$lib/supabase/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
document?: Document | null;
|
document?: Document | null;
|
||||||
content?: object | null;
|
content?: object | null;
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
onUpdate?: (content: object) => void;
|
onUpdate?: (content: Json) => void;
|
||||||
onSave?: (content: object) => void;
|
onSave?: (content: Json) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
let element: HTMLDivElement;
|
let element: HTMLDivElement;
|
||||||
let editor: Editor | null = $state(null);
|
let editor: Editor | null = $state(null);
|
||||||
let saveStatus = $state<"idle" | "saving" | "saved" | "error">("idle");
|
let saveStatus = $state<"idle" | "saving" | "saved" | "error">("idle");
|
||||||
|
let isMounted = $state(true);
|
||||||
|
|
||||||
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
let statusTimeout: ReturnType<typeof setTimeout> | null = null;
|
let statusTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -37,24 +38,25 @@
|
|||||||
if (saveTimeout) clearTimeout(saveTimeout);
|
if (saveTimeout) clearTimeout(saveTimeout);
|
||||||
saveStatus = "idle";
|
saveStatus = "idle";
|
||||||
saveTimeout = setTimeout(async () => {
|
saveTimeout = setTimeout(async () => {
|
||||||
await saveNow();
|
if (isMounted) await saveNow();
|
||||||
}, 1000); // Auto-save after 1 second of inactivity
|
}, 1000); // Auto-save after 1 second of inactivity
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveNow() {
|
async function saveNow() {
|
||||||
if (editor && onSave) {
|
if (!isMounted || !editor || !onSave) return;
|
||||||
saveStatus = "saving";
|
|
||||||
try {
|
saveStatus = "saving";
|
||||||
await onSave(editor.getJSON());
|
try {
|
||||||
saveStatus = "saved";
|
await onSave(editor.getJSON());
|
||||||
// Reset status after 2 seconds
|
if (!isMounted) return; // Guard after async
|
||||||
if (statusTimeout) clearTimeout(statusTimeout);
|
saveStatus = "saved";
|
||||||
statusTimeout = setTimeout(() => {
|
// Reset status after 2 seconds
|
||||||
saveStatus = "idle";
|
if (statusTimeout) clearTimeout(statusTimeout);
|
||||||
}, 2000);
|
statusTimeout = setTimeout(() => {
|
||||||
} catch {
|
if (isMounted) saveStatus = "idle";
|
||||||
saveStatus = "error";
|
}, 2000);
|
||||||
}
|
} catch {
|
||||||
|
if (isMounted) saveStatus = "error";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +73,7 @@
|
|||||||
},
|
},
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
class: "prose prose-invert max-w-none focus:outline-none min-h-[200px] p-4",
|
class: "prose prose-invert max-w-3xl mx-auto focus:outline-none min-h-[200px] p-4",
|
||||||
},
|
},
|
||||||
handleKeyDown: (view, event) => {
|
handleKeyDown: (view, event) => {
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key === "s") {
|
if ((event.ctrlKey || event.metaKey) && event.key === "s") {
|
||||||
@@ -86,6 +88,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
isMounted = false;
|
||||||
if (saveTimeout) clearTimeout(saveTimeout);
|
if (saveTimeout) clearTimeout(saveTimeout);
|
||||||
if (statusTimeout) clearTimeout(statusTimeout);
|
if (statusTimeout) clearTimeout(statusTimeout);
|
||||||
editor?.destroy();
|
editor?.destroy();
|
||||||
@@ -124,11 +127,9 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="bg-surface rounded-xl border border-light/10 overflow-hidden">
|
<div class="bg-background rounded-xl overflow-hidden">
|
||||||
{#if editable}
|
{#if editable}
|
||||||
<div
|
<div class="flex items-center gap-1 px-2 py-1.5 bg-background">
|
||||||
class="flex items-center gap-1 px-2 py-1.5 border-b border-light/10 bg-dark/50"
|
|
||||||
>
|
|
||||||
<!-- Save Button -->
|
<!-- Save Button -->
|
||||||
<button
|
<button
|
||||||
class="flex items-center gap-1.5 px-2 py-1 mr-2 text-xs rounded hover:bg-light/10 transition-colors {saveStatus ===
|
class="flex items-center gap-1.5 px-2 py-1 mr-2 text-xs rounded hover:bg-light/10 transition-colors {saveStatus ===
|
||||||
@@ -346,7 +347,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div bind:this={element}></div>
|
<div class="border-none" bind:this={element}></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
874
src/lib/components/documents/FileBrowser.svelte
Normal file
874
src/lib/components/documents/FileBrowser.svelte
Normal file
@@ -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 Editor } from './Editor.svelte';
|
||||||
|
export { default as DocumentViewer } from './DocumentViewer.svelte';
|
||||||
|
export { default as FileBrowser } from './FileBrowser.svelte';
|
||||||
|
|||||||
170
src/lib/components/kanban/CardChecklist.svelte
Normal file
170
src/lib/components/kanban/CardChecklist.svelte
Normal file
@@ -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>
|
||||||
159
src/lib/components/kanban/CardComments.svelte
Normal file
159
src/lib/components/kanban/CardComments.svelte
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getContext, onDestroy } from "svelte";
|
||||||
|
import { Button, Input, Icon, Avatar } from "$lib/components/ui";
|
||||||
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
|
import type { Database } from "$lib/supabase/types";
|
||||||
|
|
||||||
|
interface Comment {
|
||||||
|
id: string;
|
||||||
|
card_id: string;
|
||||||
|
user_id: string;
|
||||||
|
content: string;
|
||||||
|
created_at: string;
|
||||||
|
profiles?: { full_name: string | null; email: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cardId: string;
|
||||||
|
userId: string;
|
||||||
|
comments: Comment[];
|
||||||
|
onCommentsChange: (comments: Comment[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { cardId, userId, comments, onCommentsChange }: Props = $props();
|
||||||
|
|
||||||
|
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
||||||
|
|
||||||
|
let isMounted = $state(true);
|
||||||
|
let newComment = $state("");
|
||||||
|
let isAdding = $state(false);
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
isMounted = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddComment() {
|
||||||
|
if (!newComment.trim() || !isMounted) return;
|
||||||
|
isAdding = true;
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("kanban_comments")
|
||||||
|
.insert({
|
||||||
|
card_id: cardId,
|
||||||
|
user_id: userId,
|
||||||
|
content: newComment.trim(),
|
||||||
|
})
|
||||||
|
.select(
|
||||||
|
`
|
||||||
|
id,
|
||||||
|
card_id,
|
||||||
|
user_id,
|
||||||
|
content,
|
||||||
|
created_at,
|
||||||
|
profiles:user_id (full_name, email)
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
if (!error && data) {
|
||||||
|
onCommentsChange([...comments, data as Comment]);
|
||||||
|
newComment = "";
|
||||||
|
}
|
||||||
|
isAdding = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteComment(commentId: string) {
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("kanban_comments")
|
||||||
|
.delete()
|
||||||
|
.eq("id", commentId);
|
||||||
|
|
||||||
|
if (!error && isMounted) {
|
||||||
|
onCommentsChange(comments.filter((c) => c.id !== commentId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<span class="px-3 font-bold font-body text-body text-white">Comments</span>
|
||||||
|
|
||||||
|
<!-- Comment list -->
|
||||||
|
{#if comments.length > 0}
|
||||||
|
<div class="space-y-3 max-h-48 overflow-y-auto">
|
||||||
|
{#each comments as comment (comment.id)}
|
||||||
|
<div class="flex gap-2 group">
|
||||||
|
<Avatar
|
||||||
|
name={comment.profiles?.full_name ||
|
||||||
|
comment.profiles?.email ||
|
||||||
|
"?"}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="text-sm font-medium text-light truncate"
|
||||||
|
>
|
||||||
|
{comment.profiles?.full_name ||
|
||||||
|
comment.profiles?.email ||
|
||||||
|
"Unknown"}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-light/40">
|
||||||
|
{formatDate(comment.created_at)}
|
||||||
|
</span>
|
||||||
|
{#if comment.user_id === userId}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="opacity-0 group-hover:opacity-100 p-0.5 text-light/40 hover:text-error transition-all ml-auto"
|
||||||
|
onclick={() => deleteComment(comment.id)}
|
||||||
|
aria-label="Delete comment"
|
||||||
|
>
|
||||||
|
<Icon name="close" size={12} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-light/70 break-words">
|
||||||
|
{comment.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-light/40 text-center py-2">No comments yet</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Add comment form -->
|
||||||
|
<form
|
||||||
|
class="flex gap-2 items-end"
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAddComment();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="Add a comment..."
|
||||||
|
bind:value={newComment}
|
||||||
|
disabled={isAdding}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="md"
|
||||||
|
disabled={!newComment.trim() || isAdding}
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
@@ -1,10 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext } from "svelte";
|
import { getContext, onDestroy } from "svelte";
|
||||||
import { Modal, Button, Input, Textarea } from "$lib/components/ui";
|
import {
|
||||||
|
Modal,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Textarea,
|
||||||
|
Select,
|
||||||
|
AssigneePicker,
|
||||||
|
Icon,
|
||||||
|
} from "$lib/components/ui";
|
||||||
import type { KanbanCard } from "$lib/supabase/types";
|
import type { KanbanCard } from "$lib/supabase/types";
|
||||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
import type { Database } from "$lib/supabase/types";
|
import type { Database } from "$lib/supabase/types";
|
||||||
|
|
||||||
|
let isMounted = $state(true);
|
||||||
|
onDestroy(() => {
|
||||||
|
isMounted = false;
|
||||||
|
});
|
||||||
|
|
||||||
interface ChecklistItem {
|
interface ChecklistItem {
|
||||||
id: string;
|
id: string;
|
||||||
card_id: string;
|
card_id: string;
|
||||||
@@ -33,6 +46,12 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface OrgTag {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
card: KanbanCard | null;
|
card: KanbanCard | null;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -42,6 +61,7 @@
|
|||||||
mode?: "edit" | "create";
|
mode?: "edit" | "create";
|
||||||
columnId?: string;
|
columnId?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
orgId?: string;
|
||||||
onCreate?: (card: KanbanCard) => void;
|
onCreate?: (card: KanbanCard) => void;
|
||||||
members?: Member[];
|
members?: Member[];
|
||||||
}
|
}
|
||||||
@@ -55,6 +75,7 @@
|
|||||||
mode = "edit",
|
mode = "edit",
|
||||||
columnId,
|
columnId,
|
||||||
userId,
|
userId,
|
||||||
|
orgId,
|
||||||
onCreate,
|
onCreate,
|
||||||
members = [],
|
members = [],
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
@@ -74,20 +95,35 @@
|
|||||||
let isSaving = $state(false);
|
let isSaving = $state(false);
|
||||||
let showAssigneePicker = $state(false);
|
let showAssigneePicker = $state(false);
|
||||||
|
|
||||||
|
// Tags state
|
||||||
|
let orgTags = $state<OrgTag[]>([]);
|
||||||
|
let cardTagIds = $state<Set<string>>(new Set());
|
||||||
|
let newTagName = $state("");
|
||||||
|
let showTagInput = $state(false);
|
||||||
|
|
||||||
|
const TAG_COLORS = [
|
||||||
|
"#00A3E0",
|
||||||
|
"#33E000",
|
||||||
|
"#E03D00",
|
||||||
|
"#FFAB00",
|
||||||
|
"#A855F7",
|
||||||
|
"#EC4899",
|
||||||
|
"#6366F1",
|
||||||
|
];
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
if (mode === "edit" && card) {
|
if (mode === "edit" && card) {
|
||||||
title = card.title;
|
title = card.title;
|
||||||
description = card.description ?? "";
|
description = card.description ?? "";
|
||||||
assigneeId = (card as any).assignee_id ?? null;
|
assigneeId = card.assignee_id ?? null;
|
||||||
dueDate = (card as any).due_date
|
dueDate = card.due_date
|
||||||
? new Date((card as any).due_date)
|
? new Date(card.due_date).toISOString().split("T")[0]
|
||||||
.toISOString()
|
|
||||||
.split("T")[0]
|
|
||||||
: "";
|
: "";
|
||||||
priority = (card as any).priority ?? "medium";
|
priority = card.priority ?? "medium";
|
||||||
loadChecklist();
|
loadChecklist();
|
||||||
loadComments();
|
loadComments();
|
||||||
|
loadTags();
|
||||||
} else if (mode === "create") {
|
} else if (mode === "create") {
|
||||||
title = "";
|
title = "";
|
||||||
description = "";
|
description = "";
|
||||||
@@ -96,12 +132,14 @@
|
|||||||
priority = "medium";
|
priority = "medium";
|
||||||
checklist = [];
|
checklist = [];
|
||||||
comments = [];
|
comments = [];
|
||||||
|
cardTagIds = new Set();
|
||||||
|
loadOrgTags();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadChecklist() {
|
async function loadChecklist() {
|
||||||
if (!card) return;
|
if (!card || !isMounted) return;
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
|
|
||||||
const { data } = await supabase
|
const { data } = await supabase
|
||||||
@@ -110,12 +148,13 @@
|
|||||||
.eq("card_id", card.id)
|
.eq("card_id", card.id)
|
||||||
.order("position");
|
.order("position");
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
checklist = (data ?? []) as ChecklistItem[];
|
checklist = (data ?? []) as ChecklistItem[];
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadComments() {
|
async function loadComments() {
|
||||||
if (!card) return;
|
if (!card || !isMounted) return;
|
||||||
|
|
||||||
const { data } = await supabase
|
const { data } = await supabase
|
||||||
.from("kanban_comments")
|
.from("kanban_comments")
|
||||||
@@ -132,10 +171,75 @@
|
|||||||
.eq("card_id", card.id)
|
.eq("card_id", card.id)
|
||||||
.order("created_at", { ascending: true });
|
.order("created_at", { ascending: true });
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
comments = (data ?? []) as Comment[];
|
comments = (data ?? []) as Comment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadOrgTags() {
|
||||||
|
if (!orgId) return;
|
||||||
|
const { data } = await supabase
|
||||||
|
.from("tags")
|
||||||
|
.select("id, name, color")
|
||||||
|
.eq("org_id", orgId)
|
||||||
|
.order("name");
|
||||||
|
if (!isMounted) return;
|
||||||
|
orgTags = (data ?? []) as OrgTag[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTags() {
|
||||||
|
await loadOrgTags();
|
||||||
|
if (!card) return;
|
||||||
|
const { data } = await supabase
|
||||||
|
.from("card_tags")
|
||||||
|
.select("tag_id")
|
||||||
|
.eq("card_id", card.id);
|
||||||
|
if (!isMounted) return;
|
||||||
|
cardTagIds = new Set((data ?? []).map((t) => t.tag_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleTag(tagId: string) {
|
||||||
|
if (!card) return;
|
||||||
|
if (cardTagIds.has(tagId)) {
|
||||||
|
await supabase
|
||||||
|
.from("card_tags")
|
||||||
|
.delete()
|
||||||
|
.eq("card_id", card.id)
|
||||||
|
.eq("tag_id", tagId);
|
||||||
|
cardTagIds.delete(tagId);
|
||||||
|
cardTagIds = new Set(cardTagIds);
|
||||||
|
} else {
|
||||||
|
await supabase
|
||||||
|
.from("card_tags")
|
||||||
|
.insert({ card_id: card.id, tag_id: tagId });
|
||||||
|
cardTagIds.add(tagId);
|
||||||
|
cardTagIds = new Set(cardTagIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTag() {
|
||||||
|
if (!newTagName.trim() || !orgId) return;
|
||||||
|
const color = TAG_COLORS[orgTags.length % TAG_COLORS.length];
|
||||||
|
const { data: newTag, error } = await supabase
|
||||||
|
.from("tags")
|
||||||
|
.insert({ name: newTagName.trim(), org_id: orgId, color })
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
if (!error && newTag) {
|
||||||
|
orgTags = [...orgTags, newTag as OrgTag];
|
||||||
|
if (card) {
|
||||||
|
await supabase
|
||||||
|
.from("card_tags")
|
||||||
|
.insert({ card_id: card.id, tag_id: newTag.id });
|
||||||
|
cardTagIds.add(newTag.id);
|
||||||
|
cardTagIds = new Set(cardTagIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newTagName = "";
|
||||||
|
showTagInput = false;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
|
if (!isMounted) return;
|
||||||
if (mode === "create") {
|
if (mode === "create") {
|
||||||
await handleCreate();
|
await handleCreate();
|
||||||
return;
|
return;
|
||||||
@@ -178,7 +282,7 @@
|
|||||||
.eq("id", columnId)
|
.eq("id", columnId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
const position = (column as any)?.cards?.[0]?.count ?? 0;
|
const position = (column as any)?.cards?.[0]?.count ?? 0; // join aggregation not typed
|
||||||
|
|
||||||
const { data: newCard, error } = await supabase
|
const { data: newCard, error } = await supabase
|
||||||
.from("kanban_cards")
|
.from("kanban_cards")
|
||||||
@@ -186,6 +290,9 @@
|
|||||||
column_id: columnId,
|
column_id: columnId,
|
||||||
title,
|
title,
|
||||||
description: description || null,
|
description: description || null,
|
||||||
|
priority: priority || null,
|
||||||
|
due_date: dueDate || null,
|
||||||
|
assignee_id: assigneeId || null,
|
||||||
position,
|
position,
|
||||||
created_by: userId,
|
created_by: userId,
|
||||||
})
|
})
|
||||||
@@ -320,133 +427,97 @@
|
|||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Assignee, Due Date, Priority Row -->
|
<!-- Tags -->
|
||||||
<div class="grid grid-cols-3 gap-4">
|
<div>
|
||||||
<!-- Assignee -->
|
<span
|
||||||
<div class="relative">
|
class="px-3 font-bold font-body text-body text-white mb-2 block"
|
||||||
<label class="block text-sm font-medium text-light mb-1"
|
>Tags</span
|
||||||
>Assignee</label
|
>
|
||||||
>
|
<div class="flex flex-wrap gap-2 items-center">
|
||||||
<button
|
{#each orgTags as tag}
|
||||||
type="button"
|
<button
|
||||||
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-left text-sm flex items-center gap-2 hover:border-light/40 transition-colors"
|
type="button"
|
||||||
onclick={() =>
|
class="rounded-[4px] px-2 py-1 font-body font-bold text-[13px] leading-none transition-all border-2"
|
||||||
(showAssigneePicker = !showAssigneePicker)}
|
style="background-color: {cardTagIds.has(tag.id)
|
||||||
>
|
? tag.color || '#00A3E0'
|
||||||
{#if assigneeId && getAssignee(assigneeId)}
|
: 'transparent'}; color: {cardTagIds.has(tag.id)
|
||||||
{@const assignee = getAssignee(assigneeId)}
|
? '#0A121F'
|
||||||
<div
|
: tag.color ||
|
||||||
class="w-6 h-6 rounded-full bg-primary/20 flex items-center justify-center text-xs text-primary"
|
'#00A3E0'}; border-color: {tag.color ||
|
||||||
>
|
'#00A3E0'};"
|
||||||
{(assignee?.profiles.full_name ||
|
onclick={() => toggleTag(tag.id)}
|
||||||
assignee?.profiles.email ||
|
|
||||||
"?")[0].toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<span class="text-light truncate"
|
|
||||||
>{assignee?.profiles.full_name ||
|
|
||||||
assignee?.profiles.email}</span
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
<div
|
|
||||||
class="w-6 h-6 rounded-full bg-light/10 flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-3 h-3 text-light/40"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"
|
|
||||||
/>
|
|
||||||
<circle cx="12" cy="7" r="4" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<span class="text-light/40">Unassigned</span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{#if showAssigneePicker}
|
|
||||||
<div
|
|
||||||
class="absolute top-full left-0 right-0 mt-1 bg-dark border border-light/20 rounded-lg shadow-lg z-10 max-h-48 overflow-y-auto"
|
|
||||||
>
|
>
|
||||||
|
{tag.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#if showTagInput}
|
||||||
|
<div class="flex gap-1 items-center">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="bg-dark border border-light/20 rounded-lg px-2 py-1 text-sm text-white w-24 focus:outline-none focus:border-primary"
|
||||||
|
placeholder="Tag name"
|
||||||
|
bind:value={newTagName}
|
||||||
|
onkeydown={(e) =>
|
||||||
|
e.key === "Enter" && createTag()}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
class="w-full px-3 py-2 text-left text-sm text-light/60 hover:bg-light/5 flex items-center gap-2"
|
type="button"
|
||||||
|
class="text-primary text-sm font-bold hover:text-primary/80"
|
||||||
|
onclick={createTag}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-light/40 text-sm hover:text-light"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
assigneeId = null;
|
showTagInput = false;
|
||||||
showAssigneePicker = false;
|
newTagName = "";
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
Cancel
|
||||||
class="w-6 h-6 rounded-full bg-light/10"
|
|
||||||
></div>
|
|
||||||
Unassigned
|
|
||||||
</button>
|
</button>
|
||||||
{#each members as member}
|
|
||||||
<button
|
|
||||||
class="w-full px-3 py-2 text-left text-sm hover:bg-light/5 flex items-center gap-2 {assigneeId ===
|
|
||||||
member.user_id
|
|
||||||
? 'bg-primary/10 text-primary'
|
|
||||||
: 'text-light'}"
|
|
||||||
onclick={() => {
|
|
||||||
assigneeId = member.user_id;
|
|
||||||
showAssigneePicker = false;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-6 h-6 rounded-full bg-primary/20 flex items-center justify-center text-xs"
|
|
||||||
>
|
|
||||||
{(member.profiles.full_name ||
|
|
||||||
member.profiles.email ||
|
|
||||||
"?")[0].toUpperCase()}
|
|
||||||
</div>
|
|
||||||
{member.profiles.full_name ||
|
|
||||||
member.profiles.email}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg px-2 py-1 text-sm text-light/50 hover:text-light border border-dashed border-light/20 hover:border-light/40 transition-colors"
|
||||||
|
onclick={() => (showTagInput = true)}
|
||||||
|
>
|
||||||
|
+ New tag
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Due Date -->
|
<!-- Assignee, Due Date, Priority Row -->
|
||||||
<div>
|
<div class="grid grid-cols-3 gap-4">
|
||||||
<label
|
<AssigneePicker
|
||||||
for="due-date"
|
label="Assignee"
|
||||||
class="block text-sm font-medium text-light mb-1"
|
value={assigneeId}
|
||||||
>Due Date</label
|
{members}
|
||||||
>
|
onchange={(id) => (assigneeId = id)}
|
||||||
<input
|
/>
|
||||||
id="due-date"
|
|
||||||
type="date"
|
|
||||||
bind:value={dueDate}
|
|
||||||
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-sm text-light focus:outline-none focus:border-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Priority -->
|
<Input type="date" label="Due Date" bind:value={dueDate} />
|
||||||
<div>
|
|
||||||
<label
|
<Select
|
||||||
for="priority"
|
label="Priority"
|
||||||
class="block text-sm font-medium text-light mb-1"
|
bind:value={priority}
|
||||||
>Priority</label
|
placeholder=""
|
||||||
>
|
options={[
|
||||||
<select
|
{ value: "low", label: "Low" },
|
||||||
id="priority"
|
{ value: "medium", label: "Medium" },
|
||||||
bind:value={priority}
|
{ value: "high", label: "High" },
|
||||||
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-sm text-light focus:outline-none focus:border-primary"
|
{ value: "urgent", label: "Urgent" },
|
||||||
>
|
]}
|
||||||
<option value="low">Low</option>
|
/>
|
||||||
<option value="medium">Medium</option>
|
|
||||||
<option value="high">High</option>
|
|
||||||
<option value="urgent">Urgent</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<label class="text-sm font-medium text-light"
|
<span class="px-3 font-bold font-body text-body text-white"
|
||||||
>Checklist</label
|
>Checklist</span
|
||||||
>
|
>
|
||||||
{#if checklist.length > 0}
|
{#if checklist.length > 0}
|
||||||
<span class="text-xs text-light/50"
|
<span class="text-xs text-light/50"
|
||||||
@@ -499,36 +570,26 @@
|
|||||||
{item.title}
|
{item.title}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
class="opacity-0 group-hover:opacity-100 p-1 text-light/40 hover:text-error transition-all"
|
class="opacity-0 group-hover:opacity-100 p-1 text-light/40 hover:text-error transition-all"
|
||||||
onclick={() => deleteItem(item.id)}
|
onclick={() => deleteItem(item.id)}
|
||||||
aria-label="Delete item"
|
aria-label="Delete item"
|
||||||
>
|
>
|
||||||
<svg
|
<Icon name="close" size={16} />
|
||||||
class="w-4 h-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2 items-end">
|
||||||
<input
|
<Input
|
||||||
type="text"
|
|
||||||
class="flex-1 px-3 py-2 bg-dark border border-light/20 rounded-lg text-sm text-light placeholder:text-light/40 focus:outline-none focus:border-primary"
|
|
||||||
placeholder="Add an item..."
|
placeholder="Add an item..."
|
||||||
bind:value={newItemTitle}
|
bind:value={newItemTitle}
|
||||||
onkeydown={(e) =>
|
onkeydown={(e) =>
|
||||||
e.key === "Enter" && handleAddItem()}
|
e.key === "Enter" && handleAddItem()}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="md"
|
||||||
onclick={handleAddItem}
|
onclick={handleAddItem}
|
||||||
disabled={!newItemTitle.trim()}
|
disabled={!newItemTitle.trim()}
|
||||||
>
|
>
|
||||||
@@ -541,8 +602,9 @@
|
|||||||
<!-- Comments Section -->
|
<!-- Comments Section -->
|
||||||
{#if mode === "edit"}
|
{#if mode === "edit"}
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-light mb-3"
|
<span
|
||||||
>Comments</label
|
class="px-3 font-bold font-body text-body text-white mb-3 block"
|
||||||
|
>Comments</span
|
||||||
>
|
>
|
||||||
<div class="space-y-3 mb-3 max-h-48 overflow-y-auto">
|
<div class="space-y-3 mb-3 max-h-48 overflow-y-auto">
|
||||||
{#each comments as comment}
|
{#each comments as comment}
|
||||||
@@ -550,8 +612,8 @@
|
|||||||
<div
|
<div
|
||||||
class="w-8 h-8 rounded-full bg-primary/20 flex-shrink-0 flex items-center justify-center text-xs text-primary"
|
class="w-8 h-8 rounded-full bg-primary/20 flex-shrink-0 flex items-center justify-center text-xs text-primary"
|
||||||
>
|
>
|
||||||
{((comment.profiles as any)?.full_name ||
|
{(comment.profiles?.full_name ||
|
||||||
(comment.profiles as any)?.email ||
|
comment.profiles?.email ||
|
||||||
"?")[0].toUpperCase()}
|
"?")[0].toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
@@ -559,10 +621,8 @@
|
|||||||
<span
|
<span
|
||||||
class="text-sm font-medium text-light"
|
class="text-sm font-medium text-light"
|
||||||
>
|
>
|
||||||
{(comment.profiles as any)
|
{comment.profiles?.full_name ||
|
||||||
?.full_name ||
|
comment.profiles?.email ||
|
||||||
(comment.profiles as any)
|
|
||||||
?.email ||
|
|
||||||
"Unknown"}
|
"Unknown"}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-xs text-light/40"
|
<span class="text-xs text-light/40"
|
||||||
@@ -583,17 +643,15 @@
|
|||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2 items-end">
|
||||||
<input
|
<Input
|
||||||
type="text"
|
|
||||||
class="flex-1 px-3 py-2 bg-dark border border-light/20 rounded-lg text-sm text-light placeholder:text-light/40 focus:outline-none focus:border-primary"
|
|
||||||
placeholder="Add a comment..."
|
placeholder="Add a comment..."
|
||||||
bind:value={newComment}
|
bind:value={newComment}
|
||||||
onkeydown={(e) =>
|
onkeydown={(e) =>
|
||||||
e.key === "Enter" && handleAddComment()}
|
e.key === "Enter" && handleAddComment()}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="md"
|
||||||
onclick={handleAddComment}
|
onclick={handleAddComment}
|
||||||
disabled={!newComment.trim()}
|
disabled={!newComment.trim()}
|
||||||
>
|
>
|
||||||
@@ -614,7 +672,7 @@
|
|||||||
<div></div>
|
<div></div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button variant="ghost" onclick={onClose}>Cancel</Button>
|
<Button variant="tertiary" onclick={onClose}>Cancel</Button>
|
||||||
<Button
|
<Button
|
||||||
onclick={handleSave}
|
onclick={handleSave}
|
||||||
loading={isSaving}
|
loading={isSaving}
|
||||||
|
|||||||
90
src/lib/components/kanban/CardMetadata.svelte
Normal file
90
src/lib/components/kanban/CardMetadata.svelte
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Input, Select, AssigneePicker, Badge } from "$lib/components/ui";
|
||||||
|
|
||||||
|
interface Member {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
profiles: {
|
||||||
|
id: string;
|
||||||
|
full_name: string | null;
|
||||||
|
email: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
assigneeId: string | null;
|
||||||
|
dueDate: string;
|
||||||
|
priority: string;
|
||||||
|
members: Member[];
|
||||||
|
onAssigneeChange: (id: string | null) => void;
|
||||||
|
onDueDateChange: (date: string) => void;
|
||||||
|
onPriorityChange: (priority: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
assigneeId,
|
||||||
|
dueDate,
|
||||||
|
priority,
|
||||||
|
members,
|
||||||
|
onAssigneeChange,
|
||||||
|
onDueDateChange,
|
||||||
|
onPriorityChange,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let dueDateLocal = $state("");
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
dueDateLocal = dueDate;
|
||||||
|
});
|
||||||
|
|
||||||
|
const priorityColors: Record<string, string> = {
|
||||||
|
low: "bg-green-500/20 text-green-400",
|
||||||
|
medium: "bg-yellow-500/20 text-yellow-400",
|
||||||
|
high: "bg-orange-500/20 text-orange-400",
|
||||||
|
urgent: "bg-red-500/20 text-red-400",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-3 gap-3">
|
||||||
|
<AssigneePicker
|
||||||
|
label="Assignee"
|
||||||
|
value={assigneeId}
|
||||||
|
{members}
|
||||||
|
onchange={onAssigneeChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
label="Due Date"
|
||||||
|
bind:value={dueDateLocal}
|
||||||
|
onchange={() => onDueDateChange(dueDateLocal)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Priority"
|
||||||
|
value={priority}
|
||||||
|
placeholder=""
|
||||||
|
options={[
|
||||||
|
{ value: "low", label: "Low" },
|
||||||
|
{ value: "medium", label: "Medium" },
|
||||||
|
{ value: "high", label: "High" },
|
||||||
|
{ value: "urgent", label: "Urgent" },
|
||||||
|
]}
|
||||||
|
onchange={(e) =>
|
||||||
|
onPriorityChange((e.target as HTMLSelectElement).value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Priority indicator pill -->
|
||||||
|
{#if priority && priority !== "medium"}
|
||||||
|
<div class="mt-2">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium {priorityColors[
|
||||||
|
priority
|
||||||
|
] || priorityColors.medium}"
|
||||||
|
>
|
||||||
|
{priority.charAt(0).toUpperCase() + priority.slice(1)} Priority
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ColumnWithCards } from "$lib/api/kanban";
|
import type { ColumnWithCards } from "$lib/api/kanban";
|
||||||
import type { KanbanCard } from "$lib/supabase/types";
|
import type { KanbanCard } from "$lib/supabase/types";
|
||||||
import { Button, Card, Badge } from "$lib/components/ui";
|
import KanbanCardComponent from "./KanbanCard.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
columns: ColumnWithCards[];
|
columns: ColumnWithCards[];
|
||||||
@@ -29,15 +29,11 @@
|
|||||||
canEdit = true,
|
canEdit = true,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
function handleDeleteCard(e: MouseEvent, cardId: string) {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (confirm("Are you sure you want to delete this task?")) {
|
|
||||||
onDeleteCard?.(cardId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let draggedCard = $state<KanbanCard | null>(null);
|
let draggedCard = $state<KanbanCard | null>(null);
|
||||||
let dragOverColumn = $state<string | null>(null);
|
let dragOverColumn = $state<string | null>(null);
|
||||||
|
let dragOverCardIndex = $state<{ columnId: string; index: number } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
function handleDragStart(e: DragEvent, card: KanbanCard) {
|
function handleDragStart(e: DragEvent, card: KanbanCard) {
|
||||||
draggedCard = card;
|
draggedCard = card;
|
||||||
@@ -47,272 +43,193 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDragOver(e: DragEvent, columnId: string) {
|
function handleColumnDragOver(e: DragEvent, columnId: string) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dragOverColumn = columnId;
|
dragOverColumn = columnId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDragLeave() {
|
function handleColumnDragLeave() {
|
||||||
dragOverColumn = null;
|
dragOverColumn = null;
|
||||||
|
dragOverCardIndex = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCardDragOver(e: DragEvent, columnId: string, index: number) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!draggedCard) return;
|
||||||
|
|
||||||
|
// Determine if we're in the top or bottom half of the card
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
const midY = rect.top + rect.height / 2;
|
||||||
|
const dropIndex = e.clientY < midY ? index : index + 1;
|
||||||
|
|
||||||
|
dragOverColumn = columnId;
|
||||||
|
dragOverCardIndex = { columnId, index: dropIndex };
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDrop(e: DragEvent, columnId: string) {
|
function handleDrop(e: DragEvent, columnId: string) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
const targetIndex = dragOverCardIndex;
|
||||||
dragOverColumn = null;
|
dragOverColumn = null;
|
||||||
|
dragOverCardIndex = null;
|
||||||
|
|
||||||
if (draggedCard && draggedCard.column_id !== columnId) {
|
if (!draggedCard) return;
|
||||||
const column = columns.find((c) => c.id === columnId);
|
|
||||||
const newPosition = column?.cards.length ?? 0;
|
const column = columns.find((c) => c.id === columnId);
|
||||||
onCardMove?.(draggedCard.id, columnId, newPosition);
|
if (!column) {
|
||||||
|
draggedCard = null;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let newPosition: number;
|
||||||
|
if (targetIndex && targetIndex.columnId === columnId) {
|
||||||
|
newPosition = targetIndex.index;
|
||||||
|
// If moving within the same column and the card is above the target, adjust
|
||||||
|
if (draggedCard.column_id === columnId) {
|
||||||
|
const currentIndex = column.cards.findIndex(
|
||||||
|
(c) => c.id === draggedCard!.id,
|
||||||
|
);
|
||||||
|
if (currentIndex !== -1 && currentIndex < newPosition) {
|
||||||
|
newPosition = Math.max(0, newPosition - 1);
|
||||||
|
}
|
||||||
|
// No-op if dropping in the same position
|
||||||
|
if (currentIndex === newPosition) {
|
||||||
|
draggedCard = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newPosition = column.cards.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
onCardMove?.(draggedCard.id, columnId, newPosition);
|
||||||
draggedCard = null;
|
draggedCard = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDueDate(dateStr: string | null): string {
|
|
||||||
if (!dateStr) return "";
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
const now = new Date();
|
|
||||||
const diff = date.getTime() - now.getTime();
|
|
||||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
if (days < 0) return "Overdue";
|
|
||||||
if (days === 0) return "Today";
|
|
||||||
if (days === 1) return "Tomorrow";
|
|
||||||
return date.toLocaleDateString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDueDateColor(
|
|
||||||
dateStr: string | null,
|
|
||||||
): "error" | "warning" | "default" {
|
|
||||||
if (!dateStr) return "default";
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
const now = new Date();
|
|
||||||
const diff = date.getTime() - now.getTime();
|
|
||||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
if (days < 0) return "error";
|
|
||||||
if (days <= 2) return "warning";
|
|
||||||
return "default";
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex gap-4 overflow-x-auto pb-4 min-h-[500px] scrollbar-visible">
|
<div class="flex gap-2 overflow-x-auto pb-4 h-full kanban-scroll">
|
||||||
{#each columns as column}
|
{#each columns as column}
|
||||||
<div
|
<div
|
||||||
class="flex-shrink-0 w-72 bg-surface/80 backdrop-blur-sm rounded-xl p-3 flex flex-col max-h-[calc(100vh-200px)] border border-light/10 shadow-lg {dragOverColumn ===
|
class="flex-shrink-0 w-[256px] bg-background rounded-[32px] px-4 py-5 flex flex-col gap-4 max-h-full {dragOverColumn ===
|
||||||
column.id
|
column.id
|
||||||
? 'ring-2 ring-primary bg-primary/5'
|
? 'ring-2 ring-primary'
|
||||||
: ''}"
|
: ''}"
|
||||||
ondragover={(e) => handleDragOver(e, column.id)}
|
ondragover={(e) => handleColumnDragOver(e, column.id)}
|
||||||
ondragleave={handleDragLeave}
|
ondragleave={handleColumnDragLeave}
|
||||||
ondrop={(e) => handleDrop(e, column.id)}
|
ondrop={(e) => handleDrop(e, column.id)}
|
||||||
role="list"
|
role="list"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between mb-3 px-1">
|
<!-- Column Header -->
|
||||||
<h3 class="font-medium text-light flex items-center gap-2">
|
<div class="flex items-center gap-2 p-1 rounded-[32px]">
|
||||||
{column.name}
|
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||||
<span
|
<h3 class="font-heading text-h4 text-white truncate">
|
||||||
class="text-xs text-light/50 bg-light/10 px-1.5 py-0.5 rounded"
|
{column.name}
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
class="bg-dark flex items-center justify-center px-1.5 py-0.5 rounded-[8px] shrink-0"
|
||||||
>
|
>
|
||||||
{column.cards.length}
|
<span class="font-heading text-h6 text-white"
|
||||||
|
>{column.cards.length}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="p-1 hover:bg-night rounded-lg transition-colors shrink-0"
|
||||||
|
onclick={() => onDeleteColumn?.(column.id)}
|
||||||
|
aria-label="Column options"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded text-light/50"
|
||||||
|
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||||
|
>
|
||||||
|
more_horiz
|
||||||
</span>
|
</span>
|
||||||
</h3>
|
</button>
|
||||||
<div class="flex items-center gap-1">
|
</div>
|
||||||
{#if column.color}
|
|
||||||
|
<!-- Cards -->
|
||||||
|
<div class="flex-1 overflow-y-auto flex flex-col gap-0">
|
||||||
|
{#each column.cards as card, cardIndex}
|
||||||
|
<!-- Drop indicator before card -->
|
||||||
|
{#if draggedCard && dragOverCardIndex?.columnId === column.id && dragOverCardIndex?.index === cardIndex && draggedCard.id !== card.id}
|
||||||
<div
|
<div
|
||||||
class="w-3 h-3 rounded-full"
|
class="h-1 bg-primary rounded-full mx-2 my-1 transition-all"
|
||||||
style="background-color: {column.color}"
|
|
||||||
></div>
|
></div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if canEdit}
|
|
||||||
<button
|
|
||||||
class="p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-error/20 text-light/40 hover:text-error transition-all"
|
|
||||||
onclick={() => onDeleteColumn?.(column.id)}
|
|
||||||
title="Delete column"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-3.5 h-3.5"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<polyline points="3,6 5,6 21,6" />
|
|
||||||
<path
|
|
||||||
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto space-y-2">
|
|
||||||
{#each column.cards as card}
|
|
||||||
<div
|
<div
|
||||||
class="group bg-dark rounded-lg p-3 cursor-pointer hover:ring-1 hover:ring-light/20 transition-all relative"
|
class="mb-2"
|
||||||
class:opacity-50={draggedCard?.id === card.id}
|
ondragover={(e) =>
|
||||||
draggable={canEdit}
|
handleCardDragOver(e, column.id, cardIndex)}
|
||||||
ondragstart={(e) => handleDragStart(e, card)}
|
|
||||||
onclick={() => onCardClick?.(card)}
|
|
||||||
onkeydown={(e) =>
|
|
||||||
e.key === "Enter" && onCardClick?.(card)}
|
|
||||||
role="listitem"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
>
|
||||||
{#if canEdit}
|
<KanbanCardComponent
|
||||||
<button
|
{card}
|
||||||
class="absolute top-2 right-2 p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-error/20 text-light/40 hover:text-error transition-all"
|
isDragging={draggedCard?.id === card.id}
|
||||||
onclick={(e) => handleDeleteCard(e, card.id)}
|
draggable={canEdit}
|
||||||
title="Delete task"
|
ondragstart={(e) => handleDragStart(e, card)}
|
||||||
>
|
onclick={() => onCardClick?.(card)}
|
||||||
<svg
|
ondelete={canEdit
|
||||||
class="w-3.5 h-3.5"
|
? (id) => onDeleteCard?.(id)
|
||||||
viewBox="0 0 24 24"
|
: undefined}
|
||||||
fill="none"
|
/>
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<polyline points="3,6 5,6 21,6" />
|
|
||||||
<path
|
|
||||||
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if card.color}
|
|
||||||
<div
|
|
||||||
class="w-full h-1 rounded-full mb-2"
|
|
||||||
style="background-color: {card.color}"
|
|
||||||
></div>
|
|
||||||
{/if}
|
|
||||||
<p class="text-sm text-light pr-6">{card.title}</p>
|
|
||||||
{#if card.description}
|
|
||||||
<p class="text-xs text-light/50 mt-1 line-clamp-2">
|
|
||||||
{card.description}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
{#if card.due_date || (card as any).checklist_total > 0 || (card as any).assignee_id}
|
|
||||||
<div class="mt-2 flex items-center gap-2 flex-wrap">
|
|
||||||
{#if card.due_date}
|
|
||||||
<Badge
|
|
||||||
size="sm"
|
|
||||||
variant={getDueDateColor(card.due_date)}
|
|
||||||
>
|
|
||||||
{formatDueDate(card.due_date)}
|
|
||||||
</Badge>
|
|
||||||
{/if}
|
|
||||||
{#if (card as any).checklist_total > 0}
|
|
||||||
<span
|
|
||||||
class="text-xs flex items-center gap-1 {(
|
|
||||||
card as any
|
|
||||||
).checklist_done ===
|
|
||||||
(card as any).checklist_total
|
|
||||||
? 'text-success'
|
|
||||||
: 'text-light/50'}"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-3 h-3"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<polyline
|
|
||||||
points="9,11 12,14 22,4"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{(card as any).checklist_done}/{(
|
|
||||||
card as any
|
|
||||||
).checklist_total}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{#if (card as any).assignee_id}
|
|
||||||
<div
|
|
||||||
class="w-5 h-5 rounded-full bg-primary/30 flex items-center justify-center text-[10px] text-primary ml-auto"
|
|
||||||
title="Assigned"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-3 h-3"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"
|
|
||||||
/>
|
|
||||||
<circle cx="12" cy="7" r="4" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
<!-- Drop indicator at end of column -->
|
||||||
|
{#if draggedCard && dragOverCardIndex?.columnId === column.id && dragOverCardIndex?.index === column.cards.length}
|
||||||
|
<div
|
||||||
|
class="h-1 bg-primary rounded-full mx-2 my-1 transition-all"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Card Button (secondary style) -->
|
||||||
{#if canEdit}
|
{#if canEdit}
|
||||||
<button
|
<button
|
||||||
class="mt-2 w-full py-2 text-sm text-light/50 hover:text-light hover:bg-light/5 rounded-lg transition-colors flex items-center justify-center gap-1"
|
type="button"
|
||||||
|
class="w-full py-3 border-[3px] border-primary text-primary font-heading text-h5 rounded-[32px] hover:bg-primary/10 transition-colors"
|
||||||
onclick={() => onAddCard?.(column.id)}
|
onclick={() => onAddCard?.(column.id)}
|
||||||
>
|
>
|
||||||
<svg
|
|
||||||
class="w-4 h-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<line x1="12" y1="5" x2="12" y2="19" />
|
|
||||||
<line x1="5" y1="12" x2="19" y2="12" />
|
|
||||||
</svg>
|
|
||||||
Add card
|
Add card
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
<!-- Add Column Button -->
|
||||||
{#if canEdit}
|
{#if canEdit}
|
||||||
<button
|
<button
|
||||||
class="flex-shrink-0 w-72 h-12 bg-light/5 hover:bg-light/10 rounded-xl flex items-center justify-center gap-2 text-light/50 hover:text-light transition-colors"
|
type="button"
|
||||||
|
class="flex-shrink-0 w-[256px] h-12 border-[3px] border-primary/30 hover:border-primary rounded-[32px] flex items-center justify-center gap-2 text-primary/50 hover:text-primary transition-colors"
|
||||||
onclick={() => onAddColumn?.()}
|
onclick={() => onAddColumn?.()}
|
||||||
>
|
>
|
||||||
<svg
|
<span
|
||||||
class="w-5 h-5"
|
class="material-symbols-rounded"
|
||||||
viewBox="0 0 24 24"
|
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
>
|
||||||
<line x1="12" y1="5" x2="12" y2="19" />
|
add
|
||||||
<line x1="5" y1="12" x2="19" y2="12" />
|
</span>
|
||||||
</svg>
|
|
||||||
Add column
|
Add column
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.scrollbar-visible {
|
.kanban-scroll {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: rgba(229, 230, 240, 0.3) transparent;
|
scrollbar-color: rgba(229, 230, 240, 0.3) transparent;
|
||||||
}
|
}
|
||||||
.scrollbar-visible::-webkit-scrollbar {
|
.kanban-scroll::-webkit-scrollbar {
|
||||||
height: 8px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
.scrollbar-visible::-webkit-scrollbar-track {
|
.kanban-scroll::-webkit-scrollbar-track {
|
||||||
background: rgba(229, 230, 240, 0.1);
|
background: transparent;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
.scrollbar-visible::-webkit-scrollbar-thumb {
|
.kanban-scroll::-webkit-scrollbar-thumb {
|
||||||
background: rgba(229, 230, 240, 0.3);
|
background: rgba(229, 230, 240, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
.scrollbar-visible::-webkit-scrollbar-thumb:hover {
|
.kanban-scroll::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(229, 230, 240, 0.5);
|
background: rgba(229, 230, 240, 0.5);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { KanbanCard as KanbanCardType } from "$lib/supabase/types";
|
import type { KanbanCard as KanbanCardType } from "$lib/supabase/types";
|
||||||
import { Badge } from "$lib/components/ui";
|
import { Avatar } from "$lib/components/ui";
|
||||||
|
|
||||||
// Extended card type with optional new fields from migration
|
interface Tag {
|
||||||
interface ExtendedCard extends KanbanCardType {
|
id: string;
|
||||||
priority?: "low" | "medium" | "high" | "urgent" | null;
|
name: string;
|
||||||
assignee_id?: string | null;
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
card: ExtendedCard;
|
card: KanbanCardType & {
|
||||||
|
tags?: Tag[];
|
||||||
|
checklist_done?: number;
|
||||||
|
checklist_total?: number;
|
||||||
|
assignee_name?: string | null;
|
||||||
|
assignee_avatar?: string | null;
|
||||||
|
};
|
||||||
isDragging?: boolean;
|
isDragging?: boolean;
|
||||||
onclick?: () => void;
|
onclick?: () => void;
|
||||||
|
ondelete?: (cardId: string) => void;
|
||||||
draggable?: boolean;
|
draggable?: boolean;
|
||||||
ondragstart?: (e: DragEvent) => void;
|
ondragstart?: (e: DragEvent) => void;
|
||||||
}
|
}
|
||||||
@@ -20,114 +27,125 @@
|
|||||||
card,
|
card,
|
||||||
isDragging = false,
|
isDragging = false,
|
||||||
onclick,
|
onclick,
|
||||||
|
ondelete,
|
||||||
draggable = true,
|
draggable = true,
|
||||||
ondragstart,
|
ondragstart,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
function handleDelete(e: MouseEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (confirm("Are you sure you want to delete this card?")) {
|
||||||
|
ondelete?.(card.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatDueDate(dateStr: string | null): string {
|
function formatDueDate(dateStr: string | null): string {
|
||||||
if (!dateStr) return "";
|
if (!dateStr) return "";
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
const now = new Date();
|
return date.toLocaleDateString("en-US", {
|
||||||
const diff = date.getTime() - now.getTime();
|
month: "short",
|
||||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
day: "numeric",
|
||||||
|
});
|
||||||
if (days < 0) return "Overdue";
|
|
||||||
if (days === 0) return "Today";
|
|
||||||
if (days === 1) return "Tomorrow";
|
|
||||||
return date.toLocaleDateString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDueDateVariant(
|
const hasFooter = $derived(
|
||||||
dateStr: string | null,
|
!!card.due_date ||
|
||||||
): "error" | "warning" | "default" {
|
(card.checklist_total ?? 0) > 0 ||
|
||||||
if (!dateStr) return "default";
|
!!card.assignee_id,
|
||||||
const date = new Date(dateStr);
|
);
|
||||||
const now = new Date();
|
|
||||||
const diff = date.getTime() - now.getTime();
|
|
||||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
if (days < 0) return "error";
|
|
||||||
if (days <= 2) return "warning";
|
|
||||||
return "default";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPriorityColor(priority: string | null): string {
|
|
||||||
switch (priority) {
|
|
||||||
case "urgent":
|
|
||||||
return "#E03D00";
|
|
||||||
case "high":
|
|
||||||
return "#FFAB00";
|
|
||||||
case "medium":
|
|
||||||
return "#00A3E0";
|
|
||||||
case "low":
|
|
||||||
return "#33E000";
|
|
||||||
default:
|
|
||||||
return "#E5E6F0";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<button
|
||||||
class="bg-night rounded-[16px] p-3 cursor-pointer hover:ring-1 hover:ring-primary/30 transition-all group"
|
type="button"
|
||||||
|
class="bg-night rounded-[16px] p-2 cursor-pointer hover:ring-1 hover:ring-primary/30 transition-all group w-full text-left overflow-clip flex flex-col gap-2 relative"
|
||||||
class:opacity-50={isDragging}
|
class:opacity-50={isDragging}
|
||||||
{draggable}
|
{draggable}
|
||||||
{ondragstart}
|
{ondragstart}
|
||||||
{onclick}
|
{onclick}
|
||||||
onkeydown={(e) => e.key === "Enter" && onclick?.()}
|
|
||||||
role="listitem"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
>
|
||||||
<!-- Priority indicator -->
|
<!-- Delete button (top-right, visible on hover) -->
|
||||||
{#if card.priority}
|
{#if ondelete}
|
||||||
<div
|
<button
|
||||||
class="w-full h-1 rounded-full mb-2"
|
type="button"
|
||||||
style="background-color: {getPriorityColor(card.priority)}"
|
class="absolute top-1 right-1 p-1 rounded-lg opacity-0 group-hover:opacity-100 hover:bg-error/20 transition-all z-10"
|
||||||
></div>
|
onclick={handleDelete}
|
||||||
{:else if card.color}
|
aria-label="Delete card"
|
||||||
<div
|
>
|
||||||
class="w-full h-1 rounded-full mb-2"
|
<span
|
||||||
style="background-color: {card.color}"
|
class="material-symbols-rounded text-light/40 hover:text-error"
|
||||||
></div>
|
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
|
||||||
|
>
|
||||||
|
delete
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Tags / Chips -->
|
||||||
|
{#if card.tags && card.tags.length > 0}
|
||||||
|
<div class="flex gap-[10px] items-start flex-wrap">
|
||||||
|
{#each card.tags as tag}
|
||||||
|
<span
|
||||||
|
class="rounded-[4px] px-1 py-[4px] font-body font-bold text-[14px] text-night leading-none overflow-clip"
|
||||||
|
style="background-color: {tag.color || '#00A3E0'}"
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Title -->
|
<!-- Title -->
|
||||||
<p class="text-sm font-medium text-light">{card.title}</p>
|
<p class="font-body text-body text-white w-full leading-none">
|
||||||
|
{card.title}
|
||||||
|
</p>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Bottom row: details + avatar -->
|
||||||
{#if card.description}
|
{#if hasFooter}
|
||||||
<p class="text-xs text-light/50 mt-1 line-clamp-2">
|
<div class="flex items-center justify-between w-full">
|
||||||
{card.description}
|
<div class="flex gap-1 items-center">
|
||||||
</p>
|
<!-- Due date -->
|
||||||
{/if}
|
{#if card.due_date}
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded text-light p-1"
|
||||||
|
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||||
|
>
|
||||||
|
calendar_today
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="font-body text-[12px] text-light leading-none"
|
||||||
|
>
|
||||||
|
{formatDueDate(card.due_date)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Footer with metadata -->
|
<!-- Checklist -->
|
||||||
<div class="mt-3 flex items-center justify-between gap-2">
|
{#if (card.checklist_total ?? 0) > 0}
|
||||||
<!-- Due date -->
|
<div class="flex items-center">
|
||||||
{#if card.due_date}
|
<span
|
||||||
<Badge size="sm" variant={getDueDateVariant(card.due_date)}>
|
class="material-symbols-rounded text-light p-1"
|
||||||
<svg
|
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||||
class="w-3 h-3 mr-1"
|
>
|
||||||
viewBox="0 0 24 24"
|
check_box
|
||||||
fill="none"
|
</span>
|
||||||
stroke="currentColor"
|
<span
|
||||||
stroke-width="2"
|
class="font-body text-[12px] text-light leading-none"
|
||||||
>
|
>
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
{card.checklist_done ?? 0}/{card.checklist_total}
|
||||||
<line x1="16" y1="2" x2="16" y2="6" />
|
</span>
|
||||||
<line x1="8" y1="2" x2="8" y2="6" />
|
</div>
|
||||||
<line x1="3" y1="10" x2="21" y2="10" />
|
{/if}
|
||||||
</svg>
|
|
||||||
{formatDueDate(card.due_date)}
|
|
||||||
</Badge>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Assignee placeholder -->
|
|
||||||
{#if card.assignee_id}
|
|
||||||
<div
|
|
||||||
class="w-6 h-6 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xs font-medium"
|
|
||||||
>
|
|
||||||
A
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
</div>
|
<!-- Assignee avatar -->
|
||||||
</div>
|
{#if card.assignee_id}
|
||||||
|
<Avatar
|
||||||
|
name={card.assignee_name || "?"}
|
||||||
|
src={card.assignee_avatar}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
export { default as KanbanBoard } from './KanbanBoard.svelte';
|
export { default as KanbanBoard } from './KanbanBoard.svelte';
|
||||||
export { default as CardDetailModal } from './CardDetailModal.svelte';
|
export { default as CardDetailModal } from './CardDetailModal.svelte';
|
||||||
export { default as KanbanCard } from './KanbanCard.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';
|
||||||
|
|||||||
216
src/lib/components/settings/SettingsGeneral.svelte
Normal file
216
src/lib/components/settings/SettingsGeneral.svelte
Normal file
@@ -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>
|
||||||
1
src/lib/components/settings/index.ts
Normal file
1
src/lib/components/settings/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as SettingsGeneral } from './SettingsGeneral.svelte';
|
||||||
108
src/lib/components/ui/AssigneePicker.svelte
Normal file
108
src/lib/components/ui/AssigneePicker.svelte
Normal file
@@ -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">
|
<script lang="ts">
|
||||||
interface Props {
|
interface Props {
|
||||||
|
name: string;
|
||||||
src?: string | null;
|
src?: string | null;
|
||||||
name?: string;
|
size?: "sm" | "md" | "lg" | "xl";
|
||||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
|
||||||
status?: 'online' | 'offline' | 'away' | 'busy' | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let { src = null, name = '?', size = 'md', status = null }: Props = $props();
|
let { name, src = null, size = "md" }: Props = $props();
|
||||||
|
|
||||||
const sizeClasses = {
|
const initial = $derived(name ? name[0].toUpperCase() : "?");
|
||||||
xs: 'w-6 h-6 text-xs',
|
|
||||||
sm: 'w-8 h-8 text-sm',
|
const sizes = {
|
||||||
md: 'w-10 h-10 text-base',
|
sm: { box: "w-8 h-8", text: "text-body", radius: "rounded-[16px]" },
|
||||||
lg: 'w-12 h-12 text-lg',
|
md: { box: "w-12 h-12", text: "text-h3", radius: "rounded-[24px]" },
|
||||||
xl: 'w-16 h-16 text-xl',
|
lg: { box: "w-16 h-16", text: "text-h2", radius: "rounded-[32px]" },
|
||||||
'2xl': 'w-20 h-20 text-2xl'
|
xl: { box: "w-24 h-24", text: "text-h1", radius: "rounded-[48px]" },
|
||||||
};
|
};
|
||||||
|
|
||||||
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 statusColors = {
|
|
||||||
online: 'bg-success',
|
|
||||||
offline: 'bg-light/30',
|
|
||||||
away: 'bg-warning',
|
|
||||||
busy: 'bg-error'
|
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
</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
|
<div
|
||||||
class="rounded-full flex items-center justify-center font-medium text-white overflow-hidden {sizeClasses[
|
class="{sizes[size].box} {sizes[size]
|
||||||
size
|
.radius} bg-primary flex items-center justify-center shrink-0"
|
||||||
]} {!src ? getColorFromName(name) : 'bg-surface'}"
|
|
||||||
>
|
>
|
||||||
{#if src}
|
<span class="font-heading {sizes[size].text} text-night leading-none">
|
||||||
<img {src} alt={name} class="w-full h-full object-cover" />
|
{initial}
|
||||||
{:else}
|
</span>
|
||||||
{getInitials(name)}
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
{#if status}
|
|
||||||
<div
|
|
||||||
class="absolute bottom-0 right-0 rounded-full border-2 border-dark {statusSizes[size]} {statusColors[
|
|
||||||
status
|
|
||||||
]}"
|
|
||||||
></div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -1,30 +1,40 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
variant?: 'default' | 'primary' | 'success' | 'warning' | 'error' | 'info';
|
variant?:
|
||||||
size?: 'sm' | 'md' | 'lg';
|
| "default"
|
||||||
|
| "primary"
|
||||||
|
| "success"
|
||||||
|
| "warning"
|
||||||
|
| "error"
|
||||||
|
| "info";
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { variant = 'default', size = 'md', children }: Props = $props();
|
let { variant = "default", size = "md", children }: Props = $props();
|
||||||
|
|
||||||
const variantClasses = {
|
const variantClasses = {
|
||||||
default: 'bg-light/10 text-light',
|
default: "bg-light/10 text-light",
|
||||||
primary: 'bg-primary/20 text-primary',
|
primary: "bg-primary/20 text-primary",
|
||||||
success: 'bg-success/20 text-success',
|
success: "bg-success/20 text-success",
|
||||||
warning: 'bg-warning/20 text-warning',
|
warning: "bg-warning/20 text-warning",
|
||||||
error: 'bg-error/20 text-error',
|
error: "bg-error/20 text-error",
|
||||||
info: 'bg-info/20 text-info'
|
info: "bg-info/20 text-info",
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
sm: 'px-1.5 py-0.5 text-xs',
|
sm: "px-1.5 py-0.5 text-xs",
|
||||||
md: 'px-2 py-0.5 text-sm',
|
md: "px-2 py-0.5 text-sm",
|
||||||
lg: 'px-2.5 py-1 text-sm'
|
lg: "px-2.5 py-1 text-sm",
|
||||||
};
|
};
|
||||||
</script>
|
</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()}
|
{@render children()}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -2,14 +2,16 @@
|
|||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
variant?: "primary" | "secondary" | "ghost" | "danger" | "success";
|
variant?: "primary" | "secondary" | "tertiary" | "danger" | "success";
|
||||||
size?: "sm" | "md" | "lg";
|
size?: "sm" | "md" | "lg";
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
type?: "button" | "submit" | "reset";
|
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
|
icon?: string;
|
||||||
|
type?: "button" | "submit" | "reset";
|
||||||
onclick?: (e: MouseEvent) => void;
|
onclick?: (e: MouseEvent) => void;
|
||||||
children: Snippet;
|
children?: Snippet;
|
||||||
|
class?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -17,59 +19,100 @@
|
|||||||
size = "md",
|
size = "md",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
loading = false,
|
loading = false,
|
||||||
type = "button",
|
|
||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
|
icon,
|
||||||
|
type = "button",
|
||||||
onclick,
|
onclick,
|
||||||
children,
|
children,
|
||||||
|
class: className,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
// Figma-matched base styles
|
|
||||||
const baseClasses =
|
const baseClasses =
|
||||||
"inline-flex items-center justify-center font-bold transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-30 disabled:cursor-not-allowed rounded-[32px]";
|
"inline-flex items-center justify-center gap-2 font-heading rounded-[32px] overflow-clip transition-all cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed";
|
||||||
|
|
||||||
// Figma-matched variant styles
|
|
||||||
const variantClasses = {
|
const variantClasses = {
|
||||||
primary:
|
primary:
|
||||||
"bg-primary text-night hover:brightness-110 active:brightness-90",
|
"btn-primary bg-primary text-night hover:btn-primary-hover active:btn-primary-active",
|
||||||
secondary:
|
secondary:
|
||||||
"border-2 border-primary text-primary bg-transparent hover:bg-primary/10 active:bg-primary/20",
|
"bg-transparent text-primary border-solid border-primary hover:bg-primary/10 active:bg-primary/20",
|
||||||
ghost: "bg-primary/10 text-primary hover:bg-primary/20 active:bg-primary/30",
|
tertiary:
|
||||||
danger: "bg-error text-night hover:brightness-110 active:brightness-90",
|
"bg-primary/10 text-primary hover:bg-primary/20 active:bg-primary/30",
|
||||||
|
danger: "btn-primary bg-error text-white hover:btn-primary-hover active:btn-primary-active",
|
||||||
success:
|
success:
|
||||||
"bg-success text-night hover:brightness-110 active:brightness-90",
|
"btn-primary bg-success text-night hover:btn-primary-hover active:btn-primary-active",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Figma-matched size styles (px values from Figma)
|
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
sm: "px-3 py-1.5 text-sm gap-1.5 min-w-[96px]",
|
sm: "min-w-[36px] p-[10px] text-btn-sm",
|
||||||
md: "px-4 py-2 text-base gap-2 min-w-[128px]",
|
md: "min-w-[48px] p-[12px] text-btn-md",
|
||||||
lg: "px-5 py-3 text-xl gap-2.5 min-w-[128px]",
|
lg: "min-w-[56px] p-[16px] text-btn-lg",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const borderClasses = {
|
||||||
|
sm: "border-2",
|
||||||
|
md: "border-3",
|
||||||
|
lg: "border-4",
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondaryBorder = $derived(
|
||||||
|
variant === "secondary" ? borderClasses[size] : "",
|
||||||
|
);
|
||||||
|
|
||||||
|
const iconSize = $derived(size === "sm" ? 16 : size === "lg" ? 20 : 18);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
{type}
|
{type}
|
||||||
class="{baseClasses} {variantClasses[variant]} {sizeClasses[size]}"
|
class="{baseClasses} {variantClasses[variant]} {sizeClasses[
|
||||||
|
size
|
||||||
|
]} {secondaryBorder} {className ?? ''}"
|
||||||
class:w-full={fullWidth}
|
class:w-full={fullWidth}
|
||||||
disabled={disabled || loading}
|
disabled={disabled || loading}
|
||||||
{onclick}
|
{onclick}
|
||||||
>
|
>
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
|
<span
|
||||||
<circle
|
class="material-symbols-rounded animate-spin"
|
||||||
class="opacity-25"
|
style="font-size: {iconSize}px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' {iconSize};"
|
||||||
cx="12"
|
>
|
||||||
cy="12"
|
progress_activity
|
||||||
r="10"
|
</span>
|
||||||
stroke="currentColor"
|
{:else if icon}
|
||||||
stroke-width="4"
|
<span
|
||||||
></circle>
|
class="material-symbols-rounded"
|
||||||
<path
|
style="font-size: {iconSize}px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' {iconSize};"
|
||||||
class="opacity-75"
|
>
|
||||||
fill="currentColor"
|
{icon}
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
</span>
|
||||||
></path>
|
{/if}
|
||||||
</svg>
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
{/if}
|
{/if}
|
||||||
{@render children()}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background-image: linear-gradient(
|
||||||
|
rgba(255, 255, 255, 0.2),
|
||||||
|
rgba(255, 255, 255, 0.2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.btn-primary-hover:not(:disabled) {
|
||||||
|
background-image: linear-gradient(
|
||||||
|
rgba(255, 255, 255, 0.2),
|
||||||
|
rgba(255, 255, 255, 0.2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.btn-primary:active:not(:disabled) {
|
||||||
|
background-image: linear-gradient(
|
||||||
|
rgba(14, 15, 25, 0.2),
|
||||||
|
rgba(14, 15, 25, 0.2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.btn-primary-active:not(:disabled) {
|
||||||
|
background-image: linear-gradient(
|
||||||
|
rgba(14, 15, 25, 0.2),
|
||||||
|
rgba(14, 15, 25, 0.2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
35
src/lib/components/ui/CalendarDay.svelte
Normal file
35
src/lib/components/ui/CalendarDay.svelte
Normal file
@@ -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}
|
||||||
26
src/lib/components/ui/Chip.svelte
Normal file
26
src/lib/components/ui/Chip.svelte
Normal file
@@ -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>
|
||||||
42
src/lib/components/ui/ContentHeader.svelte
Normal file
42
src/lib/components/ui/ContentHeader.svelte
Normal file
@@ -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>
|
||||||
64
src/lib/components/ui/Dropdown.svelte
Normal file
64
src/lib/components/ui/Dropdown.svelte
Normal file
@@ -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>
|
||||||
31
src/lib/components/ui/DropdownItem.svelte
Normal file
31
src/lib/components/ui/DropdownItem.svelte
Normal file
@@ -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>
|
||||||
29
src/lib/components/ui/EmptyState.svelte
Normal file
29
src/lib/components/ui/EmptyState.svelte
Normal file
@@ -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>
|
||||||
16
src/lib/components/ui/Icon.svelte
Normal file
16
src/lib/components/ui/Icon.svelte
Normal file
@@ -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>
|
||||||
59
src/lib/components/ui/IconButton.svelte
Normal file
59
src/lib/components/ui/IconButton.svelte
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
onclick?: () => void;
|
||||||
|
variant?: 'ghost' | 'subtle' | 'solid';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
disabled?: boolean;
|
||||||
|
title?: string;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
children,
|
||||||
|
onclick,
|
||||||
|
variant = 'ghost',
|
||||||
|
size = 'md',
|
||||||
|
disabled = false,
|
||||||
|
title,
|
||||||
|
class: className = '',
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const variantClasses = {
|
||||||
|
ghost: 'hover:bg-light/10 text-light/60 hover:text-light',
|
||||||
|
subtle: 'bg-light/5 hover:bg-light/10 text-light/60 hover:text-light',
|
||||||
|
solid: 'bg-primary/20 hover:bg-primary/30 text-primary',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'w-7 h-7',
|
||||||
|
md: 'w-9 h-9',
|
||||||
|
lg: 'w-11 h-11',
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconSizeClasses = {
|
||||||
|
sm: '[&>svg]:w-4 [&>svg]:h-4',
|
||||||
|
md: '[&>svg]:w-5 [&>svg]:h-5',
|
||||||
|
lg: '[&>svg]:w-6 [&>svg]:h-6',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
{onclick}
|
||||||
|
{disabled}
|
||||||
|
{title}
|
||||||
|
aria-label={title}
|
||||||
|
class="
|
||||||
|
inline-flex items-center justify-center rounded-lg transition-colors
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
{variantClasses[variant]}
|
||||||
|
{sizeClasses[size]}
|
||||||
|
{iconSizeClasses[size]}
|
||||||
|
{className}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{@render children()}
|
||||||
|
</button>
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
interface Props {
|
interface Props {
|
||||||
type?: "text" | "password" | "email" | "url" | "search" | "number";
|
type?:
|
||||||
|
| "text"
|
||||||
|
| "password"
|
||||||
|
| "email"
|
||||||
|
| "url"
|
||||||
|
| "search"
|
||||||
|
| "number"
|
||||||
|
| "tel"
|
||||||
|
| "date"
|
||||||
|
| "datetime-local";
|
||||||
value?: string;
|
value?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -9,7 +18,9 @@
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
autocomplete?: AutoFill;
|
autocomplete?: AutoFill;
|
||||||
|
icon?: string;
|
||||||
oninput?: (e: Event) => void;
|
oninput?: (e: Event) => void;
|
||||||
|
onchange?: (e: Event) => void;
|
||||||
onkeydown?: (e: KeyboardEvent) => void;
|
onkeydown?: (e: KeyboardEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,7 +34,9 @@
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
required = false,
|
required = false,
|
||||||
autocomplete,
|
autocomplete,
|
||||||
|
icon,
|
||||||
oninput,
|
oninput,
|
||||||
|
onchange,
|
||||||
onkeydown,
|
onkeydown,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
@@ -33,67 +46,72 @@
|
|||||||
const inputType = $derived(isPassword && showPassword ? "text" : type);
|
const inputType = $derived(isPassword && showPassword ? "text" : type);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3 w-full">
|
||||||
{#if label}
|
{#if label}
|
||||||
<label for={inputId} class="px-3 font-heading text-xl text-white">
|
<label
|
||||||
|
for={inputId}
|
||||||
|
class="px-3 font-bold font-body text-body text-white"
|
||||||
|
>
|
||||||
{#if required}<span class="text-error">* </span>{/if}{label}
|
{#if required}<span class="text-error">* </span>{/if}{label}
|
||||||
</label>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="relative">
|
<div class="flex items-center gap-3 w-full">
|
||||||
<input
|
{#if icon}
|
||||||
id={inputId}
|
<div
|
||||||
type={inputType}
|
class="w-8 h-8 rounded-full bg-light flex items-center justify-center shrink-0"
|
||||||
bind:value
|
|
||||||
{placeholder}
|
|
||||||
{disabled}
|
|
||||||
{required}
|
|
||||||
{autocomplete}
|
|
||||||
{oninput}
|
|
||||||
{onkeydown}
|
|
||||||
class="w-full px-3 py-3 bg-night text-white rounded-[32px] min-w-[192px]
|
|
||||||
placeholder:text-white/40
|
|
||||||
focus:outline-none focus:ring-2 focus:ring-primary
|
|
||||||
disabled:opacity-30 disabled:cursor-not-allowed
|
|
||||||
transition-colors"
|
|
||||||
class:ring-1={error}
|
|
||||||
class:ring-error={error}
|
|
||||||
/>
|
|
||||||
{#if isPassword}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-white/40 hover:text-white transition-colors"
|
|
||||||
onclick={() => (showPassword = !showPassword)}
|
|
||||||
>
|
>
|
||||||
{#if showPassword}
|
<span
|
||||||
<svg
|
class="material-symbols-rounded text-background"
|
||||||
class="w-5 h-5"
|
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||||
viewBox="0 0 24 24"
|
>
|
||||||
fill="none"
|
{icon}
|
||||||
stroke="currentColor"
|
</span>
|
||||||
stroke-width="2"
|
</div>
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"
|
|
||||||
/>
|
|
||||||
<line x1="1" y1="1" x2="23" y2="23" />
|
|
||||||
</svg>
|
|
||||||
{:else}
|
|
||||||
<svg
|
|
||||||
class="w-5 h-5"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"
|
|
||||||
/>
|
|
||||||
<circle cx="12" cy="12" r="3" />
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
type={inputType}
|
||||||
|
bind:value
|
||||||
|
{placeholder}
|
||||||
|
{disabled}
|
||||||
|
{required}
|
||||||
|
{autocomplete}
|
||||||
|
{oninput}
|
||||||
|
{onchange}
|
||||||
|
{onkeydown}
|
||||||
|
class="
|
||||||
|
w-full p-3 bg-background text-white rounded-[32px] min-w-[192px]
|
||||||
|
font-medium font-input text-body
|
||||||
|
placeholder:text-white/40
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-primary
|
||||||
|
disabled:opacity-30 disabled:cursor-not-allowed
|
||||||
|
transition-colors
|
||||||
|
"
|
||||||
|
class:ring-1={error}
|
||||||
|
class:ring-error={error}
|
||||||
|
class:pr-12={isPassword}
|
||||||
|
/>
|
||||||
|
{#if isPassword}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-colors"
|
||||||
|
onclick={() => (showPassword = !showPassword)}
|
||||||
|
aria-label={showPassword
|
||||||
|
? "Hide password"
|
||||||
|
: "Show password"}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded"
|
||||||
|
style="font-size: 22px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||||
|
>
|
||||||
|
{showPassword ? "visibility_off" : "visibility"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
|
|||||||
61
src/lib/components/ui/KanbanColumn.svelte
Normal file
61
src/lib/components/ui/KanbanColumn.svelte
Normal file
@@ -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>
|
||||||
60
src/lib/components/ui/ListItem.svelte
Normal file
60
src/lib/components/ui/ListItem.svelte
Normal file
@@ -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>
|
||||||
39
src/lib/components/ui/Logo.svelte
Normal file
39
src/lib/components/ui/Logo.svelte
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
size?: "sm" | "md";
|
||||||
|
}
|
||||||
|
|
||||||
|
let { size = "md" }: Props = $props();
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: "w-10 h-10",
|
||||||
|
md: "w-12 h-12",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center {sizeClasses[size]}">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 38 21"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="w-full h-auto"
|
||||||
|
>
|
||||||
|
<!-- Root logo SVG paths matching Figma -->
|
||||||
|
<path
|
||||||
|
d="M0 0.5C0 0.224 0.224 0 0.5 0H37.5C37.776 0 38 0.224 38 0.5V12.203C38 12.479 37.776 12.703 37.5 12.703H0.5C0.224 12.703 0 12.479 0 12.203V0.5Z"
|
||||||
|
fill="#00A3E0"
|
||||||
|
fill-opacity="0.2"
|
||||||
|
/>
|
||||||
|
<!-- Left eye -->
|
||||||
|
<circle cx="11.5" cy="7.5" r="5" fill="#00A3E0" />
|
||||||
|
<!-- Right eye -->
|
||||||
|
<circle cx="23.5" cy="7.5" r="5" fill="#00A3E0" />
|
||||||
|
<!-- Mouth/smile -->
|
||||||
|
<path
|
||||||
|
d="M12.25 15.04C12.25 15.04 15 20.25 18.75 20.25C22.5 20.25 25.25 15.04 25.25 15.04"
|
||||||
|
stroke="#00A3E0"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
@@ -1,25 +1,27 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from "svelte";
|
||||||
|
import { fade, fly } from "svelte/transition";
|
||||||
|
import { cubicOut } from "svelte/easing";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
title?: string;
|
title?: string;
|
||||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
size?: "sm" | "md" | "lg" | "xl";
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { isOpen, onClose, title, size = 'md', children }: Props = $props();
|
let { isOpen, onClose, title, size = "md", children }: Props = $props();
|
||||||
|
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
sm: 'max-w-sm',
|
sm: "max-w-sm",
|
||||||
md: 'max-w-md',
|
md: "max-w-md",
|
||||||
lg: 'max-w-lg',
|
lg: "max-w-lg",
|
||||||
xl: 'max-w-xl'
|
xl: "max-w-xl",
|
||||||
};
|
};
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === "Escape") {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,23 +41,40 @@
|
|||||||
onkeydown={handleKeydown}
|
onkeydown={handleKeydown}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby={title ? 'modal-title' : undefined}
|
aria-labelledby={title ? "modal-title" : undefined}
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
|
transition:fade={{ duration: 150 }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="bg-surface rounded-2xl w-full mx-4 {sizeClasses[size]} shadow-xl"
|
class="bg-surface rounded-2xl w-full mx-4 {sizeClasses[
|
||||||
|
size
|
||||||
|
]} shadow-xl"
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
role="document"
|
role="document"
|
||||||
|
transition:fly={{ y: 10, duration: 200, easing: cubicOut }}
|
||||||
>
|
>
|
||||||
{#if title}
|
{#if title}
|
||||||
<div class="flex items-center justify-between px-6 py-4 border-b border-light/10">
|
<div
|
||||||
<h2 id="modal-title" class="text-lg font-semibold text-light">{title}</h2>
|
class="flex items-center justify-between px-6 py-4 border-b border-light/10"
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
id="modal-title"
|
||||||
|
class="text-lg font-semibold text-light"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
<button
|
<button
|
||||||
class="w-8 h-8 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
|
class="w-8 h-8 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
|
class="w-5 h-5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
30
src/lib/components/ui/OrgHeader.svelte
Normal file
30
src/lib/components/ui/OrgHeader.svelte
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Avatar from "./Avatar.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
name: string;
|
||||||
|
role?: string;
|
||||||
|
size?: "sm" | "md";
|
||||||
|
isHover?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { name, role, size = "md", isHover = false }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 p-1 rounded-[32px] w-full transition-colors {isHover
|
||||||
|
? 'bg-dark'
|
||||||
|
: 'bg-night'}"
|
||||||
|
>
|
||||||
|
<Avatar {name} size={size === "sm" ? "sm" : "md"} />
|
||||||
|
{#if size !== "sm"}
|
||||||
|
<div class="flex-1 flex flex-col min-w-0">
|
||||||
|
<span class="font-heading text-h3 text-white truncate">{name}</span>
|
||||||
|
{#if role}
|
||||||
|
<span class="font-body text-body-sm text-white truncate"
|
||||||
|
>{role}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -10,8 +10,10 @@
|
|||||||
label?: string;
|
label?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
onchange?: (e: Event) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -20,18 +22,22 @@
|
|||||||
label,
|
label,
|
||||||
placeholder = "Select...",
|
placeholder = "Select...",
|
||||||
error,
|
error,
|
||||||
|
hint,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
required = false,
|
required = false,
|
||||||
|
onchange,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const inputId = `select-${crypto.randomUUID().slice(0, 8)}`;
|
const inputId = `select-${crypto.randomUUID().slice(0, 8)}`;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-3 w-full">
|
||||||
{#if label}
|
{#if label}
|
||||||
<label for={inputId} class="text-sm font-medium text-light/80">
|
<label
|
||||||
{label}
|
for={inputId}
|
||||||
{#if required}<span class="text-primary">*</span>{/if}
|
class="px-3 font-bold font-body text-body text-white"
|
||||||
|
>
|
||||||
|
{#if required}<span class="text-error">* </span>{/if}{label}
|
||||||
</label>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -40,21 +46,27 @@
|
|||||||
bind:value
|
bind:value
|
||||||
{disabled}
|
{disabled}
|
||||||
{required}
|
{required}
|
||||||
class="w-full px-4 py-2.5 bg-surface text-light rounded-xl border border-light/20
|
{onchange}
|
||||||
focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary
|
class="w-full p-3 bg-background text-white rounded-[32px] min-w-[192px]
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed
|
font-medium font-input text-body
|
||||||
transition-colors appearance-none cursor-pointer"
|
focus:outline-none focus:ring-2 focus:ring-primary
|
||||||
class:border-error={error}
|
disabled:opacity-30 disabled:cursor-not-allowed
|
||||||
class:placeholder-shown={!value}
|
transition-colors appearance-none cursor-pointer"
|
||||||
|
class:ring-1={error}
|
||||||
|
class:ring-error={error}
|
||||||
>
|
>
|
||||||
<option value="" disabled>{placeholder}</option>
|
{#if placeholder}
|
||||||
|
<option value="" disabled>{placeholder}</option>
|
||||||
|
{/if}
|
||||||
{#each options as option}
|
{#each options as option}
|
||||||
<option value={option.value}>{option.label}</option>
|
<option value={option.value}>{option.label}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="text-sm text-error">{error}</p>
|
<p class="text-sm text-error px-3">{error}</p>
|
||||||
|
{:else if hint}
|
||||||
|
<p class="text-sm text-white/50 px-3">{hint}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
72
src/lib/components/ui/Skeleton.svelte
Normal file
72
src/lib/components/ui/Skeleton.svelte
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
variant?: 'text' | 'circular' | 'rectangular' | 'card';
|
||||||
|
width?: string;
|
||||||
|
height?: string;
|
||||||
|
lines?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
class: className = '',
|
||||||
|
variant = 'text',
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
lines = 1,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const variantClasses: Record<string, string> = {
|
||||||
|
text: 'h-4 rounded',
|
||||||
|
circular: 'rounded-full',
|
||||||
|
rectangular: 'rounded-lg',
|
||||||
|
card: 'rounded-2xl',
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultSizes: Record<string, { w: string; h: string }> = {
|
||||||
|
text: { w: '100%', h: '1rem' },
|
||||||
|
circular: { w: '2.5rem', h: '2.5rem' },
|
||||||
|
rectangular: { w: '100%', h: '4rem' },
|
||||||
|
card: { w: '100%', h: '8rem' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalWidth = width || defaultSizes[variant].w;
|
||||||
|
const finalHeight = height || defaultSizes[variant].h;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if variant === 'text' && lines > 1}
|
||||||
|
<div class="space-y-2 {className}">
|
||||||
|
{#each Array(lines) as _, i}
|
||||||
|
<div
|
||||||
|
class="skeleton {variantClasses[variant]}"
|
||||||
|
style="width: {i === lines - 1 ? '75%' : finalWidth}; height: {finalHeight}"
|
||||||
|
></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="skeleton {variantClasses[variant]} {className}"
|
||||||
|
style="width: {finalWidth}; height: {finalHeight}"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgb(var(--color-light) / 0.06) 0%,
|
||||||
|
rgb(var(--color-light) / 0.12) 50%,
|
||||||
|
rgb(var(--color-light) / 0.06) 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,36 +8,38 @@
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
rows?: number;
|
rows?: number;
|
||||||
resize?: 'none' | 'vertical' | 'horizontal' | 'both';
|
resize?: "none" | "vertical" | "horizontal" | "both";
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
value = $bindable(''),
|
value = $bindable(""),
|
||||||
placeholder = '',
|
placeholder = "",
|
||||||
label,
|
label,
|
||||||
error,
|
error,
|
||||||
hint,
|
hint,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
required = false,
|
required = false,
|
||||||
rows = 3,
|
rows = 3,
|
||||||
resize = 'vertical'
|
resize = "vertical",
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const inputId = `textarea-${crypto.randomUUID().slice(0, 8)}`;
|
const inputId = `textarea-${crypto.randomUUID().slice(0, 8)}`;
|
||||||
|
|
||||||
const resizeClasses = {
|
const resizeClasses = {
|
||||||
none: 'resize-none',
|
none: "resize-none",
|
||||||
vertical: 'resize-y',
|
vertical: "resize-y",
|
||||||
horizontal: 'resize-x',
|
horizontal: "resize-x",
|
||||||
both: 'resize'
|
both: "resize",
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-3 w-full">
|
||||||
{#if label}
|
{#if label}
|
||||||
<label for={inputId} class="text-sm font-medium text-light/80">
|
<label
|
||||||
{label}
|
for={inputId}
|
||||||
{#if required}<span class="text-primary">*</span>{/if}
|
class="px-3 font-bold font-body text-body text-white"
|
||||||
|
>
|
||||||
|
{#if required}<span class="text-error">* </span>{/if}{label}
|
||||||
</label>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -48,19 +50,19 @@
|
|||||||
{disabled}
|
{disabled}
|
||||||
{required}
|
{required}
|
||||||
{rows}
|
{rows}
|
||||||
class="w-full px-4 py-2.5 bg-surface text-light rounded-xl border border-light/20
|
class="w-full p-3 bg-background text-white rounded-2xl min-w-[192px]
|
||||||
placeholder:text-light/40
|
font-medium font-input text-body
|
||||||
focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary
|
placeholder:text-white/40
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed
|
focus:outline-none focus:ring-2 focus:ring-primary
|
||||||
transition-colors {resizeClasses[resize]}"
|
disabled:opacity-30 disabled:cursor-not-allowed
|
||||||
class:border-error={error}
|
transition-colors {resizeClasses[resize]}"
|
||||||
class:focus:border-error={error}
|
class:ring-1={error}
|
||||||
class:focus:ring-error={error}
|
class:ring-error={error}
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="text-sm text-error">{error}</p>
|
<p class="text-sm text-error px-3">{error}</p>
|
||||||
{:else if hint}
|
{:else if hint}
|
||||||
<p class="text-sm text-light/50">{hint}</p>
|
<p class="text-sm text-white/50 px-3">{hint}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { toasts } from '$lib/stores/toast';
|
import { toasts } from "$lib/stores/toast.svelte";
|
||||||
import Toast from './Toast.svelte';
|
import Toast from "./Toast.svelte";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
|
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
|
||||||
|
|||||||
@@ -10,3 +10,17 @@ export { default as Spinner } from './Spinner.svelte';
|
|||||||
export { default as Toggle } from './Toggle.svelte';
|
export { default as Toggle } from './Toggle.svelte';
|
||||||
export { default as Toast } from './Toast.svelte';
|
export { default as Toast } from './Toast.svelte';
|
||||||
export { default as ToastContainer } from './ToastContainer.svelte';
|
export { default as ToastContainer } from './ToastContainer.svelte';
|
||||||
|
export { default as Skeleton } from './Skeleton.svelte';
|
||||||
|
export { default as EmptyState } from './EmptyState.svelte';
|
||||||
|
export { default as IconButton } from './IconButton.svelte';
|
||||||
|
export { default as Dropdown } from './Dropdown.svelte';
|
||||||
|
export { default as DropdownItem } from './DropdownItem.svelte';
|
||||||
|
export { default as Chip } from './Chip.svelte';
|
||||||
|
export { default as ListItem } from './ListItem.svelte';
|
||||||
|
export { default as CalendarDay } from './CalendarDay.svelte';
|
||||||
|
export { default as OrgHeader } from './OrgHeader.svelte';
|
||||||
|
export { default as KanbanColumn } from './KanbanColumn.svelte';
|
||||||
|
export { default as Logo } from './Logo.svelte';
|
||||||
|
export { default as ContentHeader } from './ContentHeader.svelte';
|
||||||
|
export { default as Icon } from './Icon.svelte';
|
||||||
|
export { default as AssigneePicker } from './AssigneePicker.svelte';
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
// place files you want to import through the `$lib` alias in this folder.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import type { Session, User } from '@supabase/supabase-js';
|
|
||||||
|
|
||||||
class AuthStore {
|
|
||||||
session = $state<Session | null>(null);
|
|
||||||
user = $state<User | null>(null);
|
|
||||||
isLoading = $state(true);
|
|
||||||
|
|
||||||
setSession(session: Session | null, user: User | null) {
|
|
||||||
this.session = session;
|
|
||||||
this.user = user;
|
|
||||||
this.isLoading = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isAuthenticated() {
|
|
||||||
return !!this.session && !!this.user;
|
|
||||||
}
|
|
||||||
|
|
||||||
get userId() {
|
|
||||||
return this.user?.id ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
get email() {
|
|
||||||
return this.user?.email ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const auth = new AuthStore();
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import type { Document } from '$lib/supabase/types';
|
|
||||||
import type { DocumentWithChildren } from '$lib/api/documents';
|
|
||||||
import { buildDocumentTree } from '$lib/api/documents';
|
|
||||||
|
|
||||||
class DocumentsStore {
|
|
||||||
documents = $state<Document[]>([]);
|
|
||||||
currentDocument = $state<Document | null>(null);
|
|
||||||
isLoading = $state(false);
|
|
||||||
isSaving = $state(false);
|
|
||||||
|
|
||||||
setDocuments(docs: Document[]) {
|
|
||||||
this.documents = docs;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentDocument(doc: Document | null) {
|
|
||||||
this.currentDocument = doc;
|
|
||||||
}
|
|
||||||
|
|
||||||
addDocument(doc: Document) {
|
|
||||||
this.documents = [...this.documents, doc];
|
|
||||||
}
|
|
||||||
|
|
||||||
updateDocument(id: string, updates: Partial<Document>) {
|
|
||||||
this.documents = this.documents.map((doc) =>
|
|
||||||
doc.id === id ? { ...doc, ...updates } : doc
|
|
||||||
);
|
|
||||||
if (this.currentDocument?.id === id) {
|
|
||||||
this.currentDocument = { ...this.currentDocument, ...updates };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeDocument(id: string) {
|
|
||||||
this.documents = this.documents.filter((doc) => doc.id !== id);
|
|
||||||
if (this.currentDocument?.id === id) {
|
|
||||||
this.currentDocument = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get tree(): DocumentWithChildren[] {
|
|
||||||
return buildDocumentTree(this.documents);
|
|
||||||
}
|
|
||||||
|
|
||||||
get folders() {
|
|
||||||
return this.documents.filter((doc) => doc.type === 'folder');
|
|
||||||
}
|
|
||||||
|
|
||||||
get files() {
|
|
||||||
return this.documents.filter((doc) => doc.type === 'document');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const docs = new DocumentsStore();
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { auth } from './auth.svelte';
|
|
||||||
export { orgs, type OrgWithRole } from './organizations.svelte';
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import type { Organization, OrgMember, MemberRole } from '$lib/supabase/types';
|
|
||||||
|
|
||||||
export interface OrgWithRole extends Organization {
|
|
||||||
role: MemberRole;
|
|
||||||
memberCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
class OrganizationsStore {
|
|
||||||
organizations = $state<OrgWithRole[]>([]);
|
|
||||||
currentOrg = $state<OrgWithRole | null>(null);
|
|
||||||
members = $state<(OrgMember & { profile?: { email: string; full_name: string | null; avatar_url: string | null } })[]>([]);
|
|
||||||
isLoading = $state(false);
|
|
||||||
|
|
||||||
setOrganizations(orgs: OrgWithRole[]) {
|
|
||||||
this.organizations = orgs;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentOrg(org: OrgWithRole | null) {
|
|
||||||
this.currentOrg = org;
|
|
||||||
}
|
|
||||||
|
|
||||||
setMembers(members: typeof this.members) {
|
|
||||||
this.members = members;
|
|
||||||
}
|
|
||||||
|
|
||||||
addOrganization(org: OrgWithRole) {
|
|
||||||
this.organizations = [...this.organizations, org];
|
|
||||||
}
|
|
||||||
|
|
||||||
updateOrganization(id: string, updates: Partial<Organization>) {
|
|
||||||
this.organizations = this.organizations.map((org) =>
|
|
||||||
org.id === id ? { ...org, ...updates } : org
|
|
||||||
);
|
|
||||||
if (this.currentOrg?.id === id) {
|
|
||||||
this.currentOrg = { ...this.currentOrg, ...updates };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeOrganization(id: string) {
|
|
||||||
this.organizations = this.organizations.filter((org) => org.id !== id);
|
|
||||||
if (this.currentOrg?.id === id) {
|
|
||||||
this.currentOrg = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get hasOrganizations() {
|
|
||||||
return this.organizations.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isOwnerOrAdmin() {
|
|
||||||
return this.currentOrg?.role === 'owner' || this.currentOrg?.role === 'admin';
|
|
||||||
}
|
|
||||||
|
|
||||||
get canEdit() {
|
|
||||||
return ['owner', 'admin', 'editor'].includes(this.currentOrg?.role ?? '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const orgs = new OrganizationsStore();
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
/**
|
|
||||||
* Theme Store - Manages app theme (dark/light mode and accent colors)
|
|
||||||
* Inspired by root-v2
|
|
||||||
*/
|
|
||||||
import { writable, derived } from 'svelte/store';
|
|
||||||
import { browser } from '$app/environment';
|
|
||||||
|
|
||||||
export type ThemeMode = 'dark' | 'light' | 'system';
|
|
||||||
|
|
||||||
export interface ThemeColors {
|
|
||||||
primary: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PRESET_COLORS: ThemeColors[] = [
|
|
||||||
{ name: 'Cyan', primary: '#00A3E0' },
|
|
||||||
{ name: 'Purple', primary: '#8B5CF6' },
|
|
||||||
{ name: 'Pink', primary: '#EC4899' },
|
|
||||||
{ name: 'Green', primary: '#10B981' },
|
|
||||||
{ name: 'Orange', primary: '#F97316' },
|
|
||||||
{ name: 'Red', primary: '#EF4444' },
|
|
||||||
{ name: 'Blue', primary: '#3B82F6' },
|
|
||||||
{ name: 'Indigo', primary: '#6366F1' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const THEME_STORAGE_KEY = 'root_theme';
|
|
||||||
|
|
||||||
interface ThemeState {
|
|
||||||
mode: ThemeMode;
|
|
||||||
primaryColor: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultTheme: ThemeState = {
|
|
||||||
mode: 'dark',
|
|
||||||
primaryColor: '#00A3E0',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convert hex to HSL
|
|
||||||
function hexToHSL(hex: string): { h: number; s: number; l: number } {
|
|
||||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
||||||
if (!result) return { h: 0, s: 0, l: 0 };
|
|
||||||
|
|
||||||
const r = parseInt(result[1], 16) / 255;
|
|
||||||
const g = parseInt(result[2], 16) / 255;
|
|
||||||
const b = parseInt(result[3], 16) / 255;
|
|
||||||
|
|
||||||
const max = Math.max(r, g, b);
|
|
||||||
const min = Math.min(r, g, b);
|
|
||||||
let h = 0;
|
|
||||||
let s = 0;
|
|
||||||
const l = (max + min) / 2;
|
|
||||||
|
|
||||||
if (max !== min) {
|
|
||||||
const d = max - min;
|
|
||||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
||||||
switch (max) {
|
|
||||||
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
|
|
||||||
case g: h = ((b - r) / d + 2) / 6; break;
|
|
||||||
case b: h = ((r - g) / d + 4) / 6; break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { h: h * 360, s: s * 100, l: l * 100 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert HSL to hex
|
|
||||||
function hslToHex(h: number, s: number, l: number): string {
|
|
||||||
s /= 100;
|
|
||||||
l /= 100;
|
|
||||||
const a = s * Math.min(l, 1 - l);
|
|
||||||
const f = (n: number) => {
|
|
||||||
const k = (n + h / 30) % 12;
|
|
||||||
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
|
||||||
return Math.round(255 * color).toString(16).padStart(2, '0');
|
|
||||||
};
|
|
||||||
return `#${f(0)}${f(8)}${f(4)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate derived colors from primary
|
|
||||||
function generateDerivedColors(primary: string, isDark: boolean) {
|
|
||||||
const { h, s } = hexToHSL(primary);
|
|
||||||
|
|
||||||
if (isDark) {
|
|
||||||
return {
|
|
||||||
night: hslToHex(h, Math.min(s, 40), 6),
|
|
||||||
dark: hslToHex(h, Math.min(s, 35), 10),
|
|
||||||
surface: hslToHex(h, Math.min(s, 30), 12),
|
|
||||||
background: hslToHex(h, Math.min(s, 30), 3),
|
|
||||||
light: '#e5e6f0',
|
|
||||||
text: '#ffffff',
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
const lightSat = Math.min(s, 30);
|
|
||||||
return {
|
|
||||||
night: hslToHex(h, lightSat, 95),
|
|
||||||
dark: hslToHex(h, lightSat, 90),
|
|
||||||
surface: hslToHex(h, lightSat, 98),
|
|
||||||
background: hslToHex(h, lightSat, 100),
|
|
||||||
light: '#1a1a2e',
|
|
||||||
text: '#0a121f',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEffectiveMode(mode: ThemeMode): 'dark' | 'light' {
|
|
||||||
if (mode === 'system') {
|
|
||||||
if (!browser) return 'dark';
|
|
||||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
||||||
}
|
|
||||||
return mode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadTheme(): ThemeState {
|
|
||||||
if (!browser) return defaultTheme;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
|
||||||
if (stored) {
|
|
||||||
return { ...defaultTheme, ...JSON.parse(stored) };
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to load theme:', e);
|
|
||||||
}
|
|
||||||
return defaultTheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveTheme(theme: ThemeState): void {
|
|
||||||
if (!browser) return;
|
|
||||||
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(theme));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyTheme(state: ThemeState): void {
|
|
||||||
if (!browser) return;
|
|
||||||
|
|
||||||
const root = document.documentElement;
|
|
||||||
const effectiveMode = getEffectiveMode(state.mode);
|
|
||||||
|
|
||||||
// Set mode class
|
|
||||||
root.classList.remove('dark', 'light');
|
|
||||||
root.classList.add(effectiveMode);
|
|
||||||
|
|
||||||
// Set CSS custom properties
|
|
||||||
root.style.setProperty('--color-primary', state.primaryColor);
|
|
||||||
|
|
||||||
// Calculate hover variant
|
|
||||||
const { h, s, l } = hexToHSL(state.primaryColor);
|
|
||||||
root.style.setProperty('--color-primary-hover', hslToHex(h, s, Math.min(100, l + 10)));
|
|
||||||
|
|
||||||
// Generate and apply derived colors
|
|
||||||
const derived = generateDerivedColors(state.primaryColor, effectiveMode === 'dark');
|
|
||||||
root.style.setProperty('--color-night', derived.night);
|
|
||||||
root.style.setProperty('--color-dark', derived.dark);
|
|
||||||
root.style.setProperty('--color-surface', derived.surface);
|
|
||||||
root.style.setProperty('--color-background', derived.background);
|
|
||||||
root.style.setProperty('--color-light', derived.light);
|
|
||||||
root.style.setProperty('--color-text', derived.text);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createThemeStore() {
|
|
||||||
const { subscribe, set, update } = writable<ThemeState>(loadTheme());
|
|
||||||
|
|
||||||
return {
|
|
||||||
subscribe,
|
|
||||||
setMode: (mode: ThemeMode) => {
|
|
||||||
update(state => {
|
|
||||||
const newState = { ...state, mode };
|
|
||||||
saveTheme(newState);
|
|
||||||
applyTheme(newState);
|
|
||||||
return newState;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
setPrimaryColor: (color: string) => {
|
|
||||||
update(state => {
|
|
||||||
const newState = { ...state, primaryColor: color };
|
|
||||||
saveTheme(newState);
|
|
||||||
applyTheme(newState);
|
|
||||||
return newState;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
toggleMode: () => {
|
|
||||||
update(state => {
|
|
||||||
const modes: ThemeMode[] = ['dark', 'light', 'system'];
|
|
||||||
const currentIndex = modes.indexOf(state.mode);
|
|
||||||
const newMode = modes[(currentIndex + 1) % modes.length];
|
|
||||||
const newState: ThemeState = { ...state, mode: newMode };
|
|
||||||
saveTheme(newState);
|
|
||||||
applyTheme(newState);
|
|
||||||
return newState;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
reset: () => {
|
|
||||||
set(defaultTheme);
|
|
||||||
saveTheme(defaultTheme);
|
|
||||||
applyTheme(defaultTheme);
|
|
||||||
},
|
|
||||||
init: () => {
|
|
||||||
const state = loadTheme();
|
|
||||||
applyTheme(state);
|
|
||||||
set(state);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const theme = createThemeStore();
|
|
||||||
|
|
||||||
// Derived stores for convenience
|
|
||||||
export const isDarkMode = derived(theme, $t => getEffectiveMode($t.mode) === 'dark');
|
|
||||||
export const primaryColor = derived(theme, $t => $t.primaryColor);
|
|
||||||
export const themeMode = derived(theme, $t => $t.mode);
|
|
||||||
|
|
||||||
// Initialize theme on load
|
|
||||||
if (browser) {
|
|
||||||
applyTheme(loadTheme());
|
|
||||||
|
|
||||||
// Listen for system theme changes
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
|
||||||
const state = loadTheme();
|
|
||||||
if (state.mode === 'system') {
|
|
||||||
applyTheme(state);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
83
src/lib/stores/toast.svelte.ts
Normal file
83
src/lib/stores/toast.svelte.ts
Normal file
@@ -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 } from './client';
|
||||||
export { createClient as createServerClient } from './server';
|
|
||||||
export type * from './types';
|
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
207
src/lib/utils/logger.ts
Normal file
207
src/lib/utils/logger.ts
Normal file
@@ -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');
|
||||||
|
}
|
||||||
82
src/routes/+error.svelte
Normal file
82
src/routes/+error.svelte
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from "$app/stores";
|
||||||
|
import { Button } from "$lib/components/ui";
|
||||||
|
import { dumpLogs } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
let showLogs = $state(false);
|
||||||
|
let logDump = $state("");
|
||||||
|
let copied = $state(false);
|
||||||
|
|
||||||
|
function handleShowLogs() {
|
||||||
|
logDump = dumpLogs();
|
||||||
|
showLogs = !showLogs;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopyLogs() {
|
||||||
|
const dump = dumpLogs();
|
||||||
|
const errorInfo = `--- Error Report ---
|
||||||
|
URL: ${$page.url.pathname}
|
||||||
|
Status: ${$page.status}
|
||||||
|
Message: ${$page.error?.message || "Unknown"}
|
||||||
|
Error ID: ${$page.error?.errorId || "N/A"}
|
||||||
|
Context: ${$page.error?.context || "N/A"}
|
||||||
|
Time: ${new Date().toISOString()}
|
||||||
|
|
||||||
|
--- Recent Logs ---
|
||||||
|
${dump}
|
||||||
|
`;
|
||||||
|
await navigator.clipboard.writeText(errorInfo);
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => (copied = false), 2000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-night flex items-center justify-center p-4">
|
||||||
|
<div class="max-w-lg w-full text-center space-y-6">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-[80px] font-heading text-primary">{$page.status}</p>
|
||||||
|
<h1 class="text-2xl font-heading text-white">
|
||||||
|
{$page.status === 404 ? "Page not found" : "Something went wrong"}
|
||||||
|
</h1>
|
||||||
|
<p class="text-light/60 text-base">
|
||||||
|
{$page.error?.message || "An unexpected error occurred."}
|
||||||
|
</p>
|
||||||
|
{#if $page.error?.errorId}
|
||||||
|
<p class="text-light/40 text-sm font-mono">
|
||||||
|
Error ID: {$page.error.errorId}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if $page.error?.context}
|
||||||
|
<p class="text-light/40 text-sm font-mono">
|
||||||
|
{$page.error.context}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 justify-center flex-wrap">
|
||||||
|
<Button onclick={() => window.location.href = "/"}>
|
||||||
|
Go Home
|
||||||
|
</Button>
|
||||||
|
<Button variant="tertiary" onclick={() => window.location.reload()}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onclick={handleCopyLogs}>
|
||||||
|
{copied ? "Copied!" : "Copy Error Report"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-sm text-light/40 hover:text-light/60 transition-colors underline"
|
||||||
|
onclick={handleShowLogs}
|
||||||
|
>
|
||||||
|
{showLogs ? "Hide" : "Show"} debug logs
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if showLogs}
|
||||||
|
<pre class="mt-4 p-4 bg-dark rounded-[16px] text-left text-xs text-light/70 overflow-auto max-h-[300px] font-mono whitespace-pre-wrap">{logDump || "No recent logs."}</pre>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import { Button, Card, Modal, Input } from "$lib/components/ui";
|
import { Button, Card, Modal, Input } from "$lib/components/ui";
|
||||||
import { createOrganization, generateSlug } from "$lib/api/organizations";
|
import { createOrganization, generateSlug } from "$lib/api/organizations";
|
||||||
|
import { toasts } from "$lib/stores/toast.svelte";
|
||||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
import type { Database } from "$lib/supabase/types";
|
import type { Database } from "$lib/supabase/types";
|
||||||
|
|
||||||
@@ -24,6 +25,9 @@
|
|||||||
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
||||||
|
|
||||||
let organizations = $state(data.organizations);
|
let organizations = $state(data.organizations);
|
||||||
|
$effect(() => {
|
||||||
|
organizations = data.organizations;
|
||||||
|
});
|
||||||
let showCreateModal = $state(false);
|
let showCreateModal = $state(false);
|
||||||
let newOrgName = $state("");
|
let newOrgName = $state("");
|
||||||
let creating = $state(false);
|
let creating = $state(false);
|
||||||
@@ -41,7 +45,9 @@
|
|||||||
showCreateModal = false;
|
showCreateModal = false;
|
||||||
newOrgName = "";
|
newOrgName = "";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create organization:", error);
|
toasts.error(
|
||||||
|
"Failed to create organization. The name may already be taken.",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
creating = false;
|
creating = false;
|
||||||
}
|
}
|
||||||
@@ -63,7 +69,7 @@
|
|||||||
>Style Guide</a
|
>Style Guide</a
|
||||||
>
|
>
|
||||||
<form method="POST" action="/auth/logout">
|
<form method="POST" action="/auth/logout">
|
||||||
<Button variant="ghost" size="sm" type="submit"
|
<Button variant="tertiary" size="sm" type="submit"
|
||||||
>Sign Out</Button
|
>Sign Out</Button
|
||||||
>
|
>
|
||||||
</form>
|
</form>
|
||||||
@@ -180,7 +186,7 @@
|
|||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex justify-end gap-2 pt-2">
|
<div class="flex justify-end gap-2 pt-2">
|
||||||
<Button variant="ghost" onclick={() => (showCreateModal = false)}
|
<Button variant="tertiary" onclick={() => (showCreateModal = false)}
|
||||||
>Cancel</Button
|
>Cancel</Button
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
|
|||||||
error(401, 'Unauthorized');
|
error(401, 'Unauthorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch org first (need org.id for subsequent queries)
|
||||||
const { data: org, error: orgError } = await locals.supabase
|
const { data: org, error: orgError } = await locals.supabase
|
||||||
.from('organizations')
|
.from('organizations')
|
||||||
.select('*')
|
.select('*')
|
||||||
@@ -18,58 +19,62 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
|
|||||||
error(404, 'Organization not found');
|
error(404, 'Organization not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: membership } = await locals.supabase
|
// Now fetch membership, members, and activity in parallel (all depend on org.id)
|
||||||
.from('org_members')
|
const [membershipResult, membersResult, activityResult] = await Promise.all([
|
||||||
.select('role')
|
locals.supabase
|
||||||
.eq('org_id', org.id)
|
.from('org_members')
|
||||||
.eq('user_id', user.id)
|
.select('role')
|
||||||
.single();
|
.eq('org_id', org.id)
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.single(),
|
||||||
|
locals.supabase
|
||||||
|
.from('org_members')
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
user_id,
|
||||||
|
role,
|
||||||
|
profiles:user_id (
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
full_name,
|
||||||
|
avatar_url
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.eq('org_id', org.id)
|
||||||
|
.limit(10),
|
||||||
|
locals.supabase
|
||||||
|
.from('activity_log')
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
action,
|
||||||
|
entity_type,
|
||||||
|
entity_id,
|
||||||
|
entity_name,
|
||||||
|
created_at,
|
||||||
|
profiles:user_id (
|
||||||
|
full_name,
|
||||||
|
email
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.eq('org_id', org.id)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { data: membership } = membershipResult;
|
||||||
|
const { data: members } = membersResult;
|
||||||
|
const { data: recentActivity } = activityResult;
|
||||||
|
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
error(403, 'You are not a member of this organization');
|
error(403, 'You are not a member of this organization');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch team members for sidebar
|
|
||||||
const { data: members } = await locals.supabase
|
|
||||||
.from('org_members')
|
|
||||||
.select(`
|
|
||||||
id,
|
|
||||||
user_id,
|
|
||||||
role,
|
|
||||||
profiles:user_id (
|
|
||||||
id,
|
|
||||||
email,
|
|
||||||
full_name,
|
|
||||||
avatar_url
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
.eq('org_id', org.id)
|
|
||||||
.limit(10);
|
|
||||||
|
|
||||||
// Fetch recent activity
|
|
||||||
const { data: recentActivity } = await locals.supabase
|
|
||||||
.from('activity_log')
|
|
||||||
.select(`
|
|
||||||
id,
|
|
||||||
action,
|
|
||||||
entity_type,
|
|
||||||
entity_id,
|
|
||||||
entity_name,
|
|
||||||
created_at,
|
|
||||||
profiles:user_id (
|
|
||||||
full_name,
|
|
||||||
email
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
.eq('org_id', org.id)
|
|
||||||
.order('created_at', { ascending: false })
|
|
||||||
.limit(10);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
org,
|
org,
|
||||||
role: membership.role,
|
role: membership.role,
|
||||||
userRole: membership.role,
|
userRole: membership.role, // kept for backwards compat — same as role
|
||||||
members: members ?? [],
|
members: members ?? [],
|
||||||
recentActivity: recentActivity ?? []
|
recentActivity: recentActivity ?? [],
|
||||||
|
user
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from "$app/stores";
|
import { page, navigating } from "$app/stores";
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
|
import { Avatar, Logo } from "$lib/components/ui";
|
||||||
|
|
||||||
interface Member {
|
interface Member {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,7 +17,12 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: {
|
data: {
|
||||||
org: { id: string; name: string; slug: string };
|
org: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
avatar_url?: string | null;
|
||||||
|
};
|
||||||
role: string;
|
role: string;
|
||||||
userRole: string;
|
userRole: string;
|
||||||
members: Member[];
|
members: Member[];
|
||||||
@@ -26,24 +32,25 @@
|
|||||||
|
|
||||||
let { data, children }: Props = $props();
|
let { data, children }: Props = $props();
|
||||||
|
|
||||||
let sidebarCollapsed = $state(false);
|
|
||||||
|
|
||||||
const isAdmin = $derived(
|
const isAdmin = $derived(
|
||||||
data.userRole === "owner" || data.userRole === "admin",
|
data.userRole === "owner" || data.userRole === "admin",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Sidebar collapses on all pages except org overview
|
||||||
|
const isOrgOverview = $derived($page.url.pathname === `/${data.org.slug}`);
|
||||||
|
let sidebarHovered = $state(false);
|
||||||
|
const sidebarCollapsed = $derived(!isOrgOverview && !sidebarHovered);
|
||||||
|
|
||||||
const navItems = $derived([
|
const navItems = $derived([
|
||||||
{ href: `/${data.org.slug}`, label: "Overview", icon: "home" },
|
|
||||||
{
|
{
|
||||||
href: `/${data.org.slug}/documents`,
|
href: `/${data.org.slug}/documents`,
|
||||||
label: "Documents",
|
label: "Files",
|
||||||
icon: "file",
|
icon: "cloud",
|
||||||
},
|
},
|
||||||
{ href: `/${data.org.slug}/kanban`, label: "Kanban", icon: "kanban" },
|
|
||||||
{
|
{
|
||||||
href: `/${data.org.slug}/calendar`,
|
href: `/${data.org.slug}/calendar`,
|
||||||
label: "Calendar",
|
label: "Calendar",
|
||||||
icon: "calendar",
|
icon: "calendar_today",
|
||||||
},
|
},
|
||||||
// Only show settings for admins
|
// Only show settings for admins
|
||||||
...(isAdmin
|
...(isAdmin
|
||||||
@@ -58,7 +65,7 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
function isActive(href: string): boolean {
|
function isActive(href: string): boolean {
|
||||||
return $page.url.pathname === href;
|
return $page.url.pathname.startsWith(href);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -66,206 +73,107 @@
|
|||||||
<div class="flex h-screen bg-background p-4 gap-4">
|
<div class="flex h-screen bg-background p-4 gap-4">
|
||||||
<!-- Organization Module -->
|
<!-- Organization Module -->
|
||||||
<aside
|
<aside
|
||||||
class="{sidebarCollapsed
|
class="
|
||||||
? 'w-20'
|
{sidebarCollapsed ? 'w-[72px]' : 'w-64'}
|
||||||
: 'w-56'} bg-night rounded-[32px] flex flex-col px-3 py-5 transition-all duration-200 overflow-hidden"
|
transition-all duration-300
|
||||||
|
bg-night rounded-[32px] flex flex-col px-4 py-5 gap-4 overflow-hidden shrink-0
|
||||||
|
"
|
||||||
|
onmouseenter={() => (sidebarHovered = true)}
|
||||||
|
onmouseleave={() => (sidebarHovered = false)}
|
||||||
>
|
>
|
||||||
<!-- Org Header -->
|
<!-- Org Header -->
|
||||||
<div class="flex items-start gap-2 px-1 mb-2">
|
<a
|
||||||
|
href="/{data.org.slug}"
|
||||||
|
class="flex items-center gap-2 p-1 rounded-[32px] hover:bg-dark transition-colors"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="w-12 h-12 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xl font-heading shrink-0"
|
class="shrink-0 transition-all duration-300 {sidebarCollapsed
|
||||||
|
? 'w-8 h-8'
|
||||||
|
: 'w-12 h-12'}"
|
||||||
>
|
>
|
||||||
{data.org.name[0].toUpperCase()}
|
<Avatar
|
||||||
|
name={data.org.name}
|
||||||
|
src={data.org.avatar_url}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{#if !sidebarCollapsed}
|
<div
|
||||||
<div class="min-w-0 flex-1">
|
class="min-w-0 flex-1 overflow-hidden transition-all duration-300 {sidebarCollapsed
|
||||||
<h1 class="font-heading text-xl text-light truncate">
|
? 'opacity-0 max-w-0'
|
||||||
{data.org.name}
|
: 'opacity-100 max-w-[200px]'}"
|
||||||
</h1>
|
>
|
||||||
<p class="text-xs text-white capitalize">{data.role}</p>
|
<h1
|
||||||
</div>
|
class="font-heading text-h3 text-white truncate whitespace-nowrap"
|
||||||
{/if}
|
>
|
||||||
</div>
|
{data.org.name}
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
class="text-body-sm text-white font-body capitalize whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{data.role}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
<!-- Nav Items -->
|
<!-- Nav Items -->
|
||||||
<nav class="flex-1 space-y-0.5">
|
<nav class="flex-1 flex flex-col gap-1">
|
||||||
{#each navItems as item}
|
{#each navItems as item}
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
class="flex items-center gap-2 pl-1 pr-2 py-1 rounded-[50px] transition-colors {isActive(
|
class="flex items-center gap-2 h-10 pl-1 pr-2 py-1 rounded-[32px] transition-colors {isActive(
|
||||||
item.href,
|
item.href,
|
||||||
)
|
)
|
||||||
? 'bg-primary/20'
|
? 'bg-primary'
|
||||||
: 'hover:bg-light/5'}"
|
: 'hover:bg-dark'}"
|
||||||
title={sidebarCollapsed ? item.label : undefined}
|
title={sidebarCollapsed ? item.label : undefined}
|
||||||
>
|
>
|
||||||
<!-- Icon circle -->
|
|
||||||
<div
|
<div
|
||||||
class="w-8 h-8 rounded-full {isActive(item.href)
|
class="w-8 h-8 flex items-center justify-center p-1 shrink-0"
|
||||||
? 'bg-primary'
|
|
||||||
: 'bg-light'} flex items-center justify-center shrink-0"
|
|
||||||
>
|
>
|
||||||
{#if item.icon === "home"}
|
<span
|
||||||
<svg
|
class="material-symbols-rounded {isActive(item.href)
|
||||||
class="w-4 h-4 {isActive(item.href)
|
? 'text-background'
|
||||||
? 'text-white'
|
: 'text-light'}"
|
||||||
: 'text-night'}"
|
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
|
|
||||||
/>
|
|
||||||
<polyline points="9,22 9,12 15,12 15,22" />
|
|
||||||
</svg>
|
|
||||||
{:else if item.icon === "file"}
|
|
||||||
<svg
|
|
||||||
class="w-4 h-4 {isActive(item.href)
|
|
||||||
? 'text-white'
|
|
||||||
: 'text-night'}"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
|
||||||
/>
|
|
||||||
<polyline points="14,2 14,8 20,8" />
|
|
||||||
</svg>
|
|
||||||
{:else if item.icon === "kanban"}
|
|
||||||
<svg
|
|
||||||
class="w-4 h-4 {isActive(item.href)
|
|
||||||
? 'text-white'
|
|
||||||
: 'text-night'}"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<rect
|
|
||||||
x="3"
|
|
||||||
y="3"
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
rx="2"
|
|
||||||
/>
|
|
||||||
<line x1="9" y1="3" x2="9" y2="21" />
|
|
||||||
<line x1="15" y1="3" x2="15" y2="21" />
|
|
||||||
</svg>
|
|
||||||
{:else if item.icon === "calendar"}
|
|
||||||
<svg
|
|
||||||
class="w-4 h-4 {isActive(item.href)
|
|
||||||
? 'text-white'
|
|
||||||
: 'text-night'}"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<rect
|
|
||||||
x="3"
|
|
||||||
y="4"
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
rx="2"
|
|
||||||
/>
|
|
||||||
<line x1="16" y1="2" x2="16" y2="6" />
|
|
||||||
<line x1="8" y1="2" x2="8" y2="6" />
|
|
||||||
<line x1="3" y1="10" x2="21" y2="10" />
|
|
||||||
</svg>
|
|
||||||
{:else if item.icon === "settings"}
|
|
||||||
<svg
|
|
||||||
class="w-4 h-4 {isActive(item.href)
|
|
||||||
? 'text-white'
|
|
||||||
: 'text-night'}"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="3" />
|
|
||||||
<path
|
|
||||||
d="M12 1v2m0 18v2M4.2 4.2l1.4 1.4m12.8 12.8l1.4 1.4M1 12h2m18 0h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if !sidebarCollapsed}
|
|
||||||
<span class="font-bold text-light truncate"
|
|
||||||
>{item.label}</span
|
|
||||||
>
|
>
|
||||||
{/if}
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="font-body text-body truncate whitespace-nowrap transition-all duration-300 {isActive(
|
||||||
|
item.href,
|
||||||
|
)
|
||||||
|
? 'text-background'
|
||||||
|
: 'text-white'} {sidebarCollapsed
|
||||||
|
? 'opacity-0 max-w-0 overflow-hidden'
|
||||||
|
: 'opacity-100 max-w-[200px]'}">{item.label}</span
|
||||||
|
>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Team Members -->
|
<!-- Logo at bottom -->
|
||||||
{#if !sidebarCollapsed}
|
<div class="mt-auto">
|
||||||
<div class="mt-4 pt-4 border-t border-light/10">
|
<a href="/" title="Back to organizations">
|
||||||
<p class="font-heading text-base text-light mb-2 px-1">Team</p>
|
<Logo size={sidebarCollapsed ? "sm" : "md"} />
|
||||||
{#if data.members && data.members.length > 0}
|
|
||||||
<div class="space-y-0.5">
|
|
||||||
{#each data.members.slice(0, 5) as member}
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-2 pl-1 pr-2 py-1 rounded-[50px] hover:bg-light/5 transition-colors"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-5 h-5 rounded-full bg-gradient-to-br from-primary to-primary/50 flex items-center justify-center text-white text-xs font-medium"
|
|
||||||
>
|
|
||||||
{(member.profiles?.full_name ||
|
|
||||||
member.profiles?.email ||
|
|
||||||
"?")[0].toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class="text-sm font-bold text-light truncate flex-1"
|
|
||||||
>
|
|
||||||
{member.profiles?.full_name ||
|
|
||||||
member.profiles?.email?.split("@")[0] ||
|
|
||||||
"User"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="text-xs text-light/40 px-1">
|
|
||||||
No team members found
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Back link -->
|
|
||||||
<div class="mt-auto pt-4">
|
|
||||||
<a
|
|
||||||
href="/"
|
|
||||||
class="flex items-center gap-2 pl-1 pr-2 py-1 rounded-[50px] text-light/50 hover:text-light hover:bg-light/5 transition-colors"
|
|
||||||
title={sidebarCollapsed ? "All Organizations" : undefined}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-5 h-5 rounded-full bg-light/20 flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-3 h-3"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path d="m15 18-6-6 6-6" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{#if !sidebarCollapsed}
|
|
||||||
<span class="text-sm">All Organizations</span>
|
|
||||||
{/if}
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Main Content Area -->
|
<!-- Main Content Area -->
|
||||||
<main class="flex-1 bg-night rounded-[32px] overflow-auto">
|
<main class="flex-1 bg-night rounded-[32px] overflow-auto relative">
|
||||||
|
{#if $navigating}
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 z-10 flex items-center justify-center bg-night/80 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded text-primary animate-spin"
|
||||||
|
style="font-size: 40px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 40;"
|
||||||
|
>
|
||||||
|
progress_activity
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,329 +1,21 @@
|
|||||||
<script lang="ts">
|
<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 {
|
interface Props {
|
||||||
data: {
|
data: {
|
||||||
org: { id: string; name: string; slug: string };
|
org: { id: string; name: string; slug: string };
|
||||||
role: 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();
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{data.org.name} - Overview | Root</title>
|
<title>{data.org.name} | Root</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="p-8">
|
<div class="p-4 lg:p-6">
|
||||||
<header class="mb-8">
|
<header>
|
||||||
<h1 class="text-3xl font-heading text-light">{data.org.name}</h1>
|
<h1 class="text-h1 font-heading text-white">{data.org.name}</h1>
|
||||||
<p class="text-light/50 mt-1">Organization Overview</p>
|
<p class="text-body text-light/60 font-body">Organization Overview</p>
|
||||||
</header>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { createLogger } from '$lib/utils/logger';
|
||||||
|
|
||||||
|
const log = createLogger('page.calendar');
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ parent, locals }) => {
|
export const load: PageServerLoad = async ({ parent, locals }) => {
|
||||||
const { org, userRole } = await parent();
|
const { org, userRole } = await parent();
|
||||||
@@ -9,7 +12,7 @@ export const load: PageServerLoad = async ({ parent, locals }) => {
|
|||||||
const startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
const startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||||
const endDate = new Date(now.getFullYear(), now.getMonth() + 2, 0);
|
const endDate = new Date(now.getFullYear(), now.getMonth() + 2, 0);
|
||||||
|
|
||||||
const { data: events } = await supabase
|
const { data: events, error } = await supabase
|
||||||
.from('calendar_events')
|
.from('calendar_events')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('org_id', org.id)
|
.eq('org_id', org.id)
|
||||||
@@ -17,6 +20,10 @@ export const load: PageServerLoad = async ({ parent, locals }) => {
|
|||||||
.lte('end_time', endDate.toISOString())
|
.lte('end_time', endDate.toISOString())
|
||||||
.order('start_time');
|
.order('start_time');
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
log.error('Failed to load calendar events', { error, data: { orgId: org.id } });
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
events: events ?? [],
|
events: events ?? [],
|
||||||
userRole
|
userRole
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext, onMount } from "svelte";
|
import { getContext, onMount } from "svelte";
|
||||||
import { Button, Modal } from "$lib/components/ui";
|
import { Button, Modal, Avatar } from "$lib/components/ui";
|
||||||
import { Calendar } from "$lib/components/calendar";
|
import { Calendar } from "$lib/components/calendar";
|
||||||
import {
|
import {
|
||||||
getCalendarSubscribeUrl,
|
getCalendarSubscribeUrl,
|
||||||
@@ -24,6 +24,9 @@
|
|||||||
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
||||||
|
|
||||||
let events = $state(data.events);
|
let events = $state(data.events);
|
||||||
|
$effect(() => {
|
||||||
|
events = data.events;
|
||||||
|
});
|
||||||
let googleEvents = $state<CalendarEvent[]>([]);
|
let googleEvents = $state<CalendarEvent[]>([]);
|
||||||
let isOrgCalendarConnected = $state(false);
|
let isOrgCalendarConnected = $state(false);
|
||||||
let isLoadingGoogle = $state(false);
|
let isLoadingGoogle = $state(false);
|
||||||
@@ -133,56 +136,49 @@
|
|||||||
<title>Calendar - {data.org.name} | Root</title>
|
<title>Calendar - {data.org.name} | Root</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="p-6 h-full overflow-auto">
|
<div class="flex flex-col h-full p-4 lg:p-5 gap-4">
|
||||||
<header class="flex items-center justify-between mb-6">
|
<!-- Header -->
|
||||||
<div class="flex items-center gap-4">
|
<header class="flex items-center gap-2 p-1">
|
||||||
<h1 class="text-2xl font-bold text-light">Calendar</h1>
|
<Avatar name="Calendar" size="md" />
|
||||||
{#if isOrgCalendarConnected}
|
<h1 class="flex-1 font-heading text-h1 text-white">Calendar</h1>
|
||||||
<div class="flex items-center gap-2">
|
{#if isOrgCalendarConnected}
|
||||||
<span
|
<button
|
||||||
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-blue-500/10 text-blue-400 rounded-lg"
|
type="button"
|
||||||
>
|
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-primary/10 text-primary rounded-[32px] hover:bg-primary/20 transition-colors"
|
||||||
<svg class="w-4 h-4" viewBox="0 0 24 24">
|
onclick={subscribeToCalendar}
|
||||||
<path
|
title="Add to your Google Calendar"
|
||||||
fill="currentColor"
|
>
|
||||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
<span
|
||||||
/>
|
class="material-symbols-rounded"
|
||||||
</svg>
|
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
|
||||||
{orgCalendarName ?? "Google Calendar"}
|
>
|
||||||
{#if isLoadingGoogle}
|
add
|
||||||
<span class="animate-spin">⟳</span>
|
</span>
|
||||||
{/if}
|
Subscribe
|
||||||
</span>
|
</button>
|
||||||
<button
|
{/if}
|
||||||
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-green-500/10 text-green-400 rounded-lg hover:bg-green-500/20 transition-colors"
|
<button
|
||||||
onclick={subscribeToCalendar}
|
type="button"
|
||||||
title="Add to your Google Calendar"
|
class="p-1 hover:bg-dark rounded-lg transition-colors"
|
||||||
>
|
aria-label="More options"
|
||||||
<svg
|
>
|
||||||
class="w-4 h-4"
|
<span
|
||||||
viewBox="0 0 24 24"
|
class="material-symbols-rounded text-light"
|
||||||
fill="none"
|
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||||
stroke="currentColor"
|
>
|
||||||
stroke-width="2"
|
more_horiz
|
||||||
>
|
</span>
|
||||||
<path d="M12 5v14M5 12h14" />
|
</button>
|
||||||
</svg>
|
|
||||||
Subscribe
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<p class="text-light/50 text-sm mb-4">
|
<!-- Calendar Grid -->
|
||||||
View events from connected Google Calendar. Event creation coming soon.
|
<div class="flex-1 overflow-auto">
|
||||||
</p>
|
<Calendar
|
||||||
|
events={allEvents}
|
||||||
<Calendar
|
onDateClick={handleDateClick}
|
||||||
events={allEvents}
|
onEventClick={handleEventClick}
|
||||||
onDateClick={handleDateClick}
|
/>
|
||||||
onEventClick={handleEventClick}
|
</div>
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { createLogger } from '$lib/utils/logger';
|
||||||
|
|
||||||
|
const log = createLogger('page.documents');
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ parent, locals }) => {
|
export const load: PageServerLoad = async ({ parent, locals }) => {
|
||||||
const { org } = await parent();
|
const { org } = await parent();
|
||||||
const { supabase } = locals;
|
const { supabase } = locals;
|
||||||
|
|
||||||
const { data: documents } = await supabase
|
const { data: documents, error } = await supabase
|
||||||
.from('documents')
|
.from('documents')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('org_id', org.id)
|
.eq('org_id', org.id)
|
||||||
.order('name');
|
.order('name');
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
log.error('Failed to load documents', { error, data: { orgId: org.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug('Documents loaded', { data: { count: documents?.length ?? 0 } });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
documents: documents ?? []
|
documents: documents ?? []
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext } from "svelte";
|
import { FileBrowser } from "$lib/components/documents";
|
||||||
import { Button, Modal, Input } from "$lib/components/ui";
|
|
||||||
import { FileTree, Editor } from "$lib/components/documents";
|
|
||||||
import { buildDocumentTree } from "$lib/api/documents";
|
|
||||||
import type { Document } from "$lib/supabase/types";
|
import type { Document } from "$lib/supabase/types";
|
||||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
||||||
import type { Database } from "$lib/supabase/types";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: {
|
data: {
|
||||||
@@ -17,326 +12,21 @@
|
|||||||
|
|
||||||
let { data }: Props = $props();
|
let { data }: Props = $props();
|
||||||
|
|
||||||
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
|
||||||
|
|
||||||
let documents = $state(data.documents);
|
let documents = $state(data.documents);
|
||||||
let selectedDoc = $state<Document | null>(null);
|
$effect(() => {
|
||||||
let showCreateModal = $state(false);
|
documents = data.documents;
|
||||||
let showEditModal = $state(false);
|
});
|
||||||
let editingDoc = $state<Document | null>(null);
|
|
||||||
let newDocName = $state("");
|
|
||||||
let newDocType = $state<"folder" | "document">("document");
|
|
||||||
let parentFolderId = $state<string | null>(null);
|
|
||||||
let isEditing = $state(false);
|
|
||||||
|
|
||||||
const documentTree = $derived(buildDocumentTree(documents));
|
|
||||||
|
|
||||||
function handleSelect(doc: Document) {
|
|
||||||
if (doc.type === "document") {
|
|
||||||
selectedDoc = doc;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDoubleClick(doc: Document) {
|
|
||||||
if (doc.type === "document") {
|
|
||||||
// Open document in new window
|
|
||||||
const url = `/${data.org.slug}/documents/${doc.id}`;
|
|
||||||
window.open(url, "_blank", "width=900,height=700");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAdd(folderId: string | null) {
|
|
||||||
parentFolderId = folderId;
|
|
||||||
showCreateModal = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleMove(docId: string, newParentId: string | null) {
|
|
||||||
const { error } = await supabase
|
|
||||||
.from("documents")
|
|
||||||
.update({
|
|
||||||
parent_id: newParentId,
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
.eq("id", docId);
|
|
||||||
|
|
||||||
if (!error) {
|
|
||||||
documents = documents.map((d) =>
|
|
||||||
d.id === docId ? { ...d, parent_id: newParentId } : d,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreate() {
|
|
||||||
if (!newDocName.trim() || !data.user) return;
|
|
||||||
|
|
||||||
const { data: newDoc, error } = await supabase
|
|
||||||
.from("documents")
|
|
||||||
.insert({
|
|
||||||
org_id: data.org.id,
|
|
||||||
name: newDocName,
|
|
||||||
type: newDocType,
|
|
||||||
parent_id: parentFolderId,
|
|
||||||
created_by: data.user.id,
|
|
||||||
content:
|
|
||||||
newDocType === "document"
|
|
||||||
? { type: "doc", content: [] }
|
|
||||||
: null,
|
|
||||||
})
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (!error && newDoc) {
|
|
||||||
documents = [...documents, newDoc];
|
|
||||||
if (newDocType === "document") {
|
|
||||||
selectedDoc = newDoc;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
showCreateModal = false;
|
|
||||||
newDocName = "";
|
|
||||||
newDocType = "document";
|
|
||||||
parentFolderId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave(content: unknown) {
|
|
||||||
if (!selectedDoc) return;
|
|
||||||
|
|
||||||
await supabase
|
|
||||||
.from("documents")
|
|
||||||
.update({ content, updated_at: new Date().toISOString() })
|
|
||||||
.eq("id", selectedDoc.id);
|
|
||||||
|
|
||||||
documents = documents.map((d) =>
|
|
||||||
d.id === selectedDoc!.id ? { ...d, content } : d,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEdit(doc: Document) {
|
|
||||||
editingDoc = doc;
|
|
||||||
newDocName = doc.name;
|
|
||||||
showEditModal = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRename() {
|
|
||||||
if (!editingDoc || !newDocName.trim()) return;
|
|
||||||
|
|
||||||
const { error } = await supabase
|
|
||||||
.from("documents")
|
|
||||||
.update({ name: newDocName, updated_at: new Date().toISOString() })
|
|
||||||
.eq("id", editingDoc.id);
|
|
||||||
|
|
||||||
if (!error) {
|
|
||||||
documents = documents.map((d) =>
|
|
||||||
d.id === editingDoc!.id ? { ...d, name: newDocName } : d,
|
|
||||||
);
|
|
||||||
if (selectedDoc?.id === editingDoc.id) {
|
|
||||||
selectedDoc = { ...selectedDoc, name: newDocName };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
showEditModal = false;
|
|
||||||
editingDoc = null;
|
|
||||||
newDocName = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(doc: Document) {
|
|
||||||
const itemType =
|
|
||||||
doc.type === "folder" ? "folder and all its contents" : "document";
|
|
||||||
if (!confirm(`Delete this ${itemType}?`)) return;
|
|
||||||
|
|
||||||
// If deleting a folder, delete all children first
|
|
||||||
if (doc.type === "folder") {
|
|
||||||
const childIds = documents
|
|
||||||
.filter((d) => d.parent_id === doc.id)
|
|
||||||
.map((d) => d.id);
|
|
||||||
if (childIds.length > 0) {
|
|
||||||
await supabase.from("documents").delete().in("id", childIds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error } = await supabase
|
|
||||||
.from("documents")
|
|
||||||
.delete()
|
|
||||||
.eq("id", doc.id);
|
|
||||||
|
|
||||||
if (!error) {
|
|
||||||
documents = documents.filter(
|
|
||||||
(d) => d.id !== doc.id && d.parent_id !== doc.id,
|
|
||||||
);
|
|
||||||
if (selectedDoc?.id === doc.id) {
|
|
||||||
selectedDoc = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title
|
<title>Files - {data.org.name} | Root</title>
|
||||||
>{selectedDoc ? `${selectedDoc.name} - ` : ""}Documents - {data.org
|
|
||||||
.name} | Root</title
|
|
||||||
>
|
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="flex h-full">
|
<div class="h-full p-4 lg:p-5">
|
||||||
<aside class="w-72 border-r border-light/10 flex flex-col">
|
<FileBrowser
|
||||||
<div
|
org={data.org}
|
||||||
class="p-4 border-b border-light/10 flex items-center justify-between"
|
bind:documents
|
||||||
>
|
currentFolderId={null}
|
||||||
<h2 class="font-semibold text-light">Documents</h2>
|
user={data.user}
|
||||||
<Button size="sm" onclick={() => (showCreateModal = true)}>
|
/>
|
||||||
<svg
|
|
||||||
class="w-4 h-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<line x1="12" y1="5" x2="12" y2="19" />
|
|
||||||
<line x1="5" y1="12" x2="19" y2="12" />
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto p-2">
|
|
||||||
{#if documentTree.length === 0}
|
|
||||||
<div class="text-center text-light/40 py-8 text-sm">
|
|
||||||
<p>No documents yet</p>
|
|
||||||
<p class="mt-1">Create your first document</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<FileTree
|
|
||||||
items={documentTree}
|
|
||||||
selectedId={selectedDoc?.id ?? null}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
onDoubleClick={handleDoubleClick}
|
|
||||||
onAdd={handleAdd}
|
|
||||||
onMove={handleMove}
|
|
||||||
onEdit={handleEdit}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main class="flex-1 overflow-hidden flex flex-col">
|
|
||||||
{#if selectedDoc}
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-between p-4 border-b border-light/10"
|
|
||||||
>
|
|
||||||
<h2 class="text-lg font-semibold text-light">
|
|
||||||
{selectedDoc.name}
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {isEditing
|
|
||||||
? 'bg-primary text-white'
|
|
||||||
: 'bg-light/10 text-light hover:bg-light/20'}"
|
|
||||||
onclick={() => (isEditing = !isEditing)}
|
|
||||||
>
|
|
||||||
{isEditing ? "Preview" : "Edit"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 overflow-auto">
|
|
||||||
<Editor
|
|
||||||
document={selectedDoc}
|
|
||||||
onSave={handleSave}
|
|
||||||
editable={isEditing}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="h-full flex items-center justify-center text-light/40">
|
|
||||||
<div class="text-center">
|
|
||||||
<svg
|
|
||||||
class="w-16 h-16 mx-auto mb-4 opacity-50"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
|
||||||
/>
|
|
||||||
<polyline points="14,2 14,8 20,8" />
|
|
||||||
</svg>
|
|
||||||
<p>Select a document to edit</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal
|
|
||||||
isOpen={showCreateModal}
|
|
||||||
onClose={() => (showCreateModal = false)}
|
|
||||||
title="Create New"
|
|
||||||
>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button
|
|
||||||
class="flex-1 py-2 px-4 rounded-lg border transition-colors {newDocType ===
|
|
||||||
'document'
|
|
||||||
? 'border-primary bg-primary/10'
|
|
||||||
: 'border-light/20'}"
|
|
||||||
onclick={() => (newDocType = "document")}
|
|
||||||
>
|
|
||||||
Document
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="flex-1 py-2 px-4 rounded-lg border transition-colors {newDocType ===
|
|
||||||
'folder'
|
|
||||||
? 'border-primary bg-primary/10'
|
|
||||||
: 'border-light/20'}"
|
|
||||||
onclick={() => (newDocType = "folder")}
|
|
||||||
>
|
|
||||||
Folder
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
label="Name"
|
|
||||||
bind:value={newDocName}
|
|
||||||
placeholder={newDocType === "folder"
|
|
||||||
? "Folder name"
|
|
||||||
: "Document name"}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-2 pt-2">
|
|
||||||
<Button variant="ghost" onclick={() => (showCreateModal = false)}
|
|
||||||
>Cancel</Button
|
|
||||||
>
|
|
||||||
<Button onclick={handleCreate} disabled={!newDocName.trim()}
|
|
||||||
>Create</Button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
isOpen={showEditModal}
|
|
||||||
onClose={() => {
|
|
||||||
showEditModal = false;
|
|
||||||
editingDoc = null;
|
|
||||||
newDocName = "";
|
|
||||||
}}
|
|
||||||
title="Rename"
|
|
||||||
>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<Input
|
|
||||||
label="Name"
|
|
||||||
bind:value={newDocName}
|
|
||||||
placeholder="Enter new name"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-2 pt-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onclick={() => {
|
|
||||||
showEditModal = false;
|
|
||||||
editingDoc = null;
|
|
||||||
newDocName = "";
|
|
||||||
}}>Cancel</Button
|
|
||||||
>
|
|
||||||
<Button onclick={handleRename} disabled={!newDocName.trim()}
|
|
||||||
>Save</Button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|||||||
31
src/routes/[orgSlug]/documents/[id]/+page.server.ts
Normal file
31
src/routes/[orgSlug]/documents/[id]/+page.server.ts
Normal file
@@ -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}`);
|
||||||
|
};
|
||||||
9
src/routes/[orgSlug]/documents/[id]/+page.svelte
Normal file
9
src/routes/[orgSlug]/documents/[id]/+page.svelte
Normal file
@@ -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>
|
||||||
39
src/routes/[orgSlug]/documents/file/[id]/+page.server.ts
Normal file
39
src/routes/[orgSlug]/documents/file/[id]/+page.server.ts
Normal file
@@ -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
|
||||||
|
};
|
||||||
|
};
|
||||||
572
src/routes/[orgSlug]/documents/file/[id]/+page.svelte
Normal file
572
src/routes/[orgSlug]/documents/file/[id]/+page.svelte
Normal file
@@ -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>
|
||||||
43
src/routes/[orgSlug]/documents/folder/[id]/+page.server.ts
Normal file
43
src/routes/[orgSlug]/documents/folder/[id]/+page.server.ts
Normal file
@@ -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
|
||||||
|
};
|
||||||
|
};
|
||||||
34
src/routes/[orgSlug]/documents/folder/[id]/+page.svelte
Normal file
34
src/routes/[orgSlug]/documents/folder/[id]/+page.svelte
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { FileBrowser } from "$lib/components/documents";
|
||||||
|
import type { Document } from "$lib/supabase/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: {
|
||||||
|
org: { id: string; name: string; slug: string };
|
||||||
|
folder: Document;
|
||||||
|
documents: Document[];
|
||||||
|
user: { id: string } | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
let documents = $state(data.documents);
|
||||||
|
$effect(() => {
|
||||||
|
documents = data.documents;
|
||||||
|
});
|
||||||
|
const currentFolderId = $derived(data.folder.id);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{data.folder.name} - {data.org.name} | Root</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="h-full p-4 lg:p-5">
|
||||||
|
<FileBrowser
|
||||||
|
org={data.org}
|
||||||
|
bind:documents
|
||||||
|
{currentFolderId}
|
||||||
|
user={data.user}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
@@ -1,15 +1,22 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { createLogger } from '$lib/utils/logger';
|
||||||
|
|
||||||
|
const log = createLogger('page.kanban');
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ parent, locals }) => {
|
export const load: PageServerLoad = async ({ parent, locals }) => {
|
||||||
const { org } = await parent();
|
const { org } = await parent();
|
||||||
const { supabase } = locals;
|
const { supabase } = locals;
|
||||||
|
|
||||||
const { data: boards } = await supabase
|
const { data: boards, error } = await supabase
|
||||||
.from('kanban_boards')
|
.from('kanban_boards')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('org_id', org.id)
|
.eq('org_id', org.id)
|
||||||
.order('created_at');
|
.order('created_at');
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
log.error('Failed to load kanban boards', { error, data: { orgId: org.id } });
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
boards: boards ?? []
|
boards: boards ?? []
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext } from "svelte";
|
import { getContext, onDestroy } from "svelte";
|
||||||
import { Button, Card, Modal, Input } from "$lib/components/ui";
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Modal,
|
||||||
|
Input,
|
||||||
|
Avatar,
|
||||||
|
IconButton,
|
||||||
|
Icon,
|
||||||
|
} from "$lib/components/ui";
|
||||||
import { KanbanBoard, CardDetailModal } from "$lib/components/kanban";
|
import { KanbanBoard, CardDetailModal } from "$lib/components/kanban";
|
||||||
import {
|
import {
|
||||||
fetchBoardWithColumns,
|
fetchBoardWithColumns,
|
||||||
createBoard,
|
createBoard,
|
||||||
moveCard,
|
moveCard,
|
||||||
|
subscribeToBoard,
|
||||||
} from "$lib/api/kanban";
|
} from "$lib/api/kanban";
|
||||||
|
import type { RealtimeChannel } from "@supabase/supabase-js";
|
||||||
import type {
|
import type {
|
||||||
KanbanBoard as KanbanBoardType,
|
KanbanBoard as KanbanBoardType,
|
||||||
KanbanCard,
|
KanbanCard,
|
||||||
@@ -28,6 +38,9 @@
|
|||||||
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
||||||
|
|
||||||
let boards = $state(data.boards);
|
let boards = $state(data.boards);
|
||||||
|
$effect(() => {
|
||||||
|
boards = data.boards;
|
||||||
|
});
|
||||||
let selectedBoard = $state<BoardWithColumns | null>(null);
|
let selectedBoard = $state<BoardWithColumns | null>(null);
|
||||||
let showCreateBoardModal = $state(false);
|
let showCreateBoardModal = $state(false);
|
||||||
let showEditBoardModal = $state(false);
|
let showEditBoardModal = $state(false);
|
||||||
@@ -35,15 +48,49 @@
|
|||||||
let selectedCard = $state<KanbanCard | null>(null);
|
let selectedCard = $state<KanbanCard | null>(null);
|
||||||
let newBoardName = $state("");
|
let newBoardName = $state("");
|
||||||
let editBoardName = $state("");
|
let editBoardName = $state("");
|
||||||
let newBoardVisibility = $state<"team" | "personal">("team");
|
|
||||||
let editBoardVisibility = $state<"team" | "personal">("team");
|
|
||||||
let targetColumnId = $state<string | null>(null);
|
let targetColumnId = $state<string | null>(null);
|
||||||
let cardModalMode = $state<"edit" | "create">("edit");
|
let cardModalMode = $state<"edit" | "create">("edit");
|
||||||
|
let realtimeChannel = $state<RealtimeChannel | null>(null);
|
||||||
|
|
||||||
async function loadBoard(boardId: string) {
|
async function loadBoard(boardId: string) {
|
||||||
selectedBoard = await fetchBoardWithColumns(supabase, boardId);
|
selectedBoard = await fetchBoardWithColumns(supabase, boardId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Realtime subscription with proper cleanup
|
||||||
|
$effect(() => {
|
||||||
|
const board = selectedBoard;
|
||||||
|
if (!board) return;
|
||||||
|
|
||||||
|
// Subscribe to realtime changes for this board
|
||||||
|
const channel = subscribeToBoard(
|
||||||
|
supabase,
|
||||||
|
board.id,
|
||||||
|
() => {
|
||||||
|
// Column changed - reload board data
|
||||||
|
loadBoard(board.id);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Card changed - reload board data
|
||||||
|
loadBoard(board.id);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
realtimeChannel = channel;
|
||||||
|
|
||||||
|
// Cleanup function - unsubscribe when board changes or component unmounts
|
||||||
|
return () => {
|
||||||
|
if (channel) {
|
||||||
|
supabase.removeChannel(channel);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Additional cleanup on component destroy
|
||||||
|
onDestroy(() => {
|
||||||
|
if (realtimeChannel) {
|
||||||
|
supabase.removeChannel(realtimeChannel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function handleCreateBoard() {
|
async function handleCreateBoard() {
|
||||||
if (!newBoardName.trim()) return;
|
if (!newBoardName.trim()) return;
|
||||||
|
|
||||||
@@ -58,8 +105,6 @@
|
|||||||
let editingBoardId = $state<string | null>(null);
|
let editingBoardId = $state<string | null>(null);
|
||||||
let showAddColumnModal = $state(false);
|
let showAddColumnModal = $state(false);
|
||||||
let newColumnName = $state("");
|
let newColumnName = $state("");
|
||||||
let sidebarCollapsed = $state(false);
|
|
||||||
|
|
||||||
function openEditBoardModal(board: KanbanBoardType) {
|
function openEditBoardModal(board: KanbanBoardType) {
|
||||||
editingBoardId = board.id;
|
editingBoardId = board.id;
|
||||||
editBoardName = board.name;
|
editBoardName = board.name;
|
||||||
@@ -254,127 +299,43 @@
|
|||||||
>
|
>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="flex h-full">
|
<div class="flex flex-col h-full p-4 lg:p-5 gap-4">
|
||||||
<aside
|
<!-- Header -->
|
||||||
class="{sidebarCollapsed
|
<header class="flex items-center gap-2 p-1">
|
||||||
? 'w-12'
|
<Avatar name="Kanban" size="md" />
|
||||||
: 'w-64'} border-r border-light/10 flex flex-col transition-all duration-200"
|
<h1 class="flex-1 font-heading text-h1 text-white">Kanban</h1>
|
||||||
>
|
<Button size="md" onclick={() => (showCreateBoardModal = true)}
|
||||||
<div
|
>+ New</Button
|
||||||
class="p-2 border-b border-light/10 flex items-center {sidebarCollapsed
|
|
||||||
? 'justify-center'
|
|
||||||
: 'justify-between gap-2'}"
|
|
||||||
>
|
>
|
||||||
{#if !sidebarCollapsed}
|
<IconButton
|
||||||
<h2 class="font-semibold text-light px-2">Boards</h2>
|
title="More options"
|
||||||
<Button size="sm" onclick={() => (showCreateBoardModal = true)}>
|
onclick={() => selectedBoard && openEditBoardModal(selectedBoard)}
|
||||||
<svg
|
>
|
||||||
class="w-4 h-4"
|
<Icon name="more_horiz" size={24} />
|
||||||
viewBox="0 0 24 24"
|
</IconButton>
|
||||||
fill="none"
|
</header>
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
<!-- Board selector (compact) -->
|
||||||
>
|
{#if boards.length > 1}
|
||||||
<line x1="12" y1="5" x2="12" y2="19" />
|
<div class="flex gap-2 overflow-x-auto pb-2">
|
||||||
<line x1="5" y1="12" x2="19" y2="12" />
|
{#each boards as board}
|
||||||
</svg>
|
<button
|
||||||
</Button>
|
type="button"
|
||||||
{/if}
|
class="px-4 py-2 rounded-[32px] text-sm font-body whitespace-nowrap transition-colors {selectedBoard?.id ===
|
||||||
<button
|
board.id
|
||||||
class="p-1.5 rounded-lg hover:bg-light/10 text-light/50 hover:text-light transition-colors"
|
? 'bg-primary text-night'
|
||||||
onclick={() => (sidebarCollapsed = !sidebarCollapsed)}
|
: 'bg-dark text-light hover:bg-dark/80'}"
|
||||||
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
onclick={() => loadBoard(board.id)}
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-4 h-4 transition-transform {sidebarCollapsed
|
|
||||||
? 'rotate-180'
|
|
||||||
: ''}"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
>
|
||||||
<path d="m11 17-5-5 5-5M17 17l-5-5 5-5" />
|
{board.name}
|
||||||
</svg>
|
</button>
|
||||||
</button>
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto p-2 space-y-1">
|
<!-- Kanban Board -->
|
||||||
{#if boards.length === 0}
|
<div class="flex-1 overflow-hidden">
|
||||||
<div class="text-center text-light/40 py-8 text-sm">
|
|
||||||
<p>No boards yet</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
{#each boards as board}
|
|
||||||
<div
|
|
||||||
class="group flex items-center gap-1 px-3 py-2 rounded-lg text-sm transition-colors cursor-pointer {selectedBoard?.id ===
|
|
||||||
board.id
|
|
||||||
? 'bg-primary text-white'
|
|
||||||
: 'text-light/70 hover:bg-light/5'}"
|
|
||||||
onclick={() => loadBoard(board.id)}
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
<span class="flex-1 truncate">{board.name}</span>
|
|
||||||
<div
|
|
||||||
class="opacity-0 group-hover:opacity-100 flex items-center gap-0.5 transition-opacity"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="p-1 rounded hover:bg-light/20"
|
|
||||||
onclick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
openEditBoardModal(board);
|
|
||||||
}}
|
|
||||||
title="Rename"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-3.5 h-3.5"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="p-1 rounded hover:bg-error/20 hover:text-error"
|
|
||||||
onclick={(e) => handleDeleteBoard(e, board)}
|
|
||||||
title="Delete"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-3.5 h-3.5"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<polyline points="3,6 5,6 21,6" />
|
|
||||||
<path
|
|
||||||
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main class="flex-1 overflow-hidden p-6">
|
|
||||||
{#if selectedBoard}
|
{#if selectedBoard}
|
||||||
<header class="mb-6">
|
|
||||||
<h1 class="text-2xl font-bold text-light">
|
|
||||||
{selectedBoard.name}
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<KanbanBoard
|
<KanbanBoard
|
||||||
columns={selectedBoard.columns}
|
columns={selectedBoard.columns}
|
||||||
onCardClick={handleCardClick}
|
onCardClick={handleCardClick}
|
||||||
@@ -384,25 +345,30 @@
|
|||||||
onDeleteCard={handleCardDelete}
|
onDeleteCard={handleCardDelete}
|
||||||
onDeleteColumn={handleDeleteColumn}
|
onDeleteColumn={handleDeleteColumn}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else if boards.length === 0}
|
||||||
<div class="h-full flex items-center justify-center text-light/40">
|
<div class="h-full flex items-center justify-center text-light/40">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<svg
|
<span
|
||||||
class="w-16 h-16 mx-auto mb-4 opacity-50"
|
class="material-symbols-rounded text-light/30 mb-4 block"
|
||||||
viewBox="0 0 24 24"
|
style="font-size: 64px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48;"
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
>
|
>
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
view_kanban
|
||||||
<line x1="9" y1="3" x2="9" y2="21" />
|
</span>
|
||||||
<line x1="15" y1="3" x2="15" y2="21" />
|
<p class="mb-4">Kanban boards are now managed in Files</p>
|
||||||
</svg>
|
<Button
|
||||||
<p>Select a board or create a new one</p>
|
onclick={() =>
|
||||||
|
(window.location.href = `/${data.org.slug}/documents`)}
|
||||||
|
>
|
||||||
|
Go to Files
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="h-full flex items-center justify-center text-light/40">
|
||||||
|
<p>Select a board above</p>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
@@ -418,7 +384,7 @@
|
|||||||
/>
|
/>
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="tertiary"
|
||||||
onclick={() => (showCreateBoardModal = false)}>Cancel</Button
|
onclick={() => (showCreateBoardModal = false)}>Cancel</Button
|
||||||
>
|
>
|
||||||
<Button onclick={handleCreateBoard} disabled={!newBoardName.trim()}
|
<Button onclick={handleCreateBoard} disabled={!newBoardName.trim()}
|
||||||
@@ -440,8 +406,9 @@
|
|||||||
placeholder="Board name"
|
placeholder="Board name"
|
||||||
/>
|
/>
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<Button variant="ghost" onclick={() => (showEditBoardModal = false)}
|
<Button
|
||||||
>Cancel</Button
|
variant="tertiary"
|
||||||
|
onclick={() => (showEditBoardModal = false)}>Cancel</Button
|
||||||
>
|
>
|
||||||
<Button onclick={handleEditBoard} disabled={!editBoardName.trim()}
|
<Button onclick={handleEditBoard} disabled={!editBoardName.trim()}
|
||||||
>Save</Button
|
>Save</Button
|
||||||
@@ -462,8 +429,9 @@
|
|||||||
placeholder="e.g. To Do, In Progress, Done"
|
placeholder="e.g. To Do, In Progress, Done"
|
||||||
/>
|
/>
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<Button variant="ghost" onclick={() => (showAddColumnModal = false)}
|
<Button
|
||||||
>Cancel</Button
|
variant="tertiary"
|
||||||
|
onclick={() => (showAddColumnModal = false)}>Cancel</Button
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
onclick={handleCreateColumn}
|
onclick={handleCreateColumn}
|
||||||
@@ -486,5 +454,6 @@
|
|||||||
mode={cardModalMode}
|
mode={cardModalMode}
|
||||||
columnId={targetColumnId ?? undefined}
|
columnId={targetColumnId ?? undefined}
|
||||||
userId={data.user?.id}
|
userId={data.user?.id}
|
||||||
|
orgId={data.org.id}
|
||||||
onCreate={handleCardCreated}
|
onCreate={handleCardCreated}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,61 +1,64 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { createLogger } from '$lib/utils/logger';
|
||||||
|
|
||||||
|
const log = createLogger('page.settings');
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ parent, locals }) => {
|
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
|
// Only admins and owners can access settings
|
||||||
if (userRole !== 'owner' && userRole !== 'admin') {
|
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
|
// Fetch all settings data in parallel
|
||||||
const { data: members } = await locals.supabase
|
const [membersResult, rolesResult, invitesResult, calendarResult] = await Promise.all([
|
||||||
.from('org_members')
|
// Get org members with profiles
|
||||||
.select(`
|
locals.supabase
|
||||||
id,
|
.from('org_members')
|
||||||
user_id,
|
.select(`
|
||||||
role,
|
|
||||||
role_id,
|
|
||||||
created_at,
|
|
||||||
profiles:user_id (
|
|
||||||
id,
|
id,
|
||||||
email,
|
user_id,
|
||||||
full_name,
|
role,
|
||||||
avatar_url
|
role_id,
|
||||||
)
|
created_at,
|
||||||
`)
|
profiles:user_id (
|
||||||
.eq('org_id', orgId);
|
id,
|
||||||
|
email,
|
||||||
// Get org roles
|
full_name,
|
||||||
const { data: roles } = await locals.supabase
|
avatar_url
|
||||||
.from('org_roles')
|
)
|
||||||
.select('*')
|
`)
|
||||||
.eq('org_id', orgId)
|
.eq('org_id', orgId),
|
||||||
.order('position');
|
// Get org roles
|
||||||
|
locals.supabase
|
||||||
// Get pending invites
|
.from('org_roles')
|
||||||
const { data: invites } = await locals.supabase
|
.select('*')
|
||||||
.from('org_invites')
|
.eq('org_id', orgId)
|
||||||
.select('*')
|
.order('position'),
|
||||||
.eq('org_id', orgId)
|
// Get pending invites
|
||||||
.is('accepted_at', null)
|
locals.supabase
|
||||||
.gt('expires_at', new Date().toISOString());
|
.from('org_invites')
|
||||||
|
.select('*')
|
||||||
// Get org Google Calendar connection
|
.eq('org_id', orgId)
|
||||||
const { data: orgCalendar } = await locals.supabase
|
.is('accepted_at', null)
|
||||||
.from('org_google_calendars')
|
.gt('expires_at', new Date().toISOString()),
|
||||||
.select('*')
|
// Get org Google Calendar connection
|
||||||
.eq('org_id', orgId)
|
locals.supabase
|
||||||
.single();
|
.from('org_google_calendars')
|
||||||
|
.select('*')
|
||||||
|
.eq('org_id', orgId)
|
||||||
|
.single()
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
members: members ?? [],
|
members: membersResult.data ?? [],
|
||||||
roles: roles ?? [],
|
roles: rolesResult.data ?? [],
|
||||||
invites: invites ?? [],
|
invites: invitesResult.data ?? [],
|
||||||
orgCalendar,
|
orgCalendar: calendarResult.data,
|
||||||
userRole
|
userRole
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,12 +2,22 @@
|
|||||||
import { getContext, onMount } from "svelte";
|
import { getContext, onMount } from "svelte";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import { invalidateAll } from "$app/navigation";
|
import { invalidateAll } from "$app/navigation";
|
||||||
import { Button, Modal, Card, Input } from "$lib/components/ui";
|
import {
|
||||||
|
Button,
|
||||||
|
Modal,
|
||||||
|
Card,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Icon,
|
||||||
|
Avatar,
|
||||||
|
IconButton,
|
||||||
|
} from "$lib/components/ui";
|
||||||
|
import { SettingsGeneral } from "$lib/components/settings";
|
||||||
import {
|
import {
|
||||||
extractCalendarId,
|
extractCalendarId,
|
||||||
getCalendarSubscribeUrl,
|
getCalendarSubscribeUrl,
|
||||||
} from "$lib/api/google-calendar";
|
} from "$lib/api/google-calendar";
|
||||||
import { theme, PRESET_COLORS, type ThemeMode } from "$lib/stores/theme";
|
import { toasts } from "$lib/stores/toast.svelte";
|
||||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
import type { Database } from "$lib/supabase/types";
|
import type { Database } from "$lib/supabase/types";
|
||||||
|
|
||||||
@@ -18,18 +28,20 @@
|
|||||||
calendar_name: string | null;
|
calendar_name: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProfileData {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
full_name: string | null;
|
||||||
|
avatar_url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface Member {
|
interface Member {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
role: string;
|
role: string;
|
||||||
role_id: string | null;
|
role_id: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
profiles: {
|
profiles: ProfileData | ProfileData[] | null;
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
full_name: string | null;
|
|
||||||
avatar_url: string | null;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OrgRole {
|
interface OrgRole {
|
||||||
@@ -55,7 +67,12 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: {
|
data: {
|
||||||
org: { id: string; name: string; slug: string };
|
org: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
avatar_url?: string | null;
|
||||||
|
};
|
||||||
user: { id: string; email?: string } | null;
|
user: { id: string; email?: string } | null;
|
||||||
userRole: string;
|
userRole: string;
|
||||||
members: Member[];
|
members: Member[];
|
||||||
@@ -70,14 +87,16 @@
|
|||||||
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
||||||
|
|
||||||
// Active tab
|
// Active tab
|
||||||
let activeTab = $state<
|
let activeTab = $state<"general" | "members" | "roles" | "integrations">(
|
||||||
"general" | "members" | "roles" | "integrations" | "appearance"
|
"general",
|
||||||
>("general");
|
);
|
||||||
|
|
||||||
// General settings state
|
const tabs: { id: typeof activeTab; label: string }[] = [
|
||||||
let orgName = $state(data.org.name);
|
{ id: "general", label: "General" },
|
||||||
let orgSlug = $state(data.org.slug);
|
{ id: "members", label: "Members" },
|
||||||
let isSavingGeneral = $state(false);
|
{ id: "roles", label: "Roles" },
|
||||||
|
{ id: "integrations", label: "Integrations" },
|
||||||
|
];
|
||||||
|
|
||||||
// Members state
|
// Members state
|
||||||
let members = $state<Member[]>(data.members as Member[]);
|
let members = $state<Member[]>(data.members as Member[]);
|
||||||
@@ -176,18 +195,45 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// General settings functions
|
async function deleteOrganization() {
|
||||||
async function saveGeneralSettings() {
|
if (!isOwner) return;
|
||||||
isSavingGeneral = true;
|
const confirmText = prompt(
|
||||||
|
`Type "${data.org.name}" to confirm deletion:`,
|
||||||
|
);
|
||||||
|
if (confirmText !== data.org.name) return;
|
||||||
|
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from("organizations")
|
.from("organizations")
|
||||||
.update({ name: orgName, slug: orgSlug })
|
.delete()
|
||||||
.eq("id", data.org.id);
|
.eq("id", data.org.id);
|
||||||
|
if (error) {
|
||||||
if (!error && orgSlug !== data.org.slug) {
|
toasts.error("Failed to delete organization.");
|
||||||
window.location.href = `/${orgSlug}/settings`;
|
return;
|
||||||
}
|
}
|
||||||
isSavingGeneral = false;
|
window.location.href = "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function leaveOrganization() {
|
||||||
|
if (isOwner) {
|
||||||
|
toasts.error(
|
||||||
|
"Owners cannot leave. Transfer ownership first or delete the organization.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!confirm(`Are you sure you want to leave ${data.org.name}?`))
|
||||||
|
return;
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("org_members")
|
||||||
|
.delete()
|
||||||
|
.eq("org_id", data.org.id)
|
||||||
|
.eq("user_id", data.user!.id);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toasts.error("Failed to leave organization.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = "/";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Member functions
|
// Member functions
|
||||||
@@ -219,13 +265,12 @@
|
|||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (!error && invite) {
|
if (!error && invite) {
|
||||||
// Remove old invite from UI if exists
|
|
||||||
invites = invites.filter((i) => i.email !== email);
|
invites = invites.filter((i) => i.email !== email);
|
||||||
invites = [...invites, invite as Invite];
|
invites = [...invites, invite as Invite];
|
||||||
inviteEmail = "";
|
inviteEmail = "";
|
||||||
showInviteModal = false;
|
showInviteModal = false;
|
||||||
} else if (error) {
|
} else if (error) {
|
||||||
alert("Failed to send invite: " + error.message);
|
toasts.error("Failed to send invite: " + error.message);
|
||||||
}
|
}
|
||||||
isSendingInvite = false;
|
isSendingInvite = false;
|
||||||
}
|
}
|
||||||
@@ -243,11 +288,15 @@
|
|||||||
|
|
||||||
async function updateMemberRole() {
|
async function updateMemberRole() {
|
||||||
if (!selectedMember) return;
|
if (!selectedMember) return;
|
||||||
await supabase
|
const { error } = await supabase
|
||||||
.from("org_members")
|
.from("org_members")
|
||||||
.update({ role: selectedMemberRole })
|
.update({ role: selectedMemberRole })
|
||||||
.eq("id", selectedMember.id);
|
.eq("id", selectedMember.id);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toasts.error("Failed to update role.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
members = members.map((m) =>
|
members = members.map((m) =>
|
||||||
m.id === selectedMember!.id
|
m.id === selectedMember!.id
|
||||||
? { ...m, role: selectedMemberRole }
|
? { ...m, role: selectedMemberRole }
|
||||||
@@ -258,14 +307,23 @@
|
|||||||
|
|
||||||
async function removeMember() {
|
async function removeMember() {
|
||||||
if (!selectedMember) return;
|
if (!selectedMember) return;
|
||||||
|
const rp = selectedMember.profiles;
|
||||||
|
const prof = Array.isArray(rp) ? rp[0] : rp;
|
||||||
if (
|
if (
|
||||||
!confirm(
|
!confirm(
|
||||||
`Remove ${selectedMember.profiles.full_name || selectedMember.profiles.email} from the organization?`,
|
`Remove ${prof?.full_name || prof?.email || "this member"} from the organization?`,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
await supabase.from("org_members").delete().eq("id", selectedMember.id);
|
const { error } = await supabase
|
||||||
|
.from("org_members")
|
||||||
|
.delete()
|
||||||
|
.eq("id", selectedMember.id);
|
||||||
|
if (error) {
|
||||||
|
toasts.error("Failed to remove member.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
members = members.filter((m) => m.id !== selectedMember!.id);
|
members = members.filter((m) => m.id !== selectedMember!.id);
|
||||||
showMemberModal = false;
|
showMemberModal = false;
|
||||||
}
|
}
|
||||||
@@ -348,7 +406,14 @@
|
|||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
await supabase.from("org_roles").delete().eq("id", role.id);
|
const { error } = await supabase
|
||||||
|
.from("org_roles")
|
||||||
|
.delete()
|
||||||
|
.eq("id", role.id);
|
||||||
|
if (error) {
|
||||||
|
toasts.error("Failed to delete role.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
roles = roles.filter((r) => r.id !== role.id);
|
roles = roles.filter((r) => r.id !== role.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,192 +482,56 @@
|
|||||||
|
|
||||||
async function disconnectOrgCalendar() {
|
async function disconnectOrgCalendar() {
|
||||||
if (!confirm("Disconnect Google Calendar?")) return;
|
if (!confirm("Disconnect Google Calendar?")) return;
|
||||||
await supabase
|
const { error } = await supabase
|
||||||
.from("org_google_calendars")
|
.from("org_google_calendars")
|
||||||
.delete()
|
.delete()
|
||||||
.eq("org_id", data.org.id);
|
.eq("org_id", data.org.id);
|
||||||
|
if (error) {
|
||||||
|
toasts.error("Failed to disconnect calendar.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
orgCalendar = null;
|
orgCalendar = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteOrganization() {
|
|
||||||
if (!isOwner) return;
|
|
||||||
const confirmText = prompt(
|
|
||||||
`Type "${data.org.name}" to confirm deletion:`,
|
|
||||||
);
|
|
||||||
if (confirmText !== data.org.name) return;
|
|
||||||
|
|
||||||
await supabase.from("organizations").delete().eq("id", data.org.id);
|
|
||||||
window.location.href = "/";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function leaveOrganization() {
|
|
||||||
if (isOwner) {
|
|
||||||
alert(
|
|
||||||
"Owners cannot leave. Transfer ownership first or delete the organization.",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!confirm(`Are you sure you want to leave ${data.org.name}?`))
|
|
||||||
return;
|
|
||||||
|
|
||||||
const { error } = await supabase
|
|
||||||
.from("org_members")
|
|
||||||
.delete()
|
|
||||||
.eq("org_id", data.org.id)
|
|
||||||
.eq("user_id", data.user?.id);
|
|
||||||
|
|
||||||
if (!error) {
|
|
||||||
window.location.href = "/";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Settings - {data.org.name} | Root</title>
|
<title>Settings - {data.org.name} | Root</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="p-6 h-full overflow-auto">
|
<div class="flex flex-col h-full p-4 lg:p-5 gap-4 overflow-auto">
|
||||||
<header class="mb-6">
|
<!-- Header -->
|
||||||
<h1 class="text-2xl font-bold text-light">Settings</h1>
|
<div class="flex flex-col gap-4">
|
||||||
<p class="text-light/50 mt-1">Manage {data.org.name}</p>
|
<header class="flex flex-wrap items-center gap-2 p-1 rounded-[32px]">
|
||||||
</header>
|
<Avatar name="Settings" size="md" />
|
||||||
|
<h1 class="flex-1 font-heading text-h1 text-white">Settings</h1>
|
||||||
|
<IconButton title="More options">
|
||||||
|
<Icon name="more_horiz" size={24} />
|
||||||
|
</IconButton>
|
||||||
|
</header>
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Pill Tab Navigation -->
|
||||||
<div class="flex gap-1 mb-6 border-b border-light/10">
|
<div class="flex flex-wrap gap-4">
|
||||||
<button
|
{#each tabs as tab}
|
||||||
class="px-4 py-2 text-sm font-medium transition-colors {activeTab ===
|
<Button
|
||||||
'general'
|
variant={activeTab === tab.id ? "primary" : "secondary"}
|
||||||
? 'text-primary border-b-2 border-primary'
|
size="md"
|
||||||
: 'text-light/50 hover:text-light'}"
|
onclick={() => (activeTab = tab.id)}
|
||||||
onclick={() => (activeTab = "general")}>General</button
|
>
|
||||||
>
|
{tab.label}
|
||||||
<button
|
</Button>
|
||||||
class="px-4 py-2 text-sm font-medium transition-colors {activeTab ===
|
{/each}
|
||||||
'members'
|
</div>
|
||||||
? 'text-primary border-b-2 border-primary'
|
|
||||||
: 'text-light/50 hover:text-light'}"
|
|
||||||
onclick={() => (activeTab = "members")}>Members</button
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="px-4 py-2 text-sm font-medium transition-colors {activeTab ===
|
|
||||||
'roles'
|
|
||||||
? 'text-primary border-b-2 border-primary'
|
|
||||||
: 'text-light/50 hover:text-light'}"
|
|
||||||
onclick={() => (activeTab = "roles")}>Roles</button
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="px-4 py-2 text-sm font-medium transition-colors {activeTab ===
|
|
||||||
'integrations'
|
|
||||||
? 'text-primary border-b-2 border-primary'
|
|
||||||
: 'text-light/50 hover:text-light'}"
|
|
||||||
onclick={() => (activeTab = "integrations")}>Integrations</button
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="px-4 py-2 text-sm font-medium transition-colors {activeTab ===
|
|
||||||
'appearance'
|
|
||||||
? 'text-primary border-b-2 border-primary'
|
|
||||||
: 'text-light/50 hover:text-light'}"
|
|
||||||
onclick={() => (activeTab = "appearance")}>Appearance</button
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- General Tab -->
|
<!-- General Tab -->
|
||||||
{#if activeTab === "general"}
|
{#if activeTab === "general"}
|
||||||
<div class="space-y-6 max-w-2xl">
|
<SettingsGeneral
|
||||||
<Card>
|
{supabase}
|
||||||
<div class="p-6">
|
org={data.org}
|
||||||
<h2 class="text-lg font-semibold text-light mb-4">
|
{isOwner}
|
||||||
Organization Details
|
onLeave={leaveOrganization}
|
||||||
</h2>
|
onDelete={deleteOrganization}
|
||||||
|
/>
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="org-name"
|
|
||||||
class="block text-sm font-medium text-light mb-1"
|
|
||||||
>Name</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id="org-name"
|
|
||||||
type="text"
|
|
||||||
bind:value={orgName}
|
|
||||||
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-light focus:outline-none focus:border-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="org-slug"
|
|
||||||
class="block text-sm font-medium text-light mb-1"
|
|
||||||
>URL Slug</label
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-light/40 text-sm"
|
|
||||||
>yoursite.com/</span
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id="org-slug"
|
|
||||||
type="text"
|
|
||||||
bind:value={orgSlug}
|
|
||||||
class="flex-1 px-3 py-2 bg-dark border border-light/20 rounded-lg text-light font-mono text-sm focus:outline-none focus:border-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-light/40 mt-1">
|
|
||||||
Changing the slug will update all URLs for this
|
|
||||||
organization.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="pt-2">
|
|
||||||
<Button
|
|
||||||
onclick={saveGeneralSettings}
|
|
||||||
loading={isSavingGeneral}>Save Changes</Button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{#if !isOwner}
|
|
||||||
<Card>
|
|
||||||
<div class="p-6 border-l-4 border-warning">
|
|
||||||
<h2 class="text-lg font-semibold text-warning">
|
|
||||||
Leave Organization
|
|
||||||
</h2>
|
|
||||||
<p class="text-sm text-light/50 mt-1">
|
|
||||||
Leave this organization. You will need to be
|
|
||||||
re-invited to rejoin.
|
|
||||||
</p>
|
|
||||||
<div class="mt-4">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onclick={leaveOrganization}
|
|
||||||
>Leave {data.org.name}</Button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if isOwner}
|
|
||||||
<Card>
|
|
||||||
<div class="p-6 border-l-4 border-error">
|
|
||||||
<h2 class="text-lg font-semibold text-error">
|
|
||||||
Danger Zone
|
|
||||||
</h2>
|
|
||||||
<p class="text-sm text-light/50 mt-1">
|
|
||||||
Permanently delete this organization and all its
|
|
||||||
data.
|
|
||||||
</p>
|
|
||||||
<div class="mt-4">
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
onclick={deleteOrganization}
|
|
||||||
>Delete Organization</Button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Members Tab -->
|
<!-- Members Tab -->
|
||||||
@@ -654,18 +583,20 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<Button
|
||||||
class="text-xs text-light/50 hover:text-light"
|
variant="tertiary"
|
||||||
|
size="sm"
|
||||||
onclick={() =>
|
onclick={() =>
|
||||||
navigator.clipboard.writeText(
|
navigator.clipboard.writeText(
|
||||||
`${window.location.origin}/invite/${invite.token}`,
|
`${window.location.origin}/invite/${invite.token}`,
|
||||||
)}>Copy Link</button
|
)}>Copy Link</Button
|
||||||
>
|
>
|
||||||
<button
|
<Button
|
||||||
class="text-xs text-error hover:text-error/80"
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
onclick={() =>
|
onclick={() =>
|
||||||
cancelInvite(invite.id)}
|
cancelInvite(invite.id)}
|
||||||
>Cancel</button
|
>Cancel</Button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -679,7 +610,10 @@
|
|||||||
<Card>
|
<Card>
|
||||||
<div class="divide-y divide-light/10">
|
<div class="divide-y divide-light/10">
|
||||||
{#each members as member}
|
{#each members as member}
|
||||||
{@const profile = member.profiles}
|
{@const rawProfile = member.profiles}
|
||||||
|
{@const profile = Array.isArray(rawProfile)
|
||||||
|
? rawProfile[0]
|
||||||
|
: rawProfile}
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between p-4 hover:bg-light/5 transition-colors"
|
class="flex items-center justify-between p-4 hover:bg-light/5 transition-colors"
|
||||||
>
|
>
|
||||||
@@ -717,10 +651,11 @@
|
|||||||
)?.color ?? '#6366f1'}">{member.role}</span
|
)?.color ?? '#6366f1'}">{member.role}</span
|
||||||
>
|
>
|
||||||
{#if member.user_id !== data.user?.id && member.role !== "owner"}
|
{#if member.user_id !== data.user?.id && member.role !== "owner"}
|
||||||
<button
|
<Button
|
||||||
class="text-sm text-light/50 hover:text-light"
|
variant="tertiary"
|
||||||
|
size="sm"
|
||||||
onclick={() => openMemberModal(member)}
|
onclick={() => openMemberModal(member)}
|
||||||
>Edit</button
|
>Edit</Button
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -741,16 +676,7 @@
|
|||||||
Create custom roles with specific permissions.
|
Create custom roles with specific permissions.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onclick={() => openRoleModal()}>
|
<Button onclick={() => openRoleModal()} icon="add">
|
||||||
<svg
|
|
||||||
class="w-4 h-4 mr-2"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path d="M12 5v14M5 12h14" />
|
|
||||||
</svg>
|
|
||||||
Create Role
|
Create Role
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -783,17 +709,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{#if !role.is_system || role.name !== "Owner"}
|
{#if !role.is_system || role.name !== "Owner"}
|
||||||
<button
|
<Button
|
||||||
class="text-sm text-light/50 hover:text-light"
|
variant="tertiary"
|
||||||
|
size="sm"
|
||||||
onclick={() => openRoleModal(role)}
|
onclick={() => openRoleModal(role)}
|
||||||
>Edit</button
|
>Edit</Button
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !role.is_system}
|
{#if !role.is_system}
|
||||||
<button
|
<Button
|
||||||
class="text-sm text-error/70 hover:text-error"
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
onclick={() => deleteRole(role)}
|
onclick={() => deleteRole(role)}
|
||||||
>Delete</button
|
>Delete</Button
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -977,198 +905,6 @@
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Appearance Tab -->
|
|
||||||
{#if activeTab === "appearance"}
|
|
||||||
<div class="space-y-6 max-w-2xl">
|
|
||||||
<Card>
|
|
||||||
<div class="p-6">
|
|
||||||
<h2 class="text-lg font-semibold text-light mb-4">Theme</h2>
|
|
||||||
<p class="text-sm text-light/50 mb-6">
|
|
||||||
Customize the look and feel of your workspace.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Mode Selector -->
|
|
||||||
<div class="mb-6">
|
|
||||||
<label class="block text-sm font-medium text-light mb-3"
|
|
||||||
>Mode</label
|
|
||||||
>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
{#each ["dark", "light", "system"] as mode}
|
|
||||||
<button
|
|
||||||
class="flex-1 px-4 py-3 rounded-lg border transition-all {$theme.mode ===
|
|
||||||
mode
|
|
||||||
? 'border-primary bg-primary/10 text-primary'
|
|
||||||
: 'border-light/20 text-light/60 hover:border-light/40'}"
|
|
||||||
onclick={() =>
|
|
||||||
theme.setMode(mode as ThemeMode)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex flex-col items-center gap-1"
|
|
||||||
>
|
|
||||||
{#if mode === "dark"}
|
|
||||||
<svg
|
|
||||||
class="w-5 h-5"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{:else if mode === "light"}
|
|
||||||
<svg
|
|
||||||
class="w-5 h-5"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="5" />
|
|
||||||
<line
|
|
||||||
x1="12"
|
|
||||||
y1="1"
|
|
||||||
x2="12"
|
|
||||||
y2="3"
|
|
||||||
/>
|
|
||||||
<line
|
|
||||||
x1="12"
|
|
||||||
y1="21"
|
|
||||||
x2="12"
|
|
||||||
y2="23"
|
|
||||||
/>
|
|
||||||
<line
|
|
||||||
x1="4.22"
|
|
||||||
y1="4.22"
|
|
||||||
x2="5.64"
|
|
||||||
y2="5.64"
|
|
||||||
/>
|
|
||||||
<line
|
|
||||||
x1="18.36"
|
|
||||||
y1="18.36"
|
|
||||||
x2="19.78"
|
|
||||||
y2="19.78"
|
|
||||||
/>
|
|
||||||
<line
|
|
||||||
x1="1"
|
|
||||||
y1="12"
|
|
||||||
x2="3"
|
|
||||||
y2="12"
|
|
||||||
/>
|
|
||||||
<line
|
|
||||||
x1="21"
|
|
||||||
y1="12"
|
|
||||||
x2="23"
|
|
||||||
y2="12"
|
|
||||||
/>
|
|
||||||
<line
|
|
||||||
x1="4.22"
|
|
||||||
y1="19.78"
|
|
||||||
x2="5.64"
|
|
||||||
y2="18.36"
|
|
||||||
/>
|
|
||||||
<line
|
|
||||||
x1="18.36"
|
|
||||||
y1="5.64"
|
|
||||||
x2="19.78"
|
|
||||||
y2="4.22"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{:else}
|
|
||||||
<svg
|
|
||||||
class="w-5 h-5"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<rect
|
|
||||||
x="2"
|
|
||||||
y="3"
|
|
||||||
width="20"
|
|
||||||
height="14"
|
|
||||||
rx="2"
|
|
||||||
/>
|
|
||||||
<line
|
|
||||||
x1="8"
|
|
||||||
y1="21"
|
|
||||||
x2="16"
|
|
||||||
y2="21"
|
|
||||||
/>
|
|
||||||
<line
|
|
||||||
x1="12"
|
|
||||||
y1="17"
|
|
||||||
x2="12"
|
|
||||||
y2="21"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
<span class="text-xs capitalize"
|
|
||||||
>{mode}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Accent Color -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-light mb-3"
|
|
||||||
>Accent Color</label
|
|
||||||
>
|
|
||||||
<div class="grid grid-cols-4 gap-3">
|
|
||||||
{#each PRESET_COLORS as color}
|
|
||||||
<button
|
|
||||||
class="group relative h-12 rounded-lg transition-all {$theme.primaryColor ===
|
|
||||||
color.primary
|
|
||||||
? 'ring-2 ring-offset-2 ring-offset-dark ring-white'
|
|
||||||
: 'hover:scale-105'}"
|
|
||||||
style="background-color: {color.primary}"
|
|
||||||
onclick={() =>
|
|
||||||
theme.setPrimaryColor(color.primary)}
|
|
||||||
title={color.name}
|
|
||||||
>
|
|
||||||
{#if $theme.primaryColor === color.primary}
|
|
||||||
<svg
|
|
||||||
class="absolute inset-0 m-auto w-5 h-5 text-white"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="3"
|
|
||||||
>
|
|
||||||
<polyline points="20,6 9,17 4,12" />
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-light/40 mt-3">
|
|
||||||
Selected: {PRESET_COLORS.find(
|
|
||||||
(c) => c.primary === $theme.primaryColor,
|
|
||||||
)?.name || "Custom"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<div class="p-6">
|
|
||||||
<h2 class="text-lg font-semibold text-light mb-2">
|
|
||||||
Reset Theme
|
|
||||||
</h2>
|
|
||||||
<p class="text-sm text-light/50 mb-4">
|
|
||||||
Reset to the default theme settings.
|
|
||||||
</p>
|
|
||||||
<Button variant="secondary" onclick={() => theme.reset()}>
|
|
||||||
Reset to Default
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Invite Member Modal -->
|
<!-- Invite Member Modal -->
|
||||||
@@ -1178,44 +914,34 @@
|
|||||||
title="Invite Member"
|
title="Invite Member"
|
||||||
>
|
>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<Input
|
||||||
<label
|
type="email"
|
||||||
for="invite-email"
|
label="Email address"
|
||||||
class="block text-sm font-medium text-light mb-1"
|
bind:value={inviteEmail}
|
||||||
>Email address</label
|
placeholder="colleague@example.com"
|
||||||
>
|
/>
|
||||||
<input
|
<Select
|
||||||
id="invite-email"
|
label="Role"
|
||||||
type="email"
|
bind:value={inviteRole}
|
||||||
bind:value={inviteEmail}
|
placeholder=""
|
||||||
placeholder="colleague@example.com"
|
options={[
|
||||||
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-light focus:outline-none focus:border-primary"
|
{ value: "viewer", label: "Viewer - Can view content" },
|
||||||
/>
|
{
|
||||||
</div>
|
value: "commenter",
|
||||||
<div>
|
label: "Commenter - Can view and comment",
|
||||||
<label
|
},
|
||||||
for="invite-role"
|
{
|
||||||
class="block text-sm font-medium text-light mb-1">Role</label
|
value: "editor",
|
||||||
>
|
label: "Editor - Can create and edit content",
|
||||||
<select
|
},
|
||||||
id="invite-role"
|
{
|
||||||
bind:value={inviteRole}
|
value: "admin",
|
||||||
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-light focus:outline-none focus:border-primary"
|
label: "Admin - Can manage members and settings",
|
||||||
>
|
},
|
||||||
<option value="viewer">Viewer - Can view content</option>
|
]}
|
||||||
<option value="commenter"
|
/>
|
||||||
>Commenter - Can view and comment</option
|
|
||||||
>
|
|
||||||
<option value="editor"
|
|
||||||
>Editor - Can create and edit content</option
|
|
||||||
>
|
|
||||||
<option value="admin"
|
|
||||||
>Admin - Can manage members and settings</option
|
|
||||||
>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end gap-2 pt-2">
|
<div class="flex justify-end gap-2 pt-2">
|
||||||
<Button variant="ghost" onclick={() => (showInviteModal = false)}
|
<Button variant="tertiary" onclick={() => (showInviteModal = false)}
|
||||||
>Cancel</Button
|
>Cancel</Button
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
@@ -1234,48 +960,44 @@
|
|||||||
title="Edit Member"
|
title="Edit Member"
|
||||||
>
|
>
|
||||||
{#if selectedMember}
|
{#if selectedMember}
|
||||||
|
{@const rawP = selectedMember.profiles}
|
||||||
|
{@const memberProfile = Array.isArray(rawP) ? rawP[0] : rawP}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center gap-3 p-3 bg-light/5 rounded-lg">
|
<div class="flex items-center gap-3 p-3 bg-light/5 rounded-lg">
|
||||||
<div
|
<div
|
||||||
class="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium"
|
class="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium"
|
||||||
>
|
>
|
||||||
{(selectedMember.profiles.full_name ||
|
{(memberProfile?.full_name ||
|
||||||
selectedMember.profiles.email ||
|
memberProfile?.email ||
|
||||||
"?")[0].toUpperCase()}
|
"?")[0].toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-light font-medium">
|
<p class="text-light font-medium">
|
||||||
{selectedMember.profiles.full_name || "No name"}
|
{memberProfile?.full_name || "No name"}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-light/50">
|
<p class="text-sm text-light/50">
|
||||||
{selectedMember.profiles.email}
|
{memberProfile?.email || "No email"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<Select
|
||||||
<label
|
label="Role"
|
||||||
for="member-role"
|
bind:value={selectedMemberRole}
|
||||||
class="block text-sm font-medium text-light mb-1"
|
placeholder=""
|
||||||
>Role</label
|
options={[
|
||||||
>
|
{ value: "viewer", label: "Viewer" },
|
||||||
<select
|
{ value: "commenter", label: "Commenter" },
|
||||||
id="member-role"
|
{ value: "editor", label: "Editor" },
|
||||||
bind:value={selectedMemberRole}
|
{ value: "admin", label: "Admin" },
|
||||||
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-light focus:outline-none focus:border-primary"
|
]}
|
||||||
>
|
/>
|
||||||
<option value="viewer">Viewer</option>
|
|
||||||
<option value="commenter">Commenter</option>
|
|
||||||
<option value="editor">Editor</option>
|
|
||||||
<option value="admin">Admin</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between pt-2">
|
<div class="flex items-center justify-between pt-2">
|
||||||
<Button variant="danger" onclick={removeMember}
|
<Button variant="danger" onclick={removeMember}
|
||||||
>Remove from Org</Button
|
>Remove from Org</Button
|
||||||
>
|
>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="tertiary"
|
||||||
onclick={() => (showMemberModal = false)}>Cancel</Button
|
onclick={() => (showMemberModal = false)}>Cancel</Button
|
||||||
>
|
>
|
||||||
<Button onclick={updateMemberRole}>Save</Button>
|
<Button onclick={updateMemberRole}>Save</Button>
|
||||||
@@ -1292,20 +1014,12 @@
|
|||||||
title={editingRole ? "Edit Role" : "Create Role"}
|
title={editingRole ? "Edit Role" : "Create Role"}
|
||||||
>
|
>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<Input
|
||||||
<label
|
label="Name"
|
||||||
for="role-name"
|
bind:value={newRoleName}
|
||||||
class="block text-sm font-medium text-light mb-1">Name</label
|
placeholder="e.g., Moderator"
|
||||||
>
|
disabled={editingRole?.is_system}
|
||||||
<input
|
/>
|
||||||
id="role-name"
|
|
||||||
type="text"
|
|
||||||
bind:value={newRoleName}
|
|
||||||
placeholder="e.g., Moderator"
|
|
||||||
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-light focus:outline-none focus:border-primary"
|
|
||||||
disabled={editingRole?.is_system}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-light mb-2"
|
<label class="block text-sm font-medium text-light mb-2"
|
||||||
>Color</label
|
>Color</label
|
||||||
@@ -1357,7 +1071,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-2 pt-2">
|
<div class="flex justify-end gap-2 pt-2">
|
||||||
<Button variant="ghost" onclick={() => (showRoleModal = false)}
|
<Button variant="tertiary" onclick={() => (showRoleModal = false)}
|
||||||
>Cancel</Button
|
>Cancel</Button
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
@@ -1412,8 +1126,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex justify-end gap-2 pt-2">
|
<div class="flex justify-end gap-2 pt-2">
|
||||||
<Button variant="ghost" onclick={() => (showConnectModal = false)}
|
<Button
|
||||||
>Cancel</Button
|
variant="tertiary"
|
||||||
|
onclick={() => (showConnectModal = false)}>Cancel</Button
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
onclick={handleSaveOrgCalendar}
|
onclick={handleSaveOrgCalendar}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { json } from '@sveltejs/kit';
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { GOOGLE_API_KEY } from '$env/static/private';
|
import { GOOGLE_API_KEY } from '$env/static/private';
|
||||||
import { fetchPublicCalendarEvents } from '$lib/api/google-calendar';
|
import { fetchPublicCalendarEvents } from '$lib/api/google-calendar';
|
||||||
|
import { createLogger } from '$lib/utils/logger';
|
||||||
|
|
||||||
|
const log = createLogger('api:google-calendar');
|
||||||
|
|
||||||
// Fetch events from a public Google Calendar
|
|
||||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||||
const orgId = url.searchParams.get('org_id');
|
const orgId = url.searchParams.get('org_id');
|
||||||
|
|
||||||
@@ -11,6 +13,23 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
|||||||
return json({ error: 'org_id required' }, { status: 400 });
|
return json({ error: 'org_id required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auth check — must be logged in and a member of this org
|
||||||
|
const { session, user } = await locals.safeGetSession();
|
||||||
|
if (!session || !user) {
|
||||||
|
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: membership } = await locals.supabase
|
||||||
|
.from('org_members')
|
||||||
|
.select('id')
|
||||||
|
.eq('org_id', orgId)
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
return json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
if (!GOOGLE_API_KEY) {
|
if (!GOOGLE_API_KEY) {
|
||||||
return json({ error: 'Google API key not configured' }, { status: 500 });
|
return json({ error: 'Google API key not configured' }, { status: 500 });
|
||||||
}
|
}
|
||||||
@@ -24,7 +43,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
|||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (dbError) {
|
if (dbError) {
|
||||||
console.error('DB error fetching calendar:', dbError);
|
log.error('DB error fetching calendar', { data: { orgId }, error: dbError });
|
||||||
return json({ error: 'No calendar connected', events: [] }, { status: 404 });
|
return json({ error: 'No calendar connected', events: [] }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +51,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
|||||||
return json({ error: 'No calendar connected', events: [] }, { status: 404 });
|
return json({ error: 'No calendar connected', events: [] }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Fetching events for calendar:', (orgCal as any).calendar_id);
|
log.debug('Fetching events for calendar', { data: { calendarId: orgCal.calendar_id } });
|
||||||
|
|
||||||
// Fetch events for the next 3 months
|
// Fetch events for the next 3 months
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -40,21 +59,21 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
|||||||
const timeMax = new Date(now.getFullYear(), now.getMonth() + 3, 0);
|
const timeMax = new Date(now.getFullYear(), now.getMonth() + 3, 0);
|
||||||
|
|
||||||
const events = await fetchPublicCalendarEvents(
|
const events = await fetchPublicCalendarEvents(
|
||||||
(orgCal as any).calendar_id,
|
orgCal.calendar_id,
|
||||||
GOOGLE_API_KEY,
|
GOOGLE_API_KEY,
|
||||||
timeMin,
|
timeMin,
|
||||||
timeMax
|
timeMax
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('Fetched', events.length, 'events');
|
log.debug('Fetched events', { data: { count: events.length } });
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
events,
|
events,
|
||||||
calendar_id: (orgCal as any).calendar_id,
|
calendar_id: orgCal.calendar_id,
|
||||||
calendar_name: (orgCal as any).calendar_name
|
calendar_name: orgCal.calendar_name
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch calendar events:', err);
|
log.error('Failed to fetch calendar events', { data: { orgId }, error: err });
|
||||||
return json({ error: 'Failed to fetch events. Make sure the calendar is public.', events: [] }, { status: 500 });
|
return json({ error: 'Failed to fetch events. Make sure the calendar is public.', events: [] }, { status: 500 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
function safeRedirect(target: string): string {
|
||||||
|
if (target.startsWith('/') && !target.startsWith('//')) return target;
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||||
const code = url.searchParams.get('code');
|
const code = url.searchParams.get('code');
|
||||||
const next = url.searchParams.get('next') ?? url.searchParams.get('redirect') ?? '/';
|
const next = safeRedirect(url.searchParams.get('next') ?? url.searchParams.get('redirect') ?? '/');
|
||||||
|
|
||||||
if (code) {
|
if (code) {
|
||||||
const { error } = await locals.supabase.auth.exchangeCodeForSession(code);
|
const { error } = await locals.supabase.auth.exchangeCodeForSession(code);
|
||||||
|
|||||||
@@ -21,10 +21,8 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const inv = invite as any;
|
|
||||||
|
|
||||||
// Check if invite is expired
|
// Check if invite is expired
|
||||||
if (new Date(inv.expires_at) < new Date()) {
|
if (invite.expires_at && new Date(invite.expires_at) < new Date()) {
|
||||||
return {
|
return {
|
||||||
error: 'This invite has expired',
|
error: 'This invite has expired',
|
||||||
token
|
token
|
||||||
@@ -36,10 +34,10 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
invite: {
|
invite: {
|
||||||
id: inv.id,
|
id: invite.id,
|
||||||
email: inv.email,
|
email: invite.email,
|
||||||
role: inv.role,
|
role: invite.role,
|
||||||
org: inv.organizations
|
org: (invite as any).organizations // join not typed
|
||||||
},
|
},
|
||||||
user,
|
user,
|
||||||
token
|
token
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
org_id: data.invite.org.id,
|
org_id: data.invite.org.id,
|
||||||
user_id: data.user.id,
|
user_id: data.user.id,
|
||||||
role: data.invite.role,
|
role: data.invite.role,
|
||||||
|
joined_at: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (memberError) {
|
if (memberError) {
|
||||||
@@ -84,7 +85,7 @@
|
|||||||
function goToSignup() {
|
function goToSignup() {
|
||||||
const returnUrl = `/invite/${data.token}`;
|
const returnUrl = `/invite/${data.token}`;
|
||||||
goto(
|
goto(
|
||||||
`/signup?redirect=${encodeURIComponent(returnUrl)}&email=${encodeURIComponent(data.invite?.email || "")}`,
|
`/login?tab=signup&redirect=${encodeURIComponent(returnUrl)}&email=${encodeURIComponent(data.invite?.email || "")}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -166,7 +167,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="text-light/40 text-xs mt-3">
|
<p class="text-light/40 text-xs mt-3">
|
||||||
Wrong account? <a
|
Wrong account? <a
|
||||||
href="/logout"
|
href="/auth/logout"
|
||||||
class="text-primary hover:underline">Sign out</a
|
class="text-primary hover:underline">Sign out</a
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
@@ -177,7 +178,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<Button onclick={goToLogin}>Sign In</Button>
|
<Button onclick={goToLogin}>Sign In</Button>
|
||||||
<Button onclick={goToSignup} variant="ghost"
|
<Button onclick={goToSignup} variant="tertiary"
|
||||||
>Create Account</Button
|
>Create Account</Button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Tilt+Warp&family=Work+Sans:wght@400;500;600;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Tilt+Warp&family=Work+Sans:wght@400;500;600;700&family=Inter:wght@400;500;600&display=swap');
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap');
|
||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@plugin '@tailwindcss/forms';
|
@plugin '@tailwindcss/forms';
|
||||||
@plugin '@tailwindcss/typography';
|
@plugin '@tailwindcss/typography';
|
||||||
@@ -26,8 +27,26 @@
|
|||||||
/* Typography - Figma Fonts */
|
/* Typography - Figma Fonts */
|
||||||
--font-heading: 'Tilt Warp', sans-serif;
|
--font-heading: 'Tilt Warp', sans-serif;
|
||||||
--font-body: 'Work Sans', sans-serif;
|
--font-body: 'Work Sans', sans-serif;
|
||||||
|
--font-input: 'Inter', sans-serif;
|
||||||
--font-sans: 'Work Sans', system-ui, -apple-system, sans-serif;
|
--font-sans: 'Work Sans', system-ui, -apple-system, sans-serif;
|
||||||
|
|
||||||
|
/* Font Sizes - Figma Text Styles (--text-* → text-* utilities) */
|
||||||
|
/* Headings (heading font) */
|
||||||
|
--text-h1: 32px;
|
||||||
|
--text-h2: 28px;
|
||||||
|
--text-h3: 24px;
|
||||||
|
--text-h4: 20px;
|
||||||
|
--text-h5: 16px;
|
||||||
|
--text-h6: 14px;
|
||||||
|
/* Button text (heading font) */
|
||||||
|
--text-btn-lg: 20px;
|
||||||
|
--text-btn-md: 16px;
|
||||||
|
--text-btn-sm: 14px;
|
||||||
|
/* Body text (body font) */
|
||||||
|
--text-body: 16px;
|
||||||
|
--text-body-md: 14px;
|
||||||
|
--text-body-sm: 12px;
|
||||||
|
|
||||||
/* Border Radius - Figma Design */
|
/* Border Radius - Figma Design */
|
||||||
--radius-sm: 8px;
|
--radius-sm: 8px;
|
||||||
--radius-md: 16px;
|
--radius-md: 16px;
|
||||||
@@ -37,127 +56,44 @@
|
|||||||
--radius-circle: 128px;
|
--radius-circle: 128px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Base styles */
|
/* Base layer — element defaults via Tailwind utilities */
|
||||||
html, body {
|
@layer base {
|
||||||
background-color: var(--color-background);
|
html, body {
|
||||||
color: var(--color-light);
|
@apply bg-background text-light font-body antialiased;
|
||||||
font-family: var(--font-body);
|
}
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
h1 { @apply font-heading font-normal text-h1 leading-normal; }
|
||||||
|
h2 { @apply font-heading font-normal text-h2 leading-normal; }
|
||||||
|
h3 { @apply font-heading font-normal text-h3 leading-normal; }
|
||||||
|
h4 { @apply font-heading font-normal text-h4 leading-normal; }
|
||||||
|
h5 { @apply font-heading font-normal text-h5 leading-normal; }
|
||||||
|
h6 { @apply font-heading font-normal text-h6 leading-normal; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Headings */
|
/* Scrollbar — no Tailwind equivalent for pseudo-elements */
|
||||||
h1, h2, h3, h4, h5, h6 {
|
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
font-family: var(--font-heading);
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
font-weight: 400;
|
::-webkit-scrollbar-thumb { @apply bg-night rounded-pill; }
|
||||||
}
|
::-webkit-scrollbar-thumb:hover { @apply bg-dark; }
|
||||||
|
|
||||||
/* Scrollbar styling */
|
/* Focus & Selection — pseudo-elements require raw CSS */
|
||||||
::-webkit-scrollbar {
|
:focus-visible { @apply outline-2 outline-primary outline-offset-2; }
|
||||||
width: 8px;
|
::selection { @apply text-light; background-color: rgba(0, 163, 224, 0.3); }
|
||||||
height: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
/* Prose/Markdown styles — used by the TipTap editor */
|
||||||
background: transparent;
|
@layer components {
|
||||||
}
|
.prose { @apply leading-relaxed; }
|
||||||
|
.prose p { @apply my-2; }
|
||||||
::-webkit-scrollbar-thumb {
|
.prose strong { @apply font-bold text-light; }
|
||||||
background: var(--color-night);
|
.prose code { @apply bg-night px-1.5 py-0.5 rounded text-primary text-[0.9em]; font-family: 'Consolas', 'Monaco', monospace; }
|
||||||
border-radius: var(--radius-pill);
|
.prose pre { @apply bg-night p-4 rounded-sm overflow-x-auto my-2; }
|
||||||
}
|
.prose pre code { @apply bg-transparent p-0 text-light; }
|
||||||
|
.prose blockquote { @apply border-l-3 border-primary pl-4 my-2 text-text-muted italic; }
|
||||||
::-webkit-scrollbar-thumb:hover {
|
.prose ul, .prose ol { @apply pl-6 my-2; }
|
||||||
background: var(--color-dark);
|
.prose ul { @apply list-disc; }
|
||||||
}
|
.prose ol { @apply list-decimal; }
|
||||||
|
.prose li { @apply my-1; }
|
||||||
/* Focus styles */
|
.prose h1, .prose h2, .prose h3, .prose h4 { @apply text-light font-heading; margin: 0.75em 0 0.5em; }
|
||||||
:focus-visible {
|
.prose a { @apply text-primary underline; }
|
||||||
outline: 2px solid var(--color-primary);
|
.prose hr { @apply border-t border-dark my-4; }
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Selection */
|
|
||||||
::selection {
|
|
||||||
background-color: rgba(0, 163, 224, 0.3);
|
|
||||||
color: var(--color-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Prose/Markdown styles */
|
|
||||||
.prose {
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose p {
|
|
||||||
margin: 0.5em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose strong {
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose code {
|
|
||||||
background: var(--color-night);
|
|
||||||
padding: 0.15em 0.4em;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: 'Consolas', 'Monaco', monospace;
|
|
||||||
font-size: 0.9em;
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose pre {
|
|
||||||
background: var(--color-night);
|
|
||||||
padding: 1em;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
overflow-x: auto;
|
|
||||||
margin: 0.5em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose pre code {
|
|
||||||
background: none;
|
|
||||||
padding: 0;
|
|
||||||
color: var(--color-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose blockquote {
|
|
||||||
border-left: 3px solid var(--color-primary);
|
|
||||||
padding-left: 1em;
|
|
||||||
margin: 0.5em 0;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose ul, .prose ol {
|
|
||||||
padding-left: 1.5em;
|
|
||||||
margin: 0.5em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose ul {
|
|
||||||
list-style-type: disc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose ol {
|
|
||||||
list-style-type: decimal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose li {
|
|
||||||
margin: 0.25em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose h1, .prose h2, .prose h3, .prose h4 {
|
|
||||||
color: var(--color-light);
|
|
||||||
margin: 0.75em 0 0.5em;
|
|
||||||
font-family: var(--font-heading);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose a {
|
|
||||||
color: var(--color-primary);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose hr {
|
|
||||||
border: none;
|
|
||||||
border-top: 1px solid var(--color-dark);
|
|
||||||
margin: 1em 0;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,17 +4,29 @@
|
|||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
|
|
||||||
let email = $state("");
|
let email = $state($page.url.searchParams.get("email") || "");
|
||||||
let password = $state("");
|
let password = $state("");
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let error = $state("");
|
let error = $state("");
|
||||||
let mode = $state<"login" | "signup">("login");
|
let signupSuccess = $state(false);
|
||||||
|
let mode = $state<"login" | "signup">(
|
||||||
|
($page.url.searchParams.get("tab") as "login" | "signup") || "login",
|
||||||
|
);
|
||||||
|
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
|
|
||||||
// Get redirect URL from query params (for invite flow)
|
// Get redirect URL from query params (for invite flow)
|
||||||
const redirectUrl = $derived($page.url.searchParams.get("redirect") || "/");
|
const redirectUrl = $derived($page.url.searchParams.get("redirect") || "/");
|
||||||
|
|
||||||
|
// Show error from callback (e.g. OAuth failure)
|
||||||
|
const callbackError = $page.url.searchParams.get("error");
|
||||||
|
if (callbackError) {
|
||||||
|
error =
|
||||||
|
callbackError === "auth_callback_error"
|
||||||
|
? "Authentication failed. Please try again."
|
||||||
|
: callbackError;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
error = "Please fill in all fields";
|
error = "Please fill in all fields";
|
||||||
@@ -32,17 +44,24 @@
|
|||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
if (authError) throw authError;
|
if (authError) throw authError;
|
||||||
|
goto(redirectUrl);
|
||||||
} else {
|
} else {
|
||||||
const { error: authError } = await supabase.auth.signUp({
|
const { data: signUpData, error: authError } =
|
||||||
email,
|
await supabase.auth.signUp({
|
||||||
password,
|
email,
|
||||||
options: {
|
password,
|
||||||
emailRedirectTo: `${window.location.origin}/auth/callback`,
|
options: {
|
||||||
},
|
emailRedirectTo: `${window.location.origin}/auth/callback?redirect=${encodeURIComponent(redirectUrl)}`,
|
||||||
});
|
},
|
||||||
|
});
|
||||||
if (authError) throw authError;
|
if (authError) throw authError;
|
||||||
|
// If email confirmation is required, session will be null
|
||||||
|
if (signUpData.session) {
|
||||||
|
goto(redirectUrl);
|
||||||
|
} else {
|
||||||
|
signupSuccess = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
goto(redirectUrl);
|
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
error = e instanceof Error ? e.message : "An error occurred";
|
error = e instanceof Error ? e.message : "An error occurred";
|
||||||
} finally {
|
} finally {
|
||||||
@@ -79,97 +98,129 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card variant="elevated" padding="lg">
|
<Card variant="elevated" padding="lg">
|
||||||
<h2 class="text-xl font-semibold text-light mb-6">
|
{#if signupSuccess}
|
||||||
{mode === "login" ? "Welcome back" : "Create your account"}
|
<div class="text-center py-4">
|
||||||
</h2>
|
<div
|
||||||
|
class="w-16 h-16 mx-auto mb-4 rounded-full bg-success/20 flex items-center justify-center"
|
||||||
{#if error}
|
>
|
||||||
<div
|
<span
|
||||||
class="mb-4 p-3 bg-error/20 border border-error/30 rounded-xl text-error text-sm"
|
class="material-symbols-rounded text-success"
|
||||||
>
|
style="font-size: 32px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 32;"
|
||||||
{error}
|
>
|
||||||
|
mark_email_read
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-xl font-semibold text-light mb-2">
|
||||||
|
Check your email
|
||||||
|
</h2>
|
||||||
|
<p class="text-light/60 text-sm mb-4">
|
||||||
|
We've sent a confirmation link to <strong
|
||||||
|
class="text-light">{email}</strong
|
||||||
|
>. Click the link to activate your account.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
onclick={() => {
|
||||||
|
signupSuccess = false;
|
||||||
|
mode = "login";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back to Login
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{:else}
|
||||||
|
<h2 class="text-xl font-semibold text-light mb-6">
|
||||||
|
{mode === "login" ? "Welcome back" : "Create your account"}
|
||||||
|
</h2>
|
||||||
|
|
||||||
<form
|
{#if error}
|
||||||
onsubmit={(e) => {
|
<div
|
||||||
e.preventDefault();
|
class="mb-4 p-3 bg-error/20 border border-error/30 rounded-xl text-error text-sm"
|
||||||
handleSubmit();
|
|
||||||
}}
|
|
||||||
class="space-y-4"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
label="Email"
|
|
||||||
placeholder="you@example.com"
|
|
||||||
bind:value={email}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
label="Password"
|
|
||||||
placeholder="••••••••"
|
|
||||||
bind:value={password}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button type="submit" fullWidth loading={isLoading}>
|
|
||||||
{mode === "login" ? "Log In" : "Sign Up"}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="my-6 flex items-center gap-3">
|
|
||||||
<div class="flex-1 h-px bg-light/10"></div>
|
|
||||||
<span class="text-light/40 text-sm">or continue with</span>
|
|
||||||
<div class="flex-1 h-px bg-light/10"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
fullWidth
|
|
||||||
onclick={() => handleOAuth("google")}
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5 mr-2" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Continue with Google
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<p class="mt-6 text-center text-light/60 text-sm">
|
|
||||||
{#if mode === "login"}
|
|
||||||
Don't have an account?
|
|
||||||
<button
|
|
||||||
class="text-primary hover:underline"
|
|
||||||
onclick={() => (mode = "signup")}
|
|
||||||
>
|
>
|
||||||
Sign up
|
{error}
|
||||||
</button>
|
</div>
|
||||||
{:else}
|
|
||||||
Already have an account?
|
|
||||||
<button
|
|
||||||
class="text-primary hover:underline"
|
|
||||||
onclick={() => (mode = "login")}
|
|
||||||
>
|
|
||||||
Log in
|
|
||||||
</button>
|
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
|
||||||
|
<form
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}}
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
label="Email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
bind:value={email}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
label="Password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
bind:value={password}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" fullWidth loading={isLoading}>
|
||||||
|
{mode === "login" ? "Log In" : "Sign Up"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="my-6 flex items-center gap-3">
|
||||||
|
<div class="flex-1 h-px bg-light/10"></div>
|
||||||
|
<span class="text-light/40 text-sm">or continue with</span>
|
||||||
|
<div class="flex-1 h-px bg-light/10"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
fullWidth
|
||||||
|
onclick={() => handleOAuth("google")}
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5 mr-2" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Continue with Google
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p class="mt-6 text-center text-light/60 text-sm">
|
||||||
|
{#if mode === "login"}
|
||||||
|
Don't have an account?
|
||||||
|
<button
|
||||||
|
class="text-primary hover:underline"
|
||||||
|
onclick={() => (mode = "signup")}
|
||||||
|
>
|
||||||
|
Sign up
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
Already have an account?
|
||||||
|
<button
|
||||||
|
class="text-primary hover:underline"
|
||||||
|
onclick={() => (mode = "login")}
|
||||||
|
>
|
||||||
|
Log in
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import { page } from 'vitest/browser';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { render } from 'vitest-browser-svelte';
|
|
||||||
import Page from './+page.svelte';
|
|
||||||
|
|
||||||
describe('/+page.svelte', () => {
|
|
||||||
it('should render h1', async () => {
|
|
||||||
render(Page);
|
|
||||||
|
|
||||||
const heading = page.getByRole('heading', { level: 1 });
|
|
||||||
await expect.element(heading).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -11,6 +11,12 @@
|
|||||||
Spinner,
|
Spinner,
|
||||||
Toggle,
|
Toggle,
|
||||||
Toast,
|
Toast,
|
||||||
|
Chip,
|
||||||
|
ListItem,
|
||||||
|
OrgHeader,
|
||||||
|
CalendarDay,
|
||||||
|
Logo,
|
||||||
|
ContentHeader,
|
||||||
} from "$lib/components/ui";
|
} from "$lib/components/ui";
|
||||||
|
|
||||||
let inputValue = $state("");
|
let inputValue = $state("");
|
||||||
@@ -124,7 +130,7 @@
|
|||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
<Button variant="primary">Primary</Button>
|
<Button variant="primary">Primary</Button>
|
||||||
<Button variant="secondary">Secondary</Button>
|
<Button variant="secondary">Secondary</Button>
|
||||||
<Button variant="ghost">Ghost</Button>
|
<Button variant="tertiary">Tertiary</Button>
|
||||||
<Button variant="danger">Danger</Button>
|
<Button variant="danger">Danger</Button>
|
||||||
<Button variant="success">Success</Button>
|
<Button variant="success">Success</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,6 +147,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||||
|
With Icons (Material Symbols)
|
||||||
|
</h3>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<Button icon="add">Add Item</Button>
|
||||||
|
<Button variant="secondary" icon="edit">Edit</Button>
|
||||||
|
<Button variant="tertiary" icon="delete">Delete</Button>
|
||||||
|
<Button icon="send">Send</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||||
States
|
States
|
||||||
@@ -157,7 +175,9 @@
|
|||||||
Full Width
|
Full Width
|
||||||
</h3>
|
</h3>
|
||||||
<div class="max-w-sm">
|
<div class="max-w-sm">
|
||||||
<Button fullWidth>Full Width Button</Button>
|
<Button fullWidth icon="rocket_launch"
|
||||||
|
>Full Width Button</Button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,6 +222,8 @@
|
|||||||
label="Password"
|
label="Password"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
/>
|
/>
|
||||||
|
<Input placeholder="Message input with icon..." icon="add" />
|
||||||
|
<Input label="Search" placeholder="Search..." icon="search" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -263,28 +285,22 @@
|
|||||||
Sizes
|
Sizes
|
||||||
</h3>
|
</h3>
|
||||||
<div class="flex items-end gap-4">
|
<div class="flex items-end gap-4">
|
||||||
<Avatar name="John Doe" size="xs" />
|
|
||||||
<Avatar name="John Doe" size="sm" />
|
<Avatar name="John Doe" size="sm" />
|
||||||
<Avatar name="John Doe" size="md" />
|
<Avatar name="John Doe" size="md" />
|
||||||
<Avatar name="John Doe" size="lg" />
|
<Avatar name="John Doe" size="lg" />
|
||||||
<Avatar name="John Doe" size="xl" />
|
<Avatar name="John Doe" size="xl" />
|
||||||
<Avatar name="John Doe" size="2xl" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||||
With Status
|
With Status (placeholder)
|
||||||
</h3>
|
</h3>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<Avatar name="Online User" size="lg" status="online" />
|
<Avatar name="Online User" size="lg" />
|
||||||
<Avatar name="Away User" size="lg" status="away" />
|
<Avatar name="Away User" size="lg" />
|
||||||
<Avatar name="Busy User" size="lg" status="busy" />
|
<Avatar name="Busy User" size="lg" />
|
||||||
<Avatar
|
<Avatar name="Offline User" size="lg" />
|
||||||
name="Offline User"
|
|
||||||
size="lg"
|
|
||||||
status="offline"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -303,6 +319,88 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Chips -->
|
||||||
|
<section class="space-y-4">
|
||||||
|
<h2
|
||||||
|
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||||
|
>
|
||||||
|
Chips
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||||
|
Variants
|
||||||
|
</h3>
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<Chip variant="primary">Primary</Chip>
|
||||||
|
<Chip variant="success">Success</Chip>
|
||||||
|
<Chip variant="warning">Warning</Chip>
|
||||||
|
<Chip variant="error">Error</Chip>
|
||||||
|
<Chip variant="default">Default</Chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- List Items -->
|
||||||
|
<section class="space-y-4">
|
||||||
|
<h2
|
||||||
|
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||||
|
>
|
||||||
|
List Items
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="max-w-[240px] space-y-2">
|
||||||
|
<ListItem icon="info">Default Item</ListItem>
|
||||||
|
<ListItem icon="settings" variant="hover">Hover State</ListItem>
|
||||||
|
<ListItem icon="check_circle" variant="active"
|
||||||
|
>Active Item</ListItem
|
||||||
|
>
|
||||||
|
<ListItem icon="folder">Documents</ListItem>
|
||||||
|
<ListItem icon="dashboard">Dashboard</ListItem>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Org Header -->
|
||||||
|
<section class="space-y-4">
|
||||||
|
<h2
|
||||||
|
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||||
|
>
|
||||||
|
Organization Header
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="max-w-[240px] space-y-4">
|
||||||
|
<OrgHeader name="Acme Corp" role="Admin" />
|
||||||
|
<OrgHeader name="Design Team" role="Editor" isHover />
|
||||||
|
<OrgHeader name="Small" size="sm" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Calendar Day -->
|
||||||
|
<section class="space-y-4">
|
||||||
|
<h2
|
||||||
|
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||||
|
>
|
||||||
|
Calendar Day
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="flex gap-1 max-w-[720px]">
|
||||||
|
<CalendarDay day="Mon" isHeader />
|
||||||
|
<CalendarDay day="Tue" isHeader />
|
||||||
|
<CalendarDay day="Wed" isHeader />
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1 max-w-[720px]">
|
||||||
|
<CalendarDay day="1" />
|
||||||
|
<CalendarDay day="2">
|
||||||
|
{#snippet events()}
|
||||||
|
<Chip>Meeting</Chip>
|
||||||
|
{/snippet}
|
||||||
|
</CalendarDay>
|
||||||
|
<CalendarDay day="3" isPast />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Badges -->
|
<!-- Badges -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2
|
<h2
|
||||||
@@ -492,38 +590,132 @@
|
|||||||
Typography
|
Typography
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-6">
|
||||||
<h1 class="text-4xl font-bold text-light">
|
<!-- Headings (Tilt Warp) -->
|
||||||
Heading 1 (4xl bold)
|
<div>
|
||||||
</h1>
|
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||||
<h2 class="text-3xl font-bold text-light">
|
Headings — Tilt Warp
|
||||||
Heading 2 (3xl bold)
|
</h3>
|
||||||
</h2>
|
<div class="space-y-3">
|
||||||
<h3 class="text-2xl font-semibold text-light">
|
<div class="flex items-baseline gap-4">
|
||||||
Heading 3 (2xl semibold)
|
<span
|
||||||
</h3>
|
class="text-body-sm text-light/40 w-16 shrink-0"
|
||||||
<h4 class="text-xl font-semibold text-light">
|
>h1 · 32</span
|
||||||
Heading 4 (xl semibold)
|
>
|
||||||
</h4>
|
<h1 class="text-light">Heading 1</h1>
|
||||||
<h5 class="text-lg font-medium text-light">
|
</div>
|
||||||
Heading 5 (lg medium)
|
<div class="flex items-baseline gap-4">
|
||||||
</h5>
|
<span
|
||||||
<h6 class="text-base font-medium text-light">
|
class="text-body-sm text-light/40 w-16 shrink-0"
|
||||||
Heading 6 (base medium)
|
>h2 · 28</span
|
||||||
</h6>
|
>
|
||||||
<p class="text-base text-light/80">
|
<h2 class="text-light">Heading 2</h2>
|
||||||
Body text (base, 80% opacity) - Lorem ipsum dolor sit amet,
|
</div>
|
||||||
consectetur adipiscing elit. Sed do eiusmod tempor
|
<div class="flex items-baseline gap-4">
|
||||||
incididunt ut labore et dolore magna aliqua.
|
<span
|
||||||
</p>
|
class="text-body-sm text-light/40 w-16 shrink-0"
|
||||||
<p class="text-sm text-light/60">
|
>h3 · 24</span
|
||||||
Small text (sm, 60% opacity) - Used for secondary
|
>
|
||||||
information and hints.
|
<h3 class="text-light">Heading 3</h3>
|
||||||
</p>
|
</div>
|
||||||
<p class="text-xs text-light/40">
|
<div class="flex items-baseline gap-4">
|
||||||
Extra small text (xs, 40% opacity) - Used for metadata and
|
<span
|
||||||
timestamps.
|
class="text-body-sm text-light/40 w-16 shrink-0"
|
||||||
</p>
|
>h4 · 20</span
|
||||||
|
>
|
||||||
|
<h4 class="text-light">Heading 4</h4>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-baseline gap-4">
|
||||||
|
<span
|
||||||
|
class="text-body-sm text-light/40 w-16 shrink-0"
|
||||||
|
>h5 · 16</span
|
||||||
|
>
|
||||||
|
<h5 class="text-light">Heading 5</h5>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-baseline gap-4">
|
||||||
|
<span
|
||||||
|
class="text-body-sm text-light/40 w-16 shrink-0"
|
||||||
|
>h6 · 14</span
|
||||||
|
>
|
||||||
|
<h6 class="text-light">Heading 6</h6>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Button Text (Tilt Warp) -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||||
|
Button Text — Tilt Warp
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-baseline gap-4">
|
||||||
|
<span
|
||||||
|
class="text-body-sm text-light/40 w-16 shrink-0"
|
||||||
|
>btn-lg · 20</span
|
||||||
|
>
|
||||||
|
<span class="font-heading text-btn-lg text-light"
|
||||||
|
>Button Large</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-baseline gap-4">
|
||||||
|
<span
|
||||||
|
class="text-body-sm text-light/40 w-16 shrink-0"
|
||||||
|
>btn-md · 16</span
|
||||||
|
>
|
||||||
|
<span class="font-heading text-btn-md text-light"
|
||||||
|
>Button Medium</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-baseline gap-4">
|
||||||
|
<span
|
||||||
|
class="text-body-sm text-light/40 w-16 shrink-0"
|
||||||
|
>btn-sm · 14</span
|
||||||
|
>
|
||||||
|
<span class="font-heading text-btn-sm text-light"
|
||||||
|
>Button Small</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body Text (Work Sans) -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||||
|
Body — Work Sans
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-baseline gap-4">
|
||||||
|
<span
|
||||||
|
class="text-body-sm text-light/40 w-16 shrink-0"
|
||||||
|
>p · 16</span
|
||||||
|
>
|
||||||
|
<p class="text-body text-light">
|
||||||
|
Body text — Lorem ipsum dolor sit amet,
|
||||||
|
consectetur adipiscing elit.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-baseline gap-4">
|
||||||
|
<span
|
||||||
|
class="text-body-sm text-light/40 w-16 shrink-0"
|
||||||
|
>p-md · 14</span
|
||||||
|
>
|
||||||
|
<p class="text-body-md text-light/80">
|
||||||
|
Body medium — Used for secondary information and
|
||||||
|
descriptions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-baseline gap-4">
|
||||||
|
<span
|
||||||
|
class="text-body-sm text-light/40 w-16 shrink-0"
|
||||||
|
>p-sm · 12</span
|
||||||
|
>
|
||||||
|
<p class="text-body-sm text-light/60">
|
||||||
|
Body small — Used for metadata, timestamps, and
|
||||||
|
hints.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -558,6 +750,51 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Logo -->
|
||||||
|
<section class="space-y-4">
|
||||||
|
<h2
|
||||||
|
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||||
|
>
|
||||||
|
Logo
|
||||||
|
</h2>
|
||||||
|
<p class="text-light/60">
|
||||||
|
Brand logo component with size variants.
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-8 bg-night p-6 rounded-xl">
|
||||||
|
<div class="flex flex-col items-center gap-2">
|
||||||
|
<Logo size="sm" />
|
||||||
|
<span class="text-xs text-light/60">Small</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-center gap-2">
|
||||||
|
<Logo size="md" />
|
||||||
|
<span class="text-xs text-light/60">Medium</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ContentHeader -->
|
||||||
|
<section class="space-y-4">
|
||||||
|
<h2
|
||||||
|
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||||
|
>
|
||||||
|
Content Header
|
||||||
|
</h2>
|
||||||
|
<p class="text-light/60">
|
||||||
|
Page header component with avatar, title, action button, and
|
||||||
|
more menu.
|
||||||
|
</p>
|
||||||
|
<div class="bg-night p-6 rounded-xl space-y-4">
|
||||||
|
<ContentHeader
|
||||||
|
title="Page Title"
|
||||||
|
actionLabel="+ New"
|
||||||
|
onAction={() => {}}
|
||||||
|
onMore={() => {}}
|
||||||
|
/>
|
||||||
|
<ContentHeader title="Without Action" onMore={() => {}} />
|
||||||
|
<ContentHeader title="Simple Header" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<footer class="text-center py-8 border-t border-light/10">
|
<footer class="text-center py-8 border-t border-light/10">
|
||||||
<p class="text-light/40 text-sm">
|
<p class="text-light/40 text-sm">
|
||||||
|
|||||||
66
supabase/migrations/013_kanban_labels.sql
Normal file
66
supabase/migrations/013_kanban_labels.sql
Normal file
@@ -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;
|
||||||
101
supabase/migrations/014_document_enhancements.sql
Normal file
101
supabase/migrations/014_document_enhancements.sql
Normal file
@@ -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);
|
||||||
45
supabase/migrations/015_migrate_kanban_to_documents.sql
Normal file
45
supabase/migrations/015_migrate_kanban_to_documents.sql
Normal file
@@ -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'
|
||||||
|
);
|
||||||
41
supabase/migrations/016_document_locks.sql
Normal file
41
supabase/migrations/016_document_locks.sql
Normal file
@@ -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');
|
||||||
28
supabase/migrations/017_avatars_storage.sql
Normal file
28
supabase/migrations/017_avatars_storage.sql
Normal file
@@ -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');
|
||||||
Reference in New Issue
Block a user