18 Commits

Author SHA1 Message Date
AlacrisDevs
c3a25f56a7 Big changes no2 2026-03-05 13:55:11 +02:00
AlacrisDevs
2a28d88849 UI redesign vol1 2026-02-28 17:06:03 +02:00
AlacrisDevs
7ab206fe96 MEga push vol idk, chat function updates, docker fixes 2026-02-14 13:09:45 +02:00
AlacrisDevs
c2d3caaa5a Quick fixes + logo better 2026-02-09 18:05:09 +02:00
AlacrisDevs
046d4bd098 Style guide improvement 2026-02-09 15:23:03 +02:00
AlacrisDevs
bfeb33906e Fixes to language translation etc 2026-02-09 14:32:13 +02:00
AlacrisDevs
71bf7b9057 YEs 2026-02-09 12:00:40 +02:00
AlacrisDevs
9885f9459d Mojibake 2026-02-09 11:40:17 +02:00
AlacrisDevs
a4baa1ad25 Big things, maybe all's better now 2026-02-09 11:36:39 +02:00
AlacrisDevs
38a0c2274d UX ifxes 2026-02-09 00:23:35 +02:00
AlacrisDevs
9cb047c8b6 Delete fix 2026-02-08 23:59:00 +02:00
AlacrisDevs
4ee2c0ac07 Map push 2026-02-08 23:51:49 +02:00
AlacrisDevs
302fc69218 Migration to maybe make even more live 2026-02-08 23:37:13 +02:00
AlacrisDevs
ce80dc6d75 Quick fixes to lang plus mmap realtime 2026-02-08 23:30:09 +02:00
AlacrisDevs
f2384bceb8 feat: map shapes, image persistence, grab tool, layer rename/delete, i18n, page metadata 2026-02-08 23:11:09 +02:00
AlacrisDevs
75a2aefadb Resruiin 2026-02-07 23:10:10 +02:00
AlacrisDevs
23693db9ec Dashbaord fixes 2026-02-07 22:29:09 +02:00
AlacrisDevs
d22847f555 Mega push vol 7 mvp lesgoooo 2026-02-07 21:47:47 +02:00
242 changed files with 39656 additions and 6076 deletions

View File

@@ -11,6 +11,7 @@ README.md
node_modules
build
**/.env
**/.env.*
**/.env.local
**/.env.*.local
*.log
.DS_Store

View File

@@ -1,16 +1,25 @@
# ── Supabase ──
PUBLIC_SUPABASE_URL=your_supabase_url
PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
# Service role key — required for admin operations (invite emails, etc.)
# Find it in Supabase Dashboard → Settings → API → service_role key
SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key
# ── Google ──
GOOGLE_API_KEY=your_google_api_key
# Google Service Account for Calendar push (create/update/delete events)
# Paste the full JSON key file contents, or base64-encode it
# The calendar must be shared with the service account email (with "Make changes to events" permission)
GOOGLE_SERVICE_ACCOUNT_KEY=
# Matrix / Synapse integration
# ── Matrix / Synapse (optional — chat is not yet enabled) ──
# The homeserver URL where your Synapse instance is running
MATRIX_HOMESERVER_URL=https://matrix.example.com
# Synapse Admin API shared secret or admin access token
# Used to auto-provision Matrix accounts for users
MATRIX_ADMIN_TOKEN=
# ── Docker / Production ──
# Public URL of the app — required by SvelteKit node adapter for CSRF protection
# Set this to your actual domain in production (e.g. https://app.example.com)
ORIGIN=http://localhost:3000

2
.gitignore vendored
View File

@@ -29,7 +29,7 @@ Desktop.ini
# IDE / Editors
.idea
.vscode/*
.vscode
!.vscode/extensions.json
!.vscode/settings.json
*.swp

742
AUDIT.md
View File

@@ -1,742 +0,0 @@
# Comprehensive Codebase Audit Report (v4)
**Project:** root-org (SvelteKit + Supabase + Tailwind v4)
**Date:** 2026-02-06 (v4 update)
**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.
>
> **Changes in v4:** Type safety (shared `OrgLayoutData`, `as any` casts fixed, `role`→`userRole` dedup). Architecture (settings page split into 4 components, FileBrowser migrated to API modules, `createDocument` supports kanban). Performance (folder listings exclude content, kanban queries parallelized, card moves batched, realtime incremental). Testing (43 unit tests, expanded E2E coverage, GitHub Actions CI).
---
## 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.
### Resolved Since v3
| ID | Issue | Resolution |
|----|-------|------------|
| — | Icon buttons not round | All inline icon buttons (`rounded-lg`) changed to `rounded-full` across KanbanCard, KanbanBoard, DocumentViewer, Calendar, Modal, ContextMenu |
| — | Add column/card buttons missing plus icon | Replaced inline buttons with `Button` component using `icon="add"` prop |
| — | Kanban columns not reorderable | Added column drag-and-drop with grip handle, drop indicators, and DB persistence |
| — | Inconsistent cursor styles | Added global CSS rules: `cursor-pointer` on all `button`/`a`/`[role="button"]`, `cursor-grab` on `[draggable="true"]` |
| — | Blurred spinner loading overlay | Replaced `backdrop-blur-sm` spinner with context-aware `PageSkeleton` component (kanban/files/calendar/settings/default variants) |
| — | Language switcher missing | Added locale picker (English/Eesti) to account settings using Paraglide `setLocale()` |
| — | File browser view mode not persisted | Confirmed already working via `localStorage` (`root:viewMode` key) |
### Resolved Since v4
| ID | Issue | Resolution |
|----|-------|------------|
| T-1 | 2 remaining `as any` casts | Replaced with properly typed casts in invite page and CardDetailModal |
| T-2→T-5 | Untyped parent layout data | Created shared `OrgLayoutData` type in `$lib/types/layout.ts`; applied across all 8 page servers |
| R-4 | Duplicate `role`/`userRole` | Removed `role` from layout server return; migrated all consumers to `userRole` |
| A-1 | Settings page god component (1200+ lines) | Extracted `SettingsMembers`, `SettingsRoles`, `SettingsIntegrations` into `$lib/components/settings/`; page reduced to ~470 lines |
| A-2 | FileBrowser direct Supabase calls | Migrated all CRUD operations to use `$lib/api/documents.ts` (`moveDocument`, `updateDocument`, `deleteDocument`, `createDocument`, `copyDocument`) |
| A-3 | `createDocument` missing kanban type | Added `'kanban'` to type union with optional `id` and `content` params |
| E-3 | Calendar date click no-op | Already implemented — clicking a day opens create event modal pre-filled with date |
| P-1 | Folder listings fetch `select('*')` | Changed to select only metadata columns, excluding heavy `content` JSON |
| P-2 | Kanban queries sequential | Board+columns now fetched in parallel; tags+checklists+assignees fetched in parallel |
| P-3 | `moveCard` fires N updates | Now skips cards whose position didn't change — typically 2-3 updates instead of N |
| P-4 | Realtime full board reload | Upgraded `subscribeToBoard` to pass granular payloads; kanban page applies INSERT/UPDATE/DELETE diffs incrementally |
| T6 | No unit tests | Added 43 Vitest unit tests: `logger.test.ts` (10), `google-calendar.test.ts` (11), `calendar.test.ts` (12), `documents.test.ts` (10) |
| T6 | Incomplete E2E coverage | Added Playwright tests for Tags tab, calendar CRUD (create/view/delete), kanban card CRUD (create/detail modal) |
| T6 | No CI pipeline | Created `.github/workflows/ci.yml`: lint → check → unit tests → build |
| T6 | Test cleanup incomplete | Updated `cleanup.ts` to handle test tags, calendar events, and new board prefixes |
---
## Area Scores (v4)
Scores reflect the current state of the codebase after all v1v4 fixes.
| Area | Score | Notes |
|------|-------|-------|
| **Security** | ⭐⭐⭐ 3/5 | S-2, S-3, S-5 fixed. **S-1 (credential rotation) and S-4 (server-side auth for mutations) remain critical/high.** S-6 (lock cleanup race) still open. |
| **Type Safety** | ⭐⭐⭐⭐ 4/5 | `OrgLayoutData` shared type eliminates parent casts. 2 targeted `as any` casts fixed. Remaining `as any` casts are in Supabase join results that need full type regeneration (T-1). |
| **Dead Code** | ⭐⭐⭐⭐⭐ 5/5 | All dead stores, unused components, placeholder tests, empty files, and unused dependencies removed in v2. No known dead code remains. |
| **Architecture** | ⭐⭐⭐⭐ 4/5 | Settings page split into 4 components. FileBrowser migrated to API modules. `createDocument` supports all types. Remaining: some components still have inline Supabase calls (CardDetailModal, CardComments). |
| **Performance** | ⭐⭐⭐⭐ 4/5 | Folder listings exclude content. Kanban queries parallelized. Card moves batched smartly. Realtime is incremental. Remaining: full org document fetch for breadcrumbs could be optimized further. |
| **Error Handling** | ⭐⭐⭐⭐ 4/5 | `alert()` replaced with toasts. Structured logger adopted in API routes. `$effect` sync blocks added. Remaining: `console.error` in 3-4 files (calendar page, invite page), lock release in `onDestroy`. |
| **Testing** | ⭐⭐⭐⭐ 4/5 | 43 unit tests (logger, calendar, google-calendar, documents API). 35+ Playwright E2E tests covering all major flows. CI pipeline on GitHub Actions. Remaining: visual regression tests, Svelte component tests. |
| **Code Quality** | ⭐⭐⭐⭐ 4/5 | Consistent API module pattern. Shared types. i18n complete. Duplication eliminated. Remaining: `role`/`userRole` fully migrated but some inline SVGs and magic numbers persist. |
| **Dependencies** | ⭐⭐⭐⭐⭐ 5/5 | `lucide-svelte` removed. All deps actively used. No known unused packages. |
| **Future-Proofing** | ⭐⭐⭐ 3/5 | Permission system defined but not enforced (F-1). Kanban realtime subscription unscoped (F-2). No search, notifications, or keyboard shortcuts yet. |
### Overall Score: ⭐⭐⭐⭐ 4.0 / 5
**Breakdown:** 41 out of 50 possible stars across 10 areas.
### Remaining High-Priority Items
1. **S-1: Rotate credentials & purge `.env` from git history** — Critical security risk. Must be done manually.
2. **S-4: Server-side auth for settings mutations** — Move destructive operations to SvelteKit form actions with explicit authorization.
3. **T-1: Regenerate Supabase types**`supabase gen types typescript` to eliminate remaining `as any` casts from join results.
4. **F-1: Permission enforcement** — Create `hasPermission()` utility; the permission system is defined but never checked.
### Remaining Medium-Priority Items
5. **S-6: Lock cleanup race condition** — Consolidate to server-side cron only.
6. **E-2: Replace remaining `console.*` calls** — 3-4 files still use raw console instead of structured logger.
7. **E-5: Lock release in `onDestroy`** — Use `navigator.sendBeacon` for reliable cleanup.
8. **F-2: Scoped realtime subscriptions** — Filter kanban card changes to current board's columns.
9. **M-1/M-3: Magic numbers and inline SVGs** — Extract constants, use Icon component consistently.
### Feature Backlog (Tier 5)
10. Notifications system (mentions, assignments, due dates)
11. Global search across documents, kanban cards, calendar events
12. Keyboard shortcuts for common actions
13. Mobile responsive layout (sidebar drawer, touch-friendly kanban)
14. Dark/light theme toggle
15. Export/import (CSV/JSON/Markdown)
16. Undo/redo with toast-based undo for destructive actions
17. Onboarding flow for new users
18. Visual regression tests for key pages

View File

@@ -1,43 +1,59 @@
# Build stage
# ── Build stage ──
FROM node:22-alpine AS builder
WORKDIR /app
# Copy package files
# Build args needed by $env/static/public at build time
ARG PUBLIC_SUPABASE_URL
ARG PUBLIC_SUPABASE_ANON_KEY
# Copy package files first for better layer caching
COPY package*.json ./
# Install all dependencies (including dev)
# Install all dependencies (including dev) needed for the build
RUN npm ci
# Copy source files
COPY . .
# Build the application
# Build the SvelteKit application
RUN npm run build
# Prune dev dependencies
RUN npm prune --production
# ── Production dependencies stage ──
FROM node:22-alpine AS deps
# Production stage
WORKDIR /app
COPY package*.json ./
# Install only production dependencies
RUN npm ci --omit=dev
# ── Runtime stage ──
FROM node:22-alpine
WORKDIR /app
# Copy built application
# Copy built application and production deps
COPY --from=builder /app/build build/
COPY --from=builder /app/node_modules node_modules/
COPY --from=deps /app/node_modules node_modules/
COPY package.json .
# Expose port
EXPOSE 3000
# Set environment
# Set environment defaults
ENV NODE_ENV=production
ENV PORT=3000
ENV HOST=0.0.0.0
# SvelteKit node adapter needs ORIGIN for CSRF protection
# Override at runtime via docker-compose or -e flag
ENV ORIGIN=http://localhost:3000
# Allow file uploads up to 10 MB
ENV BODY_SIZE_LIMIT=10485760
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
# Run the application

View File

@@ -1,32 +1,61 @@
# Root
Team collaboration platform.
Team collaboration platform built with SvelteKit, Supabase, and TailwindCSS.
## Quick Start
```bash
# Install
npm install
# Set up environment
cp .env.example .env
# Fill in your Supabase credentials in .env
# Run migrations in Supabase Dashboard > SQL Editor
# Copy & run each file in supabase/migrations/ in order (001, 002, 003...)
# Start dev server
cp .env.example .env # Fill in your credentials
npm run dev
```
Open http://localhost:5173
## Docker
## Environment Variables
| Variable | Required | Description |
|---|---|---|
| `PUBLIC_SUPABASE_URL` | Yes | Supabase project URL |
| `PUBLIC_SUPABASE_ANON_KEY` | Yes | Supabase anonymous key |
| `GOOGLE_API_KEY` | No | Google Maps API key (for map module) |
| `GOOGLE_SERVICE_ACCOUNT_KEY` | No | Google Calendar push (JSON key) |
| `MATRIX_HOMESERVER_URL` | No | Matrix/Synapse homeserver URL |
| `MATRIX_ADMIN_TOKEN` | No | Synapse admin token for auto-provisioning |
| `RESEND_API_KEY` | No | Resend.com API key for invite emails |
| `RESEND_FROM_EMAIL` | No | Verified sender email (e.g. `noreply@yourdomain.com`) |
## Database
Migrations are in `supabase/migrations/`. Push them with:
```bash
# Production
docker-compose up app
npm run db:push # Push pending migrations
npm run db:types # Regenerate TypeScript types
npm run db:migrate # Both in one step
```
# Development
## Production Deployment
### Docker
```bash
docker-compose up -d app
```
The app runs on port 3000 with a `/health` endpoint for monitoring.
### Manual
```bash
npm run build
node build
```
Set `PORT` (default 3000), `HOST` (default 0.0.0.0), and `NODE_ENV=production`.
### Development (Docker)
```bash
docker-compose up dev
```

View File

@@ -3,22 +3,26 @@ services:
build:
context: .
dockerfile: Dockerfile
args:
- PUBLIC_SUPABASE_URL=${PUBLIC_SUPABASE_URL}
- PUBLIC_SUPABASE_ANON_KEY=${PUBLIC_SUPABASE_ANON_KEY}
ports:
- "3000:3000"
env_file: .env
environment:
- NODE_ENV=production
- PORT=3000
- HOST=0.0.0.0
# Supabase configuration (add your values)
- PUBLIC_SUPABASE_URL=${PUBLIC_SUPABASE_URL}
- PUBLIC_SUPABASE_ANON_KEY=${PUBLIC_SUPABASE_ANON_KEY}
# SvelteKit node adapter CSRF — set to your public URL in production
- ORIGIN=${ORIGIN:-http://localhost:3000}
- BODY_SIZE_LIMIT=10485760
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health" ]
interval: 30s
timeout: 3s
retries: 3
start_period: 5s
start_period: 10s
# Development mode with hot reload
dev:
@@ -30,8 +34,7 @@ services:
volumes:
- .:/app
- /app/node_modules
env_file: .env
environment:
- NODE_ENV=development
- PUBLIC_SUPABASE_URL=${PUBLIC_SUPABASE_URL}
- PUBLIC_SUPABASE_ANON_KEY=${PUBLIC_SUPABASE_ANON_KEY}
command: npm run dev -- --host

997
docs/TECHNICAL_DESIGN.md Normal file
View File

@@ -0,0 +1,997 @@
# Root — Technical Design Document
> Comprehensive documentation of every feature, how it works, what problems it solves, and where there is room for improvement.
---
## Table of Contents
1. [Platform Overview](#1-platform-overview)
2. [Architecture](#2-architecture)
3. [Authentication & Authorization](#3-authentication--authorization)
4. [Organizations](#4-organizations)
5. [Organization Settings](#5-organization-settings)
6. [Documents & File Browser](#6-documents--file-browser)
7. [Kanban Boards](#7-kanban-boards)
8. [Calendar](#8-calendar)
9. [Chat (Matrix)](#9-chat-matrix)
10. [Events](#10-events)
11. [Event Team & Departments](#11-event-team--departments)
12. [Department Dashboard](#12-department-dashboard)
13. [Checklists Module](#13-checklists-module)
14. [Notes Module](#14-notes-module)
15. [Schedule Module](#15-schedule-module)
16. [Contacts Module](#16-contacts-module)
17. [Budget & Finances Module](#17-budget--finances-module)
18. [Sponsors Module](#18-sponsors-module)
19. [Activity Tracking](#19-activity-tracking)
20. [Tags System](#20-tags-system)
21. [Roles & Permissions](#21-roles--permissions)
22. [Google Calendar Integration](#22-google-calendar-integration)
23. [Internationalization (i18n)](#23-internationalization-i18n)
24. [Platform Admin](#24-platform-admin)
25. [Testing Strategy](#25-testing-strategy)
26. [Database Migrations](#26-database-migrations)
---
## 1. Platform Overview
### What It Is
Root is an **event organizing platform** built for teams and organizations that plan events. It provides a unified workspace where teams can manage documents, tasks, budgets, sponsors, schedules, contacts, and communications — all scoped to specific events and departments.
### Problems It Solves
- **Fragmented tooling**: Event teams typically juggle Google Sheets, Slack, Trello, email, and spreadsheets. Root consolidates these into one platform.
- **No event-scoped context**: Generic project tools don't understand the concept of "events with departments." Root's data model is purpose-built for this.
- **Budget opacity**: Planned vs. actual spending is hard to track across departments. Root provides real-time financial rollups.
- **Sponsor management**: No lightweight CRM exists for event sponsorships. Root provides tier-based sponsor tracking with deliverables.
### Tech Stack
| Layer | Technology |
|-------|-----------|
| Frontend | SvelteKit (Svelte 5), TailwindCSS |
| Backend | Supabase (PostgreSQL, Auth, Storage, Realtime) |
| Chat | Matrix protocol (Synapse server) |
| Calendar | Google Calendar API (OAuth2) |
| i18n | Paraglide v2 (en, et) |
| Icons | Material Symbols (variable font) |
| Testing | Vitest (unit), Playwright (E2E) |
| Deployment | Vercel / Netlify |
---
## 2. Architecture
### Routing Structure
```
/ → Landing / login
/[orgSlug]/ → Organization overview
/[orgSlug]/documents/ → File browser (root)
/[orgSlug]/documents/folder/[id] → Folder view
/[orgSlug]/documents/file/[id] → Document / kanban viewer
/[orgSlug]/calendar/ → Calendar page
/[orgSlug]/chat/ → Matrix chat
/[orgSlug]/events/ → Event list
/[orgSlug]/events/[eventSlug]/ → Event detail
/[orgSlug]/events/[eventSlug]/team/ → Team management
/[orgSlug]/events/[eventSlug]/finances/ → Financial overview
/[orgSlug]/events/[eventSlug]/sponsors/ → Sponsor CRM
/[orgSlug]/events/[eventSlug]/contacts/ → Contact directory
/[orgSlug]/events/[eventSlug]/schedule/ → Schedule builder
/[orgSlug]/events/[eventSlug]/dept/[deptId]/ → Department dashboard
/[orgSlug]/settings/ → Organization settings
/[orgSlug]/account/ → User account settings
/admin/ → Platform admin panel
```
### Data Flow
1. **Layout server** (`[orgSlug]/+layout.server.ts`) loads org data, membership, permissions, members, activity, stats, and upcoming events via `select('*')` on the `organizations` table.
2. **Child pages** call `parent()` to inherit org context, then load page-specific data.
3. **Supabase client** is set via `setContext('supabase')` in the root layout and retrieved via `getContext` in child components.
4. **Realtime** subscriptions use Supabase channels for live updates (kanban cards, chat presence).
### Key Files
| File | Purpose |
|------|---------|
| `src/routes/[orgSlug]/+layout.server.ts` | Loads org, membership, permissions, members, stats |
| `src/routes/[orgSlug]/+layout.svelte` | Sidebar navigation, user menu, org header |
| `src/lib/supabase/types.ts` | Auto-generated + manual TypeScript types |
| `src/lib/utils/currency.ts` | Shared currency formatting, timezone, date format constants |
| `src/lib/utils/permissions.ts` | Permission checking utility |
| `src/lib/stores/toast.svelte` | Toast notification store |
| `src/lib/paraglide/messages.ts` | Generated i18n message functions |
---
## 3. Authentication & Authorization
### How It Works
- **Supabase Auth** handles email/password login with session management.
- Sessions are persisted via cookies; `locals.safeGetSession()` validates on every server load.
- **Playwright tests** use a setup project that logs in once and saves `storageState` for reuse.
### Authorization Layers
1. **Org membership**: Users must be members of an org to access it (checked in layout server).
2. **Role-based**: Each member has a role (`owner`, `admin`, `editor`, `viewer`).
3. **Permission-based**: Custom roles with granular permissions (e.g., `documents.view`, `calendar.view`, `settings.view`).
4. **Row-Level Security (RLS)**: All Supabase tables have RLS policies scoped to org membership.
### Room for Improvement
- **Invite flow**: Currently email-based; could add link-based invites.
- **SSO**: No SAML/OIDC support yet.
- **2FA**: Not implemented.
---
## 4. Organizations
### What It Is
The top-level entity. Every user belongs to one or more organizations. All data (events, documents, members) is scoped to an org.
### Database Schema
```sql
CREATE TABLE organizations (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
avatar_url TEXT,
theme_color TEXT DEFAULT '#00a3e0',
icon_url TEXT,
description TEXT DEFAULT '',
currency TEXT NOT NULL DEFAULT 'EUR',
date_format TEXT NOT NULL DEFAULT 'DD/MM/YYYY',
timezone TEXT NOT NULL DEFAULT 'Europe/Tallinn',
week_start_day TEXT NOT NULL DEFAULT 'monday',
default_calendar_view TEXT NOT NULL DEFAULT 'month',
default_event_color TEXT NOT NULL DEFAULT '#7986cb',
default_event_status TEXT NOT NULL DEFAULT 'planning',
default_dept_modules TEXT[] NOT NULL DEFAULT ARRAY['kanban','files','checklist'],
default_dept_layout TEXT NOT NULL DEFAULT 'split',
feature_chat BOOLEAN NOT NULL DEFAULT true,
feature_sponsors BOOLEAN NOT NULL DEFAULT true,
feature_contacts BOOLEAN NOT NULL DEFAULT true,
feature_budget BOOLEAN NOT NULL DEFAULT true,
matrix_space_id TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
```
### API (`src/lib/api/organizations.ts`)
- `createOrganization(supabase, name, slug, userId)` — creates org + adds creator as owner
- `updateOrganization(supabase, id, updates)` — updates name, slug, avatar
- `deleteOrganization(supabase, id)` — deletes org and cascades
- `fetchOrgMembers(supabase, orgId)` — lists members with profiles
- `inviteMember(supabase, orgId, email, role)` — sends invite
- `updateMemberRole(supabase, memberId, role)` — changes role
- `removeMember(supabase, memberId)` — removes member
### Overview Page (`/[orgSlug]/`)
Displays:
- **Stat cards**: Events, members, documents, kanban boards
- **Upcoming events**: Next 5 events with status badges
- **Recent activity**: Last 10 activity log entries
- **Member list**: First 6 members with avatars
---
## 5. Organization Settings
### What It Is
Configurable preferences that affect the entire organization. Accessible at `/[orgSlug]/settings` under the "General" tab.
### Sections
#### Organization Details
- **Name** and **URL slug** — core identity
- **Avatar** — upload/remove via Supabase Storage
- **Description** — free-text org description
- **Theme color** — color picker, stored as hex
#### Preferences
- **Currency** — ISO 4217 code (EUR, USD, GBP, etc.). Affects all financial displays via `formatCurrency()` utility. Uses locale-aware formatting (EUR → `de-DE` locale puts € after number).
- **Date format** — DD/MM/YYYY, MM/DD/YYYY, or YYYY-MM-DD
- **Timezone** — grouped by region (Europe, Americas, Asia/Pacific)
- **Week start day** — Monday or Sunday
- **Default calendar view** — Month, Week, or Day
#### Event Defaults
- **Default event color** — preset palette + custom color picker
- **Default event status** — Planning or Active
- **Default department layout** — Single, Split, Grid, or Focus+Sidebar
- **Default department modules** — toggle grid for: Kanban, Files, Checklist, Notes, Schedule, Contacts, Budget, Sponsors
#### Feature Toggles
- **Chat** — show/hide Matrix chat in sidebar
- **Budget & Finances** — enable/disable budget module
- **Sponsors** — enable/disable sponsor CRM
- **Contacts** — enable/disable contact directory
#### Danger Zone
- Delete organization (owner only)
- Leave organization (non-owners)
### Implementation
- **Settings UI**: `src/lib/components/settings/SettingsGeneral.svelte`
- **Currency utility**: `src/lib/utils/currency.ts` — shared `formatCurrency(amount, currency)` function with locale map
- **Feature toggles**: Wired into sidebar nav in `[orgSlug]/+layout.svelte` — conditionally shows/hides nav items
- **Calendar default**: Passed as `initialView` prop to `Calendar.svelte`
- **Event color default**: Used in event creation form (`events/+page.svelte`)
### Room for Improvement
- **Date format** is stored but not yet consumed by all date displays (calendar, activity feed, event cards). Need a shared `formatDate()` utility.
- **Timezone** is stored but not yet used for server-side date rendering.
- **Week start day** is stored but Calendar grid is still hardcoded to Monday.
- **Feature toggles** hide sidebar nav but don't yet block API access or hide modules within department dashboards.
- **Default dept modules/layout** are stored but not yet consumed by the department auto-create trigger.
---
## 6. Documents & File Browser
### What It Is
A hierarchical file system with folders, rich-text documents, and kanban boards. Supports org-wide and event-scoped views.
### Problems It Solves
- Centralized document storage per organization
- Auto-created folder structure for events and departments
- In-browser document editing without external tools
### Database
```sql
CREATE TABLE documents (
id UUID PRIMARY KEY,
org_id UUID REFERENCES organizations(id),
parent_id UUID REFERENCES documents(id), -- folder hierarchy
type TEXT NOT NULL, -- 'folder' | 'document' | 'kanban'
title TEXT NOT NULL,
content JSONB, -- TipTap JSON for documents
position INTEGER,
event_id UUID REFERENCES events(id), -- event scoping
department_id UUID REFERENCES event_departments(id), -- dept scoping
created_by UUID,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ
);
```
### Routing
- `/documents` — root file browser
- `/documents/folder/[id]` — folder contents
- `/documents/file/[id]` — document editor or kanban board
### Auto-Folder System
When events/departments are created, folders are auto-created:
```
📁 Events/
📁 [Event Name]/ (event_id FK)
📁 [Department Name]/ (department_id FK)
```
### Key Features
- **Drag-and-drop** file/folder reordering
- **Breadcrumb** navigation
- **Document locking** — prevents concurrent edits
- **Single-click** folder → navigate; document → inline editor; kanban → full page
- **Double-click** → open in new tab
### API (`src/lib/api/documents.ts`)
- `fetchDocuments`, `createDocument`, `moveDocument`, `deleteDocument`
- `ensureEventsFolder`, `createEventFolder`, `createDepartmentFolder`
- `findDepartmentFolder`, `fetchFolderContents`
### Room for Improvement
- **Real-time collaboration**: Currently single-user editing with locks. Could add CRDT-based collaborative editing.
- **Search**: No full-text search across documents.
- **Version history**: No document versioning.
- **File uploads**: Only structured documents; no raw file upload (PDF, images).
---
## 7. Kanban Boards
### What It Is
Drag-and-drop task boards with columns, cards, labels, checklists, and comments.
### Problems It Solves
- Visual task management within the document system
- Scoped task tracking per department
### Database
```sql
CREATE TABLE kanban_columns (id, document_id, title, position, color);
CREATE TABLE kanban_cards (id, column_id, title, description, position, due_date, assigned_to);
CREATE TABLE kanban_labels (id, document_id, name, color);
CREATE TABLE card_checklists (id, card_id, title);
CREATE TABLE card_checklist_items (id, checklist_id, text, completed, position);
CREATE TABLE task_comments (id, card_id, user_id, content, created_at);
```
### Key Features
- **Optimistic drag-and-drop**: Cards move instantly; DB persists fire-and-forget
- **Realtime**: Incremental handlers for INSERT/UPDATE/DELETE via Supabase channels
- **Labels**: Color-coded tags per board
- **Card detail**: Description, due date, assignee, checklists, comments
- **Drop indicators**: CSS box-shadow based (no layout shift)
### API (`src/lib/api/kanban.ts`)
- `fetchBoard`, `createColumn`, `moveColumn`, `createCard`, `moveCard`, `updateCard`
### Room for Improvement
- **Swimlanes**: No horizontal grouping by assignee or label.
- **Filters**: No filtering by label, assignee, or due date on the board view.
- **Card templates**: No reusable card templates.
---
## 8. Calendar
### What It Is
A month/week/day calendar view showing org events and optionally synced Google Calendar events.
### Problems It Solves
- Unified view of all organization events
- Google Calendar sync for external event visibility
### Implementation
- **Component**: `src/lib/components/calendar/Calendar.svelte`
- **Views**: Month (grid), Week (7-column), Day (single column with time slots)
- **Props**: `events`, `onDateClick`, `onEventClick`, `initialView`
- **Default view**: Configurable per org via `default_calendar_view` setting
### API (`src/lib/api/calendar.ts`)
- `getMonthDays(year, month)` — generates date grid for month view
- `isSameDay(date1, date2)` — date comparison utility
### Room for Improvement
- **Week start day**: Setting exists but calendar grid is hardcoded to Monday start.
- **Timezone**: Events display in browser timezone; should respect org timezone setting.
- **Recurring events**: Not supported.
- **Drag to create**: Can't drag across time slots to create events.
---
## 9. Chat (Matrix)
### What It Is
Real-time messaging powered by the Matrix protocol, using a self-hosted Synapse server.
### Problems It Solves
- Team communication without leaving the platform
- Org-scoped chat rooms (no cross-org leakage)
### Implementation
- Each org gets a Matrix space (`matrix_space_id` on organizations table)
- Users get Matrix credentials stored in `matrix_credentials` table
- Chat UI at `/[orgSlug]/chat/`
- Unread count badge in sidebar nav
- **Feature toggle**: Can be disabled per org via `feature_chat` setting
### Database
```sql
CREATE TABLE matrix_credentials (user_id, matrix_user_id, access_token, device_id);
ALTER TABLE organizations ADD COLUMN matrix_space_id TEXT;
```
### Room for Improvement
- **Event-scoped channels**: Currently org-wide only; could auto-create channels per event/department.
- **File sharing**: No in-chat file upload.
- **Threads**: Matrix supports threads but UI doesn't expose them.
- **Push notifications**: Not implemented.
---
## 10. Events
### What It Is
The core entity of the platform. Events are projects that contain departments, budgets, schedules, sponsors, and more.
### Problems It Solves
- Structured project management for event planning
- Status tracking (planning → active → completed → archived)
- Cross-department coordination
### Database
```sql
CREATE TABLE events (
id UUID PRIMARY KEY,
org_id UUID REFERENCES organizations(id),
name TEXT NOT NULL,
slug TEXT NOT NULL,
description TEXT,
status TEXT DEFAULT 'planning', -- planning, active, completed, archived
start_date TIMESTAMPTZ,
end_date TIMESTAMPTZ,
venue_name TEXT,
venue_address TEXT,
color TEXT,
created_by UUID,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ
);
```
### Key Features
- **Status pipeline**: Planning → Active → Completed → Archived with tab filtering
- **Color coding**: Each event has a color (defaults to org's `default_event_color`)
- **Auto-folder creation**: Creating an event auto-creates `Events/[EventName]/` folder
- **Event detail page**: Overview with status, dates, venue, team, and navigation to sub-pages
### API (`src/lib/api/events.ts`)
- `fetchEvents`, `fetchEventBySlug`, `createEvent`, `updateEvent`, `deleteEvent`
- `fetchEventDepartments`, `createDepartment`, `updateDepartment`, `deleteDepartment`
- `updateDepartmentPlannedBudget`
### Room for Improvement
- **Event templates**: No way to clone an event structure.
- **Guest list / registration**: Not yet implemented (on roadmap).
- **Public event page**: No public-facing event page for attendees.
---
## 11. Event Team & Departments
### What It Is
Team management within events. Each event has departments (e.g., "Marketing", "Logistics") with assigned members.
### Problems It Solves
- Clear ownership of event responsibilities
- Department-scoped modules (each dept gets its own dashboard)
### Database
```sql
CREATE TABLE event_departments (
id UUID PRIMARY KEY,
event_id UUID REFERENCES events(id),
name TEXT NOT NULL,
description TEXT,
color TEXT,
head_user_id UUID,
planned_budget NUMERIC DEFAULT 0,
created_at TIMESTAMPTZ
);
CREATE TABLE event_members (
id UUID PRIMARY KEY,
event_id UUID REFERENCES events(id),
user_id UUID,
department_id UUID REFERENCES event_departments(id),
role TEXT DEFAULT 'member'
);
```
### Key Features
- **Department creation**: Auto-creates folder + dashboard
- **Department head**: Assignable lead per department
- **Planned budget**: Per-department budget allocation
- **Member assignment**: Assign org members to specific departments
### Room for Improvement
- **Shift scheduling**: No volunteer shift management.
- **Department templates**: Can't save/reuse department structures.
---
## 12. Department Dashboard
### What It Is
A composable, layout-configurable dashboard per department with drag-and-drop module panels.
### Problems It Solves
- Each department needs different tools (marketing needs contacts, logistics needs schedules)
- Customizable workspace per team
### Database
```sql
CREATE TABLE department_dashboards (
id UUID PRIMARY KEY,
department_id UUID REFERENCES event_departments(id),
layout TEXT DEFAULT 'split' -- single, split, grid, focus_sidebar
);
CREATE TABLE dashboard_panels (
id UUID PRIMARY KEY,
dashboard_id UUID REFERENCES department_dashboards(id),
module TEXT NOT NULL, -- kanban, files, checklist, notes, schedule, contacts, budget, sponsors
position INTEGER,
config JSONB
);
```
### Layout Presets
- **Single**: One full-width panel
- **Split**: Two equal columns
- **Grid**: 2×2 grid
- **Focus + Sidebar**: Large left panel + narrow right panel
### Auto-Creation
A PostgreSQL trigger (`on_department_created_setup_dashboard`) auto-creates a dashboard with default panels when a department is inserted.
### Key Features
- **Add/remove modules**: Click to add any available module
- **Expand to fullscreen**: Each panel can expand to full-screen mode
- **Layout switching**: Change layout preset from dashboard header
### Room for Improvement
- **Drag-and-drop panel reordering**: Currently position is fixed by creation order.
- **Custom panel sizing**: All panels are equal size within a layout.
- **Default modules from org settings**: The `default_dept_modules` org setting is stored but not yet consumed by the auto-create trigger.
---
## 13. Checklists Module
### What It Is
Task checklists within department dashboards. Multiple checklists per department, each with ordered items.
### Problems It Solves
- Simple task tracking without full kanban overhead
- Pre-event preparation checklists
### Database
```sql
CREATE TABLE department_checklists (id, department_id, title, position);
CREATE TABLE department_checklist_items (id, checklist_id, text, completed, position);
```
### Key Features
- Multiple checklists per department
- Add/rename/delete checklists
- Toggle item completion with progress bar
- Drag-to-reorder items
### Component: `src/lib/components/modules/ChecklistWidget.svelte`
---
## 14. Notes Module
### What It Is
Rich-text notes per department with auto-save.
### Problems It Solves
- Meeting notes, decision logs, and documentation per department
- No need for external note-taking tools
### Database
```sql
CREATE TABLE department_notes (id, department_id, title, content, position, created_at, updated_at);
```
### Key Features
- Note list sidebar + textarea editor
- **Auto-save** with 500ms debounce
- Create/rename/delete notes
### Component: `src/lib/components/modules/NotesWidget.svelte`
### Room for Improvement
- **Rich text editor**: Currently plain textarea; could use TipTap like documents.
- **Collaborative editing**: No real-time collaboration.
---
## 15. Schedule Module
### What It Is
A timeline/program builder for event schedules with stages (rooms/tracks) and time blocks.
### Problems It Solves
- Event program planning with multiple parallel tracks
- Visual timeline view of the event day
### Database
```sql
CREATE TABLE schedule_stages (id, department_id, name, color, position);
CREATE TABLE schedule_blocks (
id, department_id, stage_id, title, description,
start_time TIMESTAMPTZ, end_time TIMESTAMPTZ,
speaker TEXT, color TEXT
);
```
### Key Features
- **Stages bar**: Color-coded tracks (e.g., "Main Stage", "Workshop Room")
- **Time blocks**: Scheduled items with start/end times, speaker, description
- **Date grouping**: Blocks grouped by date
- **Color picker**: Per-block color customization
### Component: `src/lib/components/modules/ScheduleWidget.svelte`
### Room for Improvement
- **Drag-and-drop timeline**: Currently list-based; could use a visual timeline grid.
- **Conflict detection**: No warning when blocks overlap on the same stage.
- **Public schedule**: No attendee-facing schedule view.
---
## 16. Contacts Module
### What It Is
A searchable vendor/contact directory per department.
### Problems It Solves
- Centralized vendor management for event logistics
- Quick access to contact info during event planning
### Database
```sql
CREATE TABLE department_contacts (
id, department_id, name, email, phone, company,
role TEXT, category TEXT, notes TEXT, website TEXT
);
```
### Categories
Predefined categories: Venue, Catering, AV/Tech, Security, Transport, Decoration, Photography, Entertainment, Printing, Accommodation, Sponsor, Speaker, Other.
### Key Features
- Search by name/company/email
- Filter by category
- Expandable detail rows
- CRUD modal with all contact fields
### Component: `src/lib/components/modules/ContactsWidget.svelte`
### Room for Improvement
- **Org-level contacts**: Currently department-scoped; could have a shared org contact book.
- **Import/export**: No CSV import/export.
- **Deduplication**: No duplicate detection.
---
## 17. Budget & Finances Module
### What It Is
Income/expense tracking with categories, planned vs. actual amounts, and cross-department financial overview.
### Problems It Solves
- Budget planning and tracking across departments
- Planned vs. actual variance analysis
- Receipt management
### Database
```sql
CREATE TABLE budget_categories (id, event_id, name, color);
CREATE TABLE budget_items (
id, event_id, department_id, category_id,
description TEXT, item_type TEXT, -- 'income' | 'expense'
planned_amount NUMERIC, actual_amount NUMERIC,
notes TEXT, receipt_document_id UUID
);
```
### Key Features
- **Overview/Income/Expense tabs**: Filtered views
- **Category management**: Color-coded budget categories
- **Planned vs. Actual**: Side-by-side comparison with diff column
- **Receipt linking**: Attach receipt documents to budget items
- **Department rollup**: Finances page shows cross-department totals
- **Sponsor allocation**: Link sponsor funds to specific departments
- **Currency formatting**: Uses org's currency setting via shared `formatCurrency()` utility
### Components
- `src/lib/components/modules/BudgetWidget.svelte` — department-level widget
- `src/routes/[orgSlug]/events/[eventSlug]/finances/+page.svelte` — cross-department overview
### API (`src/lib/api/budget.ts`)
- `fetchEventBudgetCategories`, `createBudgetCategory`, `deleteBudgetCategory`
- `fetchEventBudgetItems`, `createBudgetItem`, `updateBudgetItem`, `deleteBudgetItem`
### Room for Improvement
- **Budget approval workflow**: No approval process for expenses.
- **Currency conversion**: No multi-currency support within a single org.
- **Export**: No PDF/Excel export of financial reports.
- **Recurring items**: No support for recurring budget items.
---
## 18. Sponsors Module
### What It Is
A lightweight CRM for managing event sponsors with tiers, status pipeline, deliverables tracking, and fund allocation.
### Problems It Solves
- Sponsor relationship management
- Deliverable tracking (what was promised vs. delivered)
- Fund allocation across departments
### Database
```sql
CREATE TABLE sponsor_tiers (id, event_id, name, amount, color, position);
CREATE TABLE sponsors (
id, event_id, department_id, tier_id,
name, contact_name, contact_email, contact_phone, website,
status TEXT, -- prospect, contacted, negotiating, confirmed, declined, active, completed
amount NUMERIC, notes TEXT
);
CREATE TABLE sponsor_deliverables (id, sponsor_id, description, completed);
CREATE TABLE sponsor_allocations (id, event_id, sponsor_id, department_id, amount, notes);
```
### Status Pipeline
`prospect``contacted``negotiating``confirmed``declined``active``completed`
### Key Features
- **Tier management**: Gold/Silver/Bronze with amounts and colors
- **Status pipeline**: Visual status badges with color coding
- **Deliverables checklist**: Per-sponsor deliverable tracking
- **Contact info**: Name, email, phone, website per sponsor
- **Filter**: By status and tier
- **Fund allocation**: Allocate sponsor funds to specific departments
- **Currency formatting**: Uses org's currency setting
### Components
- `src/lib/components/modules/SponsorsWidget.svelte` — department-level widget
- `src/routes/[orgSlug]/events/[eventSlug]/sponsors/+page.svelte` — event-level overview
### API (`src/lib/api/sponsors.ts`)
- `fetchEventSponsors`, `createSponsor`, `updateSponsor`, `deleteSponsor`
- `fetchEventSponsorTiers`, `createSponsorTier`, `deleteSponsorTier`
- `fetchEventDeliverables`, `createDeliverable`, `toggleDeliverable`, `deleteDeliverable`
### Room for Improvement
- **Email integration**: No automated sponsor outreach.
- **Contract management**: No contract status tracking.
- **Sponsor portal**: No external-facing sponsor dashboard.
---
## 19. Activity Tracking
### What It Is
An audit log of all significant actions within an organization.
### Database
```sql
CREATE TABLE activity_log (
id UUID PRIMARY KEY,
org_id UUID, user_id UUID,
action TEXT, entity_type TEXT, entity_id UUID, entity_name TEXT,
metadata JSONB, created_at TIMESTAMPTZ
);
```
### Key Features
- Tracks: create, update, delete actions on all entity types
- Displayed on org overview page as "Recent Activity"
- Shows user name, action, entity, and timestamp
### API (`src/lib/api/activity.ts`)
- `logActivity(supabase, params)` — creates activity log entry
- `fetchRecentActivity(supabase, orgId, limit)` — fetches recent entries
### Room for Improvement
- **Granular tracking**: Not all actions are logged (e.g., budget item edits).
- **Notifications**: No push/email notifications based on activity.
- **Filtering**: No filtering by entity type or user on the overview page.
---
## 20. Tags System
### What It Is
Organization-wide tags that can be applied to various entities for categorization.
### Database
```sql
CREATE TABLE org_tags (id, org_id, name, color);
```
### Key Features
- Create/edit/delete tags with colors
- Managed in Settings → Tags tab
### Room for Improvement
- **Tag application**: Tags exist but aren't yet applied to events, documents, or other entities.
- **Tag filtering**: No cross-entity filtering by tag.
---
## 21. Roles & Permissions
### What It Is
A flexible role-based access control system with custom roles and granular permissions.
### Database
```sql
CREATE TABLE org_roles (
id UUID PRIMARY KEY,
org_id UUID, name TEXT, color TEXT,
permissions TEXT[],
is_default BOOLEAN, is_system BOOLEAN, position INTEGER
);
-- org_members has role (text) and role_id (FK to org_roles)
```
### Built-in Roles
- **Owner**: Full access, can delete org
- **Admin**: Full access except org deletion
- **Editor**: Can create/edit content
- **Viewer**: Read-only access
### Custom Roles
Admins can create custom roles with specific permissions:
- `documents.view`, `documents.edit`, `documents.delete`
- `calendar.view`, `calendar.edit`
- `settings.view`, `settings.edit`
- `members.view`, `members.invite`, `members.remove`
### Implementation
- `src/lib/utils/permissions.ts``hasPermission(userRole, userPermissions, permission)` utility
- Layout uses `canAccess()` to conditionally show nav items
### Room for Improvement
- **Event-level roles**: Permissions are org-wide; no per-event role assignment.
- **Department-level permissions**: No per-department access control.
---
## 22. Google Calendar Integration
### What It Is
Two-way sync between Root's calendar and Google Calendar.
### Implementation
- OAuth2 flow for connecting Google account
- Org-level Google Calendar connection (stored in org settings)
- Syncs events bidirectionally
- Subscribe URL for external calendar apps
### API (`src/lib/api/google-calendar.ts`)
- `fetchGoogleCalendarEvents` — fetches events from connected Google Calendar
- `getCalendarSubscribeUrl` — generates iCal subscribe URL
- Push notifications for real-time sync
### Room for Improvement
- **Per-user sync**: Currently org-level only.
- **Outlook/iCal**: Only Google Calendar supported.
- **Conflict resolution**: No handling of concurrent edits.
---
## 23. Internationalization (i18n)
### What It Is
Full UI translation support using Paraglide v2.
### Supported Languages
- **English** (`en`) — base locale
- **Estonian** (`et`)
### Implementation
- ~248 message keys covering all UI strings
- Import pattern: `import * as m from "$lib/paraglide/messages"`
- Parameterized messages: `{m.key({ param: value })}`
- Config: `project.inlang/settings.json`
- Vite plugin: `paraglideVitePlugin`
### Key Files
- `messages/en.json` — English translations
- `messages/et.json` — Estonian translations
- `src/lib/paraglide/` — generated output
### Room for Improvement
- **Language picker**: No in-app language switcher (uses browser locale).
- **More languages**: Only 2 languages supported.
- **RTL support**: No right-to-left language support.
---
## 24. Platform Admin
### What It Is
A super-admin panel for managing all organizations on the platform.
### Route: `/admin/`
### Database
```sql
-- Migration 030: platform_admin
-- Migration 031: platform_admin_rls
```
### Key Features
- View all organizations
- Platform-level statistics
### Room for Improvement
- **User management**: No platform-level user management.
- **Billing**: No subscription/billing management.
- **Analytics**: No usage analytics dashboard.
---
## 25. Testing Strategy
### Unit Tests (Vitest)
- **Location**: `src/lib/api/*.test.ts`
- **Coverage**: All API modules have unit tests
- **Count**: 112+ tests across 9 files
- **Run**: `npm test`
### E2E Tests (Playwright)
- **Location**: `tests/e2e/`
- **Key files**: `features.spec.ts`, `events.spec.ts`
- **Setup**: Auth setup project with `storageState` persistence
- **Important**: Uses `waitUntil: 'networkidle'` for Svelte 5 hydration timing
### Key Testing Notes
- Svelte 5 uses event delegation — handlers only work after hydration
- Serial test suites can cascade failures if early tests fail
- Database triggers (auto-create dashboard) can be flaky in test environments
---
## 26. Database Migrations
### Migration History
| # | Name | Description |
|---|------|-------------|
| 001 | initial_schema | Organizations, org_members, profiles, documents |
| 002 | card_checklists | Kanban card checklists |
| 003 | google_calendar | Google Calendar integration |
| 004 | org_google_calendar | Org-level Google Calendar |
| 005 | roles_and_invites | Custom roles, invites |
| 006 | simplify_google_calendar | Simplified Google Calendar schema |
| 007 | org_theme | Theme color, icon_url |
| 008 | kanban_enhancements | Kanban improvements |
| 009 | activity_tracking | Activity log table |
| 010 | tags_system | Org tags |
| 011 | teams_roles | Team roles |
| 012 | task_comments | Kanban card comments |
| 013 | kanban_labels | Kanban labels |
| 014 | document_enhancements | Document improvements |
| 015 | migrate_kanban_to_documents | Kanban as document type |
| 016 | document_locks | Document locking |
| 017 | avatars_storage | Avatar storage bucket |
| 018 | user_avatars_storage | User avatar storage |
| 019 | fix_org_members_profiles_fk | FK fix |
| 020 | matrix_credentials | Matrix chat credentials |
| 021 | org_matrix_space | Org Matrix space |
| 022 | events | Events table |
| 023 | event_team_management | Departments, event members |
| 024 | profile_extended_fields | Extended profile fields |
| 025 | event_tasks | Event tasks |
| 026 | department_dashboards | Dashboard, panels, checklists, notes |
| 027 | remove_default_seeds | Remove seed data |
| 028 | schedule_and_contacts | Schedule stages/blocks, contacts |
| 029 | budget_and_sponsors | Budget, sponsors, deliverables |
| 030 | platform_admin | Platform admin |
| 031 | platform_admin_rls | Platform admin RLS |
| 032 | document_event_dept_fks | Document event/dept FKs |
| 033 | files_storage | File storage |
| 034 | layout_triple | Triple layout preset |
| 035 | budget_receipts | Budget receipt linking |
| 036 | finance_enhancements | Finance improvements |
| 037 | org_settings | Organization settings expansion |
### DB Workflow
```bash
npm run db:push # Push pending migrations
npm run db:types # Regenerate TypeScript types
npm run db:migrate # Push + regenerate in one step
```
---
## Summary of Improvement Priorities
### High Priority
1. **Shared `formatDate()` utility** consuming org's `date_format` and `timezone` settings
2. **Wire `week_start_day`** into Calendar grid generation
3. **Wire `default_dept_modules`** into the department auto-create trigger
4. **Feature toggles** should also hide modules within department dashboards, not just sidebar nav
### Medium Priority
5. **Event templates** — clone event structures
6. **Guest list / registration** — attendee management
7. **Document search** — full-text search across documents
8. **Export** — PDF/Excel export for budgets and sponsor reports
### Low Priority
9. **Real-time collaboration** on documents
10. **SSO / 2FA** authentication
11. **Push notifications** for activity
12. **Public event pages** for attendees

View File

@@ -1,20 +1,23 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"app_name": "Root",
"nav_home": "Home",
"nav_files": "Files",
"nav_storage": "Storage",
"nav_calendar": "Calendar",
"nav_settings": "Settings",
"nav_kanban": "Kanban",
"user_menu_account_settings": "Account Settings",
"user_menu_switch_org": "Switch Organization",
"user_menu_logout": "Log Out",
"btn_new": "+ New",
"btn_new": "New",
"btn_create": "Create",
"btn_cancel": "Cancel",
"btn_save": "Save",
"btn_delete": "Delete",
"btn_edit": "Edit",
"btn_close": "Close",
"btn_creating": "Creating...",
"btn_upload": "Upload",
"btn_remove": "Remove",
"login_title": "Welcome to Root",
@@ -42,6 +45,7 @@
"org_selector_slug_placeholder": "my-team",
"org_overview": "Organization Overview",
"files_title": "Files",
"files_subtitle": "Organization files are stored here.",
"files_breadcrumb_home": "Home",
"files_create_title": "Create New",
"files_type_document": "Document",
@@ -56,7 +60,7 @@
"files_context_move": "Move to...",
"files_context_delete": "Delete",
"files_context_open_tab": "Open in new tab",
"files_empty": "No files yet. Click + New to create one.",
"files_empty": "No files yet. Click New to create one.",
"files_toggle_view": "Toggle view",
"kanban_title": "Kanban",
"kanban_create_board": "Create Board",
@@ -68,7 +72,7 @@
"kanban_add_column": "Add Column",
"kanban_column_name_label": "Column Name",
"kanban_column_name_placeholder": "e.g. To Do, In Progress, Done",
"kanban_add_card": "Add Card",
"kanban_add_card": "Add card",
"kanban_card_details": "Card Details",
"kanban_card_title_label": "Title",
"kanban_card_title_placeholder": "Card title",
@@ -81,6 +85,7 @@
"kanban_go_to_files": "Go to Files",
"kanban_select_board": "Select a board above",
"calendar_title": "Calendar",
"calendar_subtitle": "Keep track of organization events here.",
"calendar_subscribe": "Subscribe to Calendar",
"calendar_refresh": "Refresh Events",
"calendar_settings": "Calendar Settings",
@@ -91,12 +96,56 @@
"calendar_event_date": "Date",
"calendar_event_time": "Time",
"calendar_event_desc": "Description",
"calendar_event_desc_placeholder": "Add a description...",
"calendar_event_all_day": "All day event",
"calendar_event_all_day_label": "All day",
"calendar_event_start": "Start",
"calendar_event_end": "End",
"calendar_event_color": "Color",
"calendar_event_no_title": "(No title)",
"calendar_event_fallback_title": "Event",
"calendar_event_google": "Google Calendar Event",
"calendar_event_synced": "Synced to Google Calendar",
"calendar_event_local": "Local Event",
"calendar_open_google": "Open in Google Calendar",
"calendar_sync_google": "Sync to Google Calendar",
"calendar_delete_event": "Delete Event",
"calendar_edit_event_btn": "Edit Event",
"calendar_today": "Today",
"calendar_previous": "Previous",
"calendar_next": "Next",
"calendar_view_day": "Day",
"calendar_view_week": "Week",
"calendar_view_month": "Month",
"calendar_day_mon": "Mon",
"calendar_day_tue": "Tue",
"calendar_day_wed": "Wed",
"calendar_day_thu": "Thu",
"calendar_day_fri": "Fri",
"calendar_day_sat": "Sat",
"calendar_day_sun": "Sun",
"calendar_no_events": "No events for this day",
"settings_title": "Settings",
"settings_tab_general": "General",
"settings_tab_members": "Members",
"settings_tab_roles": "Roles",
"settings_tab_tags": "Tags",
"settings_tab_integrations": "Integrations",
"settings_tab_activity": "Activity Log",
"settings_activity_title": "Activity Log",
"settings_activity_desc": "Full history of all actions performed in this organization.",
"settings_activity_empty": "No activity recorded yet.",
"settings_activity_load_more": "Load more",
"settings_activity_loading": "Loading...",
"settings_activity_end": "You've reached the end of the activity log.",
"settings_activity_filter_all": "All actions",
"settings_activity_filter_create": "Created",
"settings_activity_filter_update": "Updated",
"settings_activity_filter_delete": "Deleted",
"settings_activity_filter_move": "Moved",
"settings_activity_filter_rename": "Renamed",
"settings_activity_count": "{count} entries",
"settings_activity_search_placeholder": "Search activity...",
"settings_general_title": "Organization details",
"settings_general_avatar": "Avatar",
"settings_general_name": "Name",
@@ -110,6 +159,29 @@
"settings_general_leave_org": "Leave Organization",
"settings_general_leave_org_desc": "Leave this organization. You will need to be re-invited to rejoin.",
"settings_general_leave_btn": "Leave {orgName}",
"settings_social_title": "Social & Links",
"settings_social_desc": "Add your organization's website and social media links.",
"settings_social_website": "Website",
"settings_social_website_placeholder": "https://yourorg.com",
"settings_social_instagram": "Instagram",
"settings_social_instagram_placeholder": "https://instagram.com/yourorg",
"settings_social_facebook": "Facebook",
"settings_social_facebook_placeholder": "https://facebook.com/yourorg",
"settings_social_discord": "Discord",
"settings_social_discord_placeholder": "https://discord.gg/yourorg",
"settings_social_linkedin": "LinkedIn",
"settings_social_linkedin_placeholder": "https://linkedin.com/company/yourorg",
"settings_social_x": "X (Twitter)",
"settings_social_x_placeholder": "https://x.com/yourorg",
"settings_social_youtube": "YouTube",
"settings_social_youtube_placeholder": "https://youtube.com/@yourorg",
"settings_social_tiktok": "TikTok",
"settings_social_tiktok_placeholder": "https://tiktok.com/@yourorg",
"settings_social_fienta": "Fienta",
"settings_social_fienta_placeholder": "https://fienta.com/yourorg",
"settings_social_twitch": "Twitch",
"settings_social_twitch_placeholder": "https://twitch.tv/yourorg",
"settings_social_save": "Save Links",
"settings_tags_title": "Organization Tags",
"settings_tags_desc": "Manage tags that can be used across all Kanban boards.",
"settings_tags_create": "Create Tag",
@@ -118,12 +190,12 @@
"settings_members_title": "Team Members ({count})",
"settings_members_invite": "Invite Member",
"settings_members_pending": "Pending Invites",
"settings_members_invited_as": "Invited as {role} Expires {date}",
"settings_members_invited_as": "Invited as {role} • Expires {date}",
"settings_members_copy_link": "Copy Link",
"settings_members_unknown": "Unknown User",
"settings_members_no_name": "No name",
"settings_members_no_email": "No email",
"settings_members_remove": "Remove from Org",
"settings_members_remove": "Remove from organization",
"settings_invite_title": "Invite Member",
"settings_invite_email": "Email address",
"settings_invite_email_placeholder": "colleague@example.com",
@@ -160,7 +232,7 @@
"settings_connect_cal_desc": "Paste your Google Calendar's shareable link or calendar ID. The calendar must be set to public in Google Calendar settings.",
"settings_connect_cal_how": "How to get your calendar link:",
"settings_connect_cal_step1": "Open Google Calendar",
"settings_connect_cal_step2": "Click the 3 dots next to your calendar Settings",
"settings_connect_cal_step2": "Click the 3 dots next to your calendar → Settings",
"settings_connect_cal_step3": "Under \"Access permissions\", check \"Make available to public\"",
"settings_connect_cal_step4": "Scroll to \"Integrate calendar\" and copy the Calendar ID or Public URL",
"settings_connect_cal_input_label": "Calendar URL or ID",
@@ -387,5 +459,601 @@
"chat_joining": "Setting up your account...",
"chat_join_success": "Chat account created! Welcome.",
"chat_join_error": "Failed to set up chat. Please try again.",
"chat_disconnect": "Disconnect from Chat"
"chat_disconnect": "Disconnect from Chat",
"dept_dashboard_no_modules": "No modules configured yet",
"dept_dashboard_add_first": "Add your first module",
"dept_dashboard_add_module": "Add Module",
"dept_dashboard_all_added": "All modules are already added",
"dept_dashboard_expand": "Expand",
"dept_dashboard_remove_module": "Remove module",
"dept_dashboard_coming_soon": "Coming soon",
"dept_dashboard_module_coming_soon": "Module coming soon",
"dept_dashboard_departments": "Departments",
"dept_checklist_no_items": "No checklists yet",
"dept_checklist_add": "Add checklist",
"dept_checklist_add_item": "Add item...",
"dept_notes_no_notes": "No notes yet",
"dept_notes_new": "New note",
"dept_notes_select": "Select a note",
"dept_notes_placeholder": "Start writing...",
"dept_notes_title_placeholder": "Note title...",
"dept_kanban_open": "Open Tasks Board",
"dept_kanban_desc": "Task board for this department",
"dept_files_open": "Open Files",
"dept_files_desc": "Department files and documents",
"dept_quick_add": "Quick add",
"dept_modules_label": "Modules",
"dept_dashboard_collapse": "Collapse",
"dept_dashboard_move_left": "Move left",
"dept_dashboard_move_right": "Move right",
"dept_dashboard_layout_1col": "1 Column",
"dept_dashboard_layout_2col": "2 Columns",
"dept_dashboard_layout_3col": "3 Columns",
"dept_dashboard_layout_grid": "2x2 Grid",
"dept_module_kanban": "Kanban",
"dept_module_files": "Files",
"dept_module_checklist": "Checklist",
"dept_module_notes": "Notes",
"dept_module_schedule": "Schedule",
"dept_module_contacts": "Contacts",
"dept_module_budget": "Budget",
"dept_module_sponsors": "Sponsors",
"dept_module_map": "Map",
"toast_error_update_layout": "Failed to update layout",
"toast_error_add_module": "Failed to add module",
"toast_error_remove_module": "Failed to remove module",
"toast_error_reorder_modules": "Failed to reorder modules",
"toast_error_add_item": "Failed to add item",
"toast_error_update_item": "Failed to update item",
"toast_error_delete_item": "Failed to delete item",
"toast_error_create_checklist": "Failed to create checklist",
"toast_error_delete_checklist": "Failed to delete checklist",
"toast_error_rename_checklist": "Failed to rename checklist",
"toast_error_create_note": "Failed to create note",
"toast_error_update_note": "Failed to update note",
"toast_error_delete_note": "Failed to delete note",
"toast_error_create_stage": "Failed to create stage",
"toast_error_delete_stage": "Failed to delete stage",
"toast_error_create_block": "Failed to create schedule block",
"toast_error_update_block": "Failed to update schedule block",
"toast_error_delete_block": "Failed to delete schedule block",
"toast_error_create_contact": "Failed to create contact",
"toast_error_update_contact": "Failed to update contact",
"toast_error_delete_contact": "Failed to delete contact",
"toast_error_create_category": "Failed to create category",
"toast_error_delete_category": "Failed to delete category",
"toast_error_create_budget_item": "Failed to create budget item",
"toast_error_update_budget_item": "Failed to update budget item",
"toast_error_delete_budget_item": "Failed to delete budget item",
"toast_error_upload_receipt": "Failed to upload receipt",
"toast_success_receipt_attached": "Receipt \"{name}\" attached",
"toast_error_create_tier": "Failed to create tier",
"toast_error_delete_tier": "Failed to delete tier",
"toast_error_create_sponsor": "Failed to create sponsor",
"toast_error_update_sponsor": "Failed to update sponsor",
"toast_error_delete_sponsor": "Failed to delete sponsor",
"toast_error_create_deliverable": "Failed to create deliverable",
"toast_error_update_deliverable": "Failed to update deliverable",
"toast_error_delete_deliverable": "Failed to delete deliverable",
"checklist_rename": "Rename",
"checklist_delete": "Delete checklist",
"checklist_add_item_placeholder": "Add item...",
"checklist_name_placeholder": "Checklist name...",
"checklist_no_items": "No checklists yet",
"checklist_add": "Add checklist",
"notes_new": "New note",
"notes_title_placeholder": "Note title...",
"notes_placeholder": "Start writing...",
"notes_no_notes": "No notes yet",
"notes_select": "Select a note",
"notes_export_document": "Export as document",
"notes_delete": "Delete note",
"notes_exported": "Exported \"{title}\" as document",
"notes_export_error": "Failed to export note",
"schedule_timeline": "Timeline",
"schedule_list": "List",
"schedule_add_block": "Add Block",
"schedule_manage_stages": "Manage Stages",
"schedule_no_blocks": "No schedule blocks yet",
"schedule_add_first": "Add your first block to start building the schedule.",
"schedule_all_day": "All day",
"schedule_no_stage": "No stage",
"schedule_add_stage_title": "Add Stage / Room",
"schedule_stage_name_placeholder": "e.g. Main Stage",
"schedule_add_stage": "Add Stage",
"schedule_existing_stages": "Existing Stages",
"schedule_no_stages": "No stages yet",
"schedule_add_block_title": "Add Schedule Block",
"schedule_edit_block_title": "Edit Schedule Block",
"schedule_block_title_label": "Title",
"schedule_block_title_placeholder": "e.g. Opening Ceremony",
"schedule_block_speaker_label": "Speaker / Host",
"schedule_block_speaker_placeholder": "e.g. John Doe",
"schedule_block_start_label": "Start Time",
"schedule_block_end_label": "End Time",
"schedule_block_stage_label": "Stage / Room",
"schedule_block_no_stage": "No stage",
"schedule_block_description_label": "Description",
"schedule_block_description_placeholder": "Optional description...",
"schedule_block_color_label": "Color",
"schedule_block_delete": "Delete Block",
"contacts_search_placeholder": "Search contacts...",
"contacts_add": "Add Contact",
"contacts_no_contacts": "No contacts yet",
"contacts_add_first": "Add your first contact to build your directory.",
"contacts_no_results": "No contacts match your search",
"contacts_category_all": "All",
"contacts_category_venue": "Venue",
"contacts_category_catering": "Catering",
"contacts_category_av": "AV / Tech",
"contacts_category_security": "Security",
"contacts_category_transport": "Transport",
"contacts_category_entertainment": "Entertainment",
"contacts_category_media": "Media",
"contacts_category_sponsor": "Sponsor",
"contacts_category_other": "Other",
"contacts_add_title": "Add Contact",
"contacts_edit_title": "Edit Contact",
"contacts_name_label": "Name",
"contacts_name_placeholder": "Contact name",
"contacts_role_label": "Role / Title",
"contacts_role_placeholder": "e.g. Event Manager",
"contacts_company_label": "Company",
"contacts_company_placeholder": "Company name",
"contacts_email_label": "Email",
"contacts_email_placeholder": "email@example.com",
"contacts_phone_label": "Phone",
"contacts_phone_placeholder": "+372 ...",
"contacts_website_label": "Website",
"contacts_website_placeholder": "https://...",
"contacts_category_label": "Category",
"contacts_notes_label": "Notes",
"contacts_notes_placeholder": "Optional notes...",
"contacts_delete_confirm": "Delete this contact?",
"budget_income": "Income",
"budget_expenses": "Expenses",
"budget_planned": "Planned: {amount}",
"budget_planned_balance": "Planned Balance",
"budget_actual_balance": "Actual Balance",
"budget_view_all": "All",
"budget_view_income": "Income",
"budget_view_expenses": "Expenses",
"budget_add_category": "Category",
"budget_add_item": "Add Item",
"budget_no_items": "No budget items yet",
"budget_col_type": "Type",
"budget_col_description": "Description",
"budget_col_category": "Category",
"budget_col_planned": "Planned",
"budget_col_actual": "Actual",
"budget_col_diff": "Diff",
"budget_col_receipt": "Receipt",
"budget_uncategorized": "Uncategorized",
"budget_total": "Total",
"budget_missing_receipt": "Missing invoice/receipt - click to upload",
"budget_missing_receipt_short": "Missing invoice/receipt",
"budget_receipt_attached": "Receipt attached",
"budget_add_item_title": "Add Budget Item",
"budget_edit_item_title": "Edit Budget Item",
"budget_description_label": "Description",
"budget_description_placeholder": "e.g. Venue rental",
"budget_type_label": "Type",
"budget_type_expense": "Expense",
"budget_type_income": "Income",
"budget_category_label": "Category",
"budget_planned_amount_label": "Planned Amount",
"budget_actual_amount_label": "Actual Amount",
"budget_notes_label": "Notes",
"budget_notes_placeholder": "Optional notes...",
"budget_add_category_title": "Add Category",
"budget_category_name_label": "Name",
"budget_category_name_placeholder": "e.g. Venue",
"budget_category_color_label": "Color",
"budget_select_color": "Select color {color}",
"budget_existing_categories": "Existing Categories",
"sponsors_search_placeholder": "Search sponsors...",
"sponsors_add_tier": "Add Tier",
"sponsors_add_sponsor": "Add Sponsor",
"sponsors_no_sponsors": "No sponsors yet",
"sponsors_add_first": "Add sponsor tiers and start tracking your sponsors.",
"sponsors_no_results": "No sponsors match your filters",
"sponsors_filter_all_statuses": "All Statuses",
"sponsors_filter_all_tiers": "All Tiers",
"sponsors_status_prospect": "Prospect",
"sponsors_status_contacted": "Contacted",
"sponsors_status_confirmed": "Confirmed",
"sponsors_status_declined": "Declined",
"sponsors_status_active": "Active",
"sponsors_contact_label": "Contact",
"sponsors_amount_label": "Amount",
"sponsors_deliverables_label": "Deliverables",
"sponsors_deliverable_placeholder": "Add deliverable...",
"sponsors_no_deliverables": "No deliverables yet",
"sponsors_notes_label": "Notes",
"sponsors_add_tier_title": "Add Sponsor Tier",
"sponsors_tier_name_label": "Tier Name",
"sponsors_tier_name_placeholder": "e.g. Gold",
"sponsors_tier_amount_label": "Minimum Amount",
"sponsors_tier_color_label": "Color",
"sponsors_existing_tiers": "Existing Tiers",
"sponsors_no_tiers": "No tiers yet",
"sponsors_add_sponsor_title": "Add Sponsor",
"sponsors_edit_sponsor_title": "Edit Sponsor",
"sponsors_name_label": "Sponsor Name",
"sponsors_name_placeholder": "Company name",
"sponsors_tier_label": "Tier",
"sponsors_no_tier": "No tier",
"sponsors_status_label": "Status",
"sponsors_sponsor_amount_label": "Amount (€)",
"sponsors_sponsor_amount_placeholder": "0",
"sponsors_contact_name_label": "Contact Name",
"sponsors_contact_name_placeholder": "Contact person",
"sponsors_contact_email_label": "Contact Email",
"sponsors_contact_email_placeholder": "email@example.com",
"sponsors_contact_phone_label": "Contact Phone",
"sponsors_contact_phone_placeholder": "+372 ...",
"sponsors_website_label": "Website",
"sponsors_website_placeholder": "https://...",
"sponsors_notes_placeholder": "Optional notes...",
"sponsors_delete_confirm": "Delete this sponsor?",
"sponsors_select_color": "Select color {color}",
"files_widget_loading": "Loading files...",
"files_widget_no_folder": "Department folder not yet created",
"files_widget_empty": "No files yet",
"files_widget_full_view": "Full View",
"files_widget_create_title": "Create New",
"files_widget_type_document": "Document",
"files_widget_type_folder": "Folder",
"files_widget_type_kanban": "Kanban Board",
"files_widget_name_label": "Name",
"files_widget_doc_placeholder": "Document name",
"files_widget_folder_placeholder": "Folder name",
"files_widget_kanban_placeholder": "Board name",
"files_widget_drop_files": "Drop files to upload",
"files_widget_uploading": "Uploading {name}...",
"kanban_widget_loading": "Loading boards...",
"kanban_widget_no_folder": "Department folder not yet created",
"kanban_widget_no_boards": "No kanban boards yet",
"kanban_widget_create": "Create Board",
"kanban_widget_create_title": "Create Kanban Board",
"kanban_widget_name_label": "Board Name",
"kanban_widget_name_placeholder": "e.g. Task Tracker",
"finances_title": "Event Finances",
"finances_subtitle": "Budget overview across all departments",
"finances_total_income": "Total Income",
"finances_total_expenses": "Total Expenses",
"finances_net_balance": "Net Balance",
"finances_missing_receipts": "Missing Receipts",
"finances_items_without_receipts": "{count} items without receipts",
"finances_planned": "planned",
"finances_view_by_dept": "By Department",
"finances_view_by_category": "By Category",
"finances_filter_all": "All",
"finances_filter_income": "Income",
"finances_filter_expenses": "Expenses",
"finances_no_items": "No budget items yet",
"finances_no_items_desc": "Budget items will appear here once departments add them.",
"finances_col_description": "Description",
"finances_col_department": "Department",
"finances_col_category": "Category",
"finances_col_planned": "Planned",
"finances_col_actual": "Actual",
"finances_col_diff": "Diff",
"finances_col_receipt": "Receipt",
"finances_uncategorized": "Uncategorized",
"finances_no_department": "No Department",
"finances_group_total": "Subtotal",
"page_chat": "Chat",
"page_department": "Department",
"page_invite": "Invitation",
"map_tool_grab": "Grab",
"map_tool_select": "Select",
"map_tool_pin": "Pin",
"map_tool_polygon": "Polygon",
"map_tool_rectangle": "Rectangle",
"map_add_layer": "Add Map Layer",
"map_add_image_layer": "Add Image Map Layer",
"map_layer_name": "Layer Name",
"map_street_map": "Street Map",
"map_custom_image": "Custom Image",
"map_osm_tiles": "OpenStreetMap tiles",
"map_upload_venue": "Upload venue map",
"map_choose_image": "Choose image file",
"map_uploading": "Uploading...",
"map_image_url": "Image URL",
"map_image_desc": "Upload a venue floor plan or map image. Aspect ratio will be preserved.",
"map_objects": "Objects",
"map_no_objects": "No objects yet",
"map_export_png": "Export PNG",
"map_rename_layer": "Rename Layer",
"map_edit_pin": "Edit Pin",
"map_add_pin": "Add Pin",
"map_edit_shape": "Edit Shape",
"map_label": "Label",
"map_description": "Description",
"map_color": "Color",
"map_pen_hint_points": "{count} point(s)",
"map_pen_hint_close": "click near first point to close, or press Esc to cancel",
"map_show_pin_labels": "Show pin labels",
"map_show_shape_labels": "Show shape labels",
"map_address": "Address",
"map_address_placeholder": "e.g., Tallinn, Estonia",
"map_change_address": "Change address",
"map_rename": "Rename",
"map_duplicate": "Duplicate",
"map_or": "or",
"map_load": "Load",
"map_go": "Go",
"event_nav_finances": "Finances",
"event_nav_sponsors": "Sponsors",
"event_nav_contacts": "Contacts",
"event_nav_files": "Files",
"event_nav_departments": "Departments",
"module_kanban_label": "Kanban",
"module_kanban_desc": "Task boards with columns and cards for tracking work",
"module_files_label": "Files",
"module_files_desc": "Shared documents, folders, and file storage",
"module_checklist_label": "Checklist",
"module_checklist_desc": "Simple to-do lists with progress tracking",
"module_notes_label": "Notes",
"module_notes_desc": "Freeform notes and documentation",
"module_schedule_label": "Schedule",
"module_schedule_desc": "Timeline and timetable for stages and sessions",
"module_contacts_label": "Contacts",
"module_contacts_desc": "Vendor and contact directory with categories",
"module_budget_label": "Budget",
"module_budget_desc": "Income and expense tracking with planned vs actual",
"module_sponsors_label": "Sponsors",
"module_sponsors_desc": "Sponsor CRM with tiers, deliverables, and pipeline",
"module_map_label": "Map",
"module_map_desc": "Interactive venue map with pins and areas",
"contact_cat_general": "General",
"contact_cat_vendor": "Vendor",
"contact_cat_sponsor": "Sponsor",
"contact_cat_speaker": "Speaker",
"contact_cat_venue": "Venue",
"contact_cat_catering": "Catering",
"contact_cat_av_tech": "AV / Tech",
"contact_cat_transport": "Transport",
"contact_cat_security": "Security",
"contact_cat_media": "Media",
"contacts_all_categories": "All Categories",
"sponsor_status_prospect": "Prospect",
"sponsor_status_contacted": "Contacted",
"sponsor_status_confirmed": "Confirmed",
"sponsor_status_declined": "Declined",
"sponsor_status_active": "Active",
"sponsors_all_statuses": "All Statuses",
"sponsors_all_tiers": "All Tiers",
"sponsors_no_tier_filter": "No Tier",
"accent_blue": "Blue (Default)",
"accent_green": "Green",
"accent_red": "Red",
"accent_amber": "Amber",
"accent_purple": "Purple",
"accent_pink": "Pink",
"accent_indigo": "Indigo",
"accent_teal": "Teal",
"role_color_red": "Red",
"role_color_amber": "Amber",
"role_color_emerald": "Emerald",
"role_color_blue": "Blue",
"role_color_indigo": "Indigo",
"role_color_violet": "Violet",
"role_color_pink": "Pink",
"role_color_gray": "Gray",
"kanban_import_json": "Import JSON",
"kanban_export_json": "Export JSON",
"kanban_rename_from_list": "Rename from the documents list.",
"admin_tab_overview": "Overview",
"admin_tab_organizations": "Organizations",
"admin_tab_users": "Users",
"admin_tab_events": "Events",
"card_add_title": "Add Card",
"card_details_title": "Card Details",
"card_title_label": "Title",
"card_title_placeholder": "Card title",
"card_description_label": "Description",
"card_description_placeholder": "Add a more detailed description...",
"card_tags": "Tags",
"card_tags_done": "Done",
"card_tags_manage": "Manage",
"card_tags_new_placeholder": "New tag name...",
"card_tags_new_short": "New tag...",
"card_tags_add": "+ Add tag",
"card_tags_all_added": "All tags added",
"card_assignee": "Assignee",
"card_due_date": "Due Date",
"card_priority": "Priority",
"card_priority_low": "Low",
"card_priority_medium": "Medium",
"card_priority_high": "High",
"card_priority_urgent": "Urgent",
"card_checklist": "Checklist",
"card_checklist_add_placeholder": "Add an item...",
"card_checklist_add_item_placeholder": "Add checklist item...",
"card_comments": "Comments",
"card_comments_none": "No comments yet",
"card_comments_add_placeholder": "Add a comment...",
"card_comments_unknown": "Unknown",
"card_delete": "Delete Card",
"card_save_changes": "Save Changes",
"card_loading": "Loading...",
"kanban_add_col": "Add column",
"kanban_confirm_delete_column": "Delete this column and all its cards?",
"kanban_confirm_delete_card": "Delete this card?",
"kanban_toast_changes_saved": "Changes saved",
"kanban_toast_save_failed": "Failed to save changes",
"kanban_toast_card_created": "Card created",
"kanban_toast_create_failed": "Failed to create card",
"tasks_no_columns": "No task columns yet",
"tasks_add_column": "Add column",
"tasks_add_column_title": "Add Column",
"tasks_column_name": "Column name",
"tasks_column_placeholder": "e.g. In Review",
"tasks_add_task_title": "Add Task",
"tasks_task_title_label": "Task title",
"tasks_task_placeholder": "What needs to be done?",
"tasks_add_task_btn": "Add Task",
"chat_confirm_delete_message": "Delete this message?",
"chat_toast_message_deleted": "Message deleted",
"btn_send": "Send",
"btn_add": "Add",
"settings_confirm_disconnect_cal": "Disconnect Google Calendar?",
"kanban_confirm_delete_tag": "Delete this tag from the organization?",
"tasks_toast_create_column_failed": "Failed to create column",
"tasks_toast_delete_column_failed": "Failed to delete column",
"tasks_toast_rename_column_failed": "Failed to rename column",
"tasks_toast_create_task_failed": "Failed to create task",
"tasks_toast_delete_task_failed": "Failed to delete task",
"toast_success_sponsor_updated": "Sponsor updated",
"toast_success_sponsor_added": "Sponsor added",
"toast_error_save_sponsor": "Failed to save sponsor",
"toast_success_sponsor_removed": "Sponsor removed",
"toast_success_contact_updated": "Contact updated",
"toast_success_contact_added": "Contact added",
"toast_error_save_contact": "Failed to save contact",
"toast_success_contact_removed": "Contact removed",
"toast_error_update_planned_budget": "Failed to update planned budget",
"toast_error_save_allocation": "Failed to save allocation",
"toast_error_delete_allocation": "Failed to delete allocation",
"toast_error_no_google_avatar": "No Google avatar found.",
"toast_error_sync_avatar": "Failed to sync avatar.",
"toast_success_sync_avatar": "Google avatar synced.",
"toast_error_select_image": "Please select an image file.",
"toast_error_image_too_large": "Image must be under 2MB.",
"toast_error_upload_avatar": "Failed to upload avatar.",
"toast_error_save_avatar": "Failed to save avatar.",
"toast_success_avatar_updated": "Avatar updated.",
"toast_error_avatar_upload": "Avatar upload failed.",
"toast_error_remove_avatar": "Failed to remove avatar.",
"toast_success_avatar_removed": "Avatar removed.",
"toast_error_save_profile": "Failed to save profile.",
"toast_success_profile_saved": "Profile saved.",
"toast_error_save_preferences": "Failed to save preferences.",
"toast_success_preferences_saved": "Preferences saved.",
"toast_error_save_settings": "Failed to save settings.",
"toast_success_settings_saved": "Settings saved.",
"toast_error_save_slug": "Failed to update slug.",
"toast_success_slug_saved": "Slug updated.",
"toast_error_save_links": "Failed to save links.",
"toast_success_links_saved": "Links saved.",
"toast_error_create_tag": "Failed to create tag.",
"toast_success_tag_created": "Tag created.",
"toast_error_delete_tag": "Failed to delete tag.",
"toast_error_update_tag": "Failed to update tag.",
"toast_error_create_role": "Failed to create role.",
"toast_error_delete_role_system": "Cannot delete system roles.",
"toast_error_create_document": "Failed to create document.",
"toast_error_create_folder": "Failed to create folder.",
"toast_error_rename_document": "Failed to rename document.",
"toast_error_delete_document": "Failed to delete document.",
"toast_error_move_document": "Failed to move document.",
"toast_error_upload_file": "Failed to upload file.",
"toast_success_document_created": "Document created.",
"toast_success_folder_created": "Folder created.",
"toast_error_import_json": "Failed to import board.",
"toast_success_import_json": "Board imported successfully.",
"toast_error_export_json": "Failed to export board.",
"toast_success_export_json": "Board exported.",
"toast_error_save_avatar_url": "Failed to save avatar URL.",
"toast_error_save_event_defaults": "Failed to save event defaults.",
"toast_success_event_defaults_saved": "Event defaults saved.",
"toast_error_save_features": "Failed to save feature settings.",
"toast_success_features_saved": "Feature settings saved.",
"toast_error_save_social": "Failed to save social links.",
"toast_success_social_saved": "Social links saved.",
"toast_error_save_document": "Failed to save document",
"toast_error_load_kanban": "Failed to load kanban board",
"toast_error_move_card": "Failed to move card",
"toast_error_no_columns_import": "No columns exist to import cards into",
"toast_error_import_json_format": "Failed to import JSON - check file format",
"toast_success_board_exported": "Board exported as JSON",
"toast_error_move_shape": "Failed to move shape",
"toast_error_create_shape": "Failed to create shape",
"toast_error_move_pin": "Failed to move pin",
"toast_error_update_pin": "Failed to update pin",
"toast_error_create_pin": "Failed to create pin",
"toast_error_delete_pin": "Failed to delete pin",
"toast_error_update_shape": "Failed to update shape",
"toast_error_delete_shape": "Failed to delete shape",
"toast_error_duplicate_shape": "Failed to duplicate shape",
"toast_error_create_layer": "Failed to create layer",
"toast_error_delete_layer": "Failed to delete layer",
"toast_error_rename_layer": "Failed to rename layer",
"toast_error_reorder_objects": "Failed to reorder objects",
"toast_error_load_image": "Failed to load image",
"toast_error_upload_image": "Failed to upload image",
"toast_error_load_image_url": "Failed to load image from URL",
"toast_error_export_map": "Failed to export map",
"toast_error_create_board_widget": "Failed to create board",
"toast_success_message_edited": "Message edited",
"toast_error_file_too_large": "File too large. Maximum size is 50MB.",
"toast_success_file_sent": "File sent!",
"toast_error_create_room": "Failed to create room",
"toast_success_room_created": "Room created",
"toast_error_create_space": "Failed to create space",
"toast_success_space_created": "Space created",
"toast_error_send_file": "Failed to send file",
"toast_error_upload_failed": "Upload failed",
"toast_error_update_room": "Failed to update room settings",
"toast_success_room_updated": "Room settings updated",
"toast_error_leave_room": "Failed to leave room",
"toast_error_enter_room_name": "Please enter a room name",
"toast_error_enter_space_name": "Please enter a space name",
"toast_error_notification_settings": "Failed to change notification settings",
"toast_success_board_created": "Kanban board created",
"login_name_label": "Display name",
"login_name_placeholder": "Your full name",
"login_name_required": "Please enter your name",
"invite_title": "Invitation",
"invite_invalid_title": "Invalid Invite",
"invite_go_home": "Go Home",
"invite_youre_invited": "You're Invited!",
"invite_join_text": "You've been invited to join",
"invite_as_role": "as",
"invite_signed_in_as": "Signed in as",
"invite_accept_btn": "Accept Invite & Join",
"invite_wrong_account": "Wrong account?",
"invite_sign_out": "Sign out",
"invite_not_logged_in": "Sign in or create an account to accept this invite.",
"invite_sign_in": "Sign In",
"invite_create_account": "Create Account",
"invite_email_mismatch": "This invite was sent to {email}. Please sign in with that email address.",
"invite_already_member": "You're already a member of this organization.",
"invite_join_failed": "Failed to join organization. Please try again.",
"invite_generic_error": "Something went wrong. Please try again.",
"onboarding_title": "Complete Your Profile",
"onboarding_subtitle": "Help your team know who you are",
"onboarding_phone_label": "Phone number",
"onboarding_phone_placeholder": "+372 5xx xxxx",
"onboarding_discord_label": "Discord handle",
"onboarding_discord_placeholder": "username#1234",
"onboarding_shirt_label": "Shirt size",
"onboarding_hoodie_label": "Hoodie size",
"onboarding_save": "Save & Continue",
"onboarding_skip": "Skip for now",
"invite_email_subject": "{orgName} — You're invited to join",
"invite_email_sent": "Invite email sent to {email}",
"toast_error_send_invite_email": "Failed to send invite email",
"settings_transfer_ownership": "Transfer ownership",
"settings_transfer_confirm": "Transfer ownership to {name}? You will be demoted to admin. This action is immediate.",
"toast_error_transfer_ownership": "Failed to transfer ownership",
"toast_success_transfer_ownership": "Ownership transferred to {name}",
"home_nav_organizations": "Organizations",
"home_title": "Your Organizations",
"home_subtitle": "Select an Organization to get started.",
"home_empty_title": "No organizations yet",
"home_empty_desc": "Create your first organization to start collaborating",
"home_pending_invitations": "Pending Invitations",
"home_invite_click_accept": "Click to accept",
"home_invite_joining": "Joining...",
"home_create_org_title": "Create Organization",
"home_create_org_name_label": "Organization Name",
"home_create_org_name_placeholder": "e.g. Acme Inc",
"home_create_org_url_preview": "URL: /{slug}",
"account_settings_title": "Your Settings",
"account_settings_subtitle": "Manage your settings here.",
"user_settings_title": "User Settings",
"user_settings_subtitle": "Manage your personal account settings."
}

View File

@@ -1,14 +1,16 @@
{
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"app_name": "Root",
"nav_home": "Avaleht",
"nav_files": "Failid",
"nav_storage": "Hoidla",
"nav_calendar": "Kalender",
"nav_settings": "Seaded",
"nav_kanban": "Kanban",
"user_menu_account_settings": "Konto seaded",
"user_menu_switch_org": "Vaheta organisatsiooni",
"user_menu_logout": "Logi välja",
"btn_new": "+ Uus",
"btn_new": "Uus",
"btn_create": "Loo",
"btn_cancel": "Tühista",
"btn_save": "Salvesta",
@@ -17,7 +19,7 @@
"btn_close": "Sulge",
"btn_upload": "Laadi üles",
"btn_remove": "Eemalda",
"login_title": "Tere tulemast Rooti",
"login_title": "Tere tulemast rooti",
"login_subtitle": "Sinu meeskonna tööruum dokumentide, tahvlite ja kalendrite jaoks.",
"login_tab_login": "Logi sisse",
"login_tab_signup": "Registreeru",
@@ -42,6 +44,7 @@
"org_selector_slug_placeholder": "minu-meeskond",
"org_overview": "Organisatsiooni ülevaade",
"files_title": "Failid",
"files_subtitle": "Organisatsiooni failid on siin.",
"files_breadcrumb_home": "Avaleht",
"files_create_title": "Loo uus",
"files_type_document": "Dokument",
@@ -56,7 +59,7 @@
"files_context_move": "Teisalda...",
"files_context_delete": "Kustuta",
"files_context_open_tab": "Ava uuel vahelehel",
"files_empty": "Faile pole veel. Kliki + Uus, et luua.",
"files_empty": "Faile pole veel. Kliki Uus, et luua.",
"files_toggle_view": "Vaheta vaadet",
"kanban_title": "Kanban",
"kanban_create_board": "Loo tahvel",
@@ -81,6 +84,7 @@
"kanban_go_to_files": "Mine Failidesse",
"kanban_select_board": "Vali tahvel ülalt",
"calendar_title": "Kalender",
"calendar_subtitle": "Jälgi organisatsiooni sündmusi siin.",
"calendar_subscribe": "Telli kalender",
"calendar_refresh": "Värskenda sündmusi",
"calendar_settings": "Kalendri seaded",
@@ -91,12 +95,56 @@
"calendar_event_date": "Kuupäev",
"calendar_event_time": "Kellaaeg",
"calendar_event_desc": "Kirjeldus",
"calendar_event_desc_placeholder": "Lisa kirjeldus...",
"calendar_event_all_day": "Kogu päeva sündmus",
"calendar_event_all_day_label": "Kogu päev",
"calendar_event_start": "Algus",
"calendar_event_end": "Lõpp",
"calendar_event_color": "Värv",
"calendar_event_no_title": "(Pealkirjata)",
"calendar_event_fallback_title": "Sündmus",
"calendar_event_google": "Google Calendar'i sündmus",
"calendar_event_synced": "Sünkroniseeritud Google Calendar'iga",
"calendar_event_local": "Kohalik sündmus",
"calendar_open_google": "Ava Google Calendar'is",
"calendar_sync_google": "Sünkroniseeri Google Calendar'iga",
"calendar_delete_event": "Kustuta sündmus",
"calendar_edit_event_btn": "Muuda sündmust",
"calendar_today": "Täna",
"calendar_previous": "Eelmine",
"calendar_next": "Järgmine",
"calendar_view_day": "Päev",
"calendar_view_week": "Nädal",
"calendar_view_month": "Kuu",
"calendar_day_mon": "E",
"calendar_day_tue": "T",
"calendar_day_wed": "K",
"calendar_day_thu": "N",
"calendar_day_fri": "R",
"calendar_day_sat": "L",
"calendar_day_sun": "P",
"calendar_no_events": "Sellel päeval sündmusi pole",
"settings_title": "Seaded",
"settings_tab_general": "Üldine",
"settings_tab_members": "Liikmed",
"settings_tab_roles": "Rollid",
"settings_tab_tags": "Sildid",
"settings_tab_integrations": "Integratsioonid",
"settings_tab_activity": "Tegevuslogi",
"settings_activity_title": "Tegevuslogi",
"settings_activity_desc": "Täielik ajalugu kõigist selles organisatsioonis tehtud toimingutest.",
"settings_activity_empty": "Tegevusi pole veel salvestatud.",
"settings_activity_load_more": "Laadi rohkem",
"settings_activity_loading": "Laadimine...",
"settings_activity_end": "Olete jõudnud tegevuslogi lõppu.",
"settings_activity_filter_all": "Kõik toimingud",
"settings_activity_filter_create": "Loodud",
"settings_activity_filter_update": "Uuendatud",
"settings_activity_filter_delete": "Kustutatud",
"settings_activity_filter_move": "Liigutatud",
"settings_activity_filter_rename": "Ümbernimetatud",
"settings_activity_count": "{count} kirjet",
"settings_activity_search_placeholder": "Otsi tegevusi...",
"settings_general_title": "Organisatsiooni andmed",
"settings_general_avatar": "Avatar",
"settings_general_name": "Nimi",
@@ -110,6 +158,29 @@
"settings_general_leave_org": "Lahku organisatsioonist",
"settings_general_leave_org_desc": "Lahku sellest organisatsioonist. Tagasi liitumiseks on vaja uut kutset.",
"settings_general_leave_btn": "Lahku {orgName}",
"settings_social_title": "Sotsiaalmeedia ja lingid",
"settings_social_desc": "Lisa oma organisatsiooni veebileht ja sotsiaalmeedia lingid.",
"settings_social_website": "Veebileht",
"settings_social_website_placeholder": "https://sinuorg.ee",
"settings_social_instagram": "Instagram",
"settings_social_instagram_placeholder": "https://instagram.com/sinuorg",
"settings_social_facebook": "Facebook",
"settings_social_facebook_placeholder": "https://facebook.com/sinuorg",
"settings_social_discord": "Discord",
"settings_social_discord_placeholder": "https://discord.gg/sinuorg",
"settings_social_linkedin": "LinkedIn",
"settings_social_linkedin_placeholder": "https://linkedin.com/company/sinuorg",
"settings_social_x": "X (Twitter)",
"settings_social_x_placeholder": "https://x.com/sinuorg",
"settings_social_youtube": "YouTube",
"settings_social_youtube_placeholder": "https://youtube.com/@sinuorg",
"settings_social_tiktok": "TikTok",
"settings_social_tiktok_placeholder": "https://tiktok.com/@sinuorg",
"settings_social_fienta": "Fienta",
"settings_social_fienta_placeholder": "https://fienta.com/sinuorg",
"settings_social_twitch": "Twitch",
"settings_social_twitch_placeholder": "https://twitch.tv/sinuorg",
"settings_social_save": "Salvesta lingid",
"settings_tags_title": "Organisatsiooni sildid",
"settings_tags_desc": "Halda silte, mida saab kasutada kõigil Kanban tahvlitel.",
"settings_tags_create": "Loo silt",
@@ -118,7 +189,7 @@
"settings_members_title": "Meeskonna liikmed ({count})",
"settings_members_invite": "Kutsu liige",
"settings_members_pending": "Ootel kutsed",
"settings_members_invited_as": "Kutsutud kui {role} Aegub {date}",
"settings_members_invited_as": "Kutsutud kui {role} • Aegub {date}",
"settings_members_copy_link": "Kopeeri link",
"settings_members_unknown": "Tundmatu kasutaja",
"settings_members_no_name": "Nimi puudub",
@@ -349,28 +420,28 @@
"team_select_member": "Vali liige",
"team_select_role": "Vali roll",
"team_already_assigned": "Juba meeskonnas",
"team_departments": "Osakonnad",
"team_departments": "Valdkonnad",
"team_roles": "Rollid",
"team_all": "Kõik",
"team_no_department": "Määramata",
"team_add_department": "Lisa osakond",
"team_add_department": "Lisa valdkond",
"team_add_role": "Lisa roll",
"team_edit_department": "Muuda osakonda",
"team_edit_department": "Muuda valdkonda",
"team_edit_role": "Muuda rolli",
"team_dept_name": "Osakonna nimi",
"team_dept_name": "Valdkonna nimi",
"team_role_name": "Rolli nimi",
"team_dept_created": "Osakond loodud",
"team_dept_updated": "Osakond uuendatud",
"team_dept_deleted": "Osakond kustutatud",
"team_dept_created": "Valdkond loodud",
"team_dept_updated": "Valdkond uuendatud",
"team_dept_deleted": "Valdkond kustutatud",
"team_role_created": "Roll loodud",
"team_role_updated": "Roll uuendatud",
"team_role_deleted": "Roll kustutatud",
"team_dept_delete_confirm": "Kustuta osakond {name}? Liikmed eemaldatakse sellest.",
"team_dept_delete_confirm": "Kustuta valdkond {name}? Liikmed eemaldatakse sellest.",
"team_role_delete_confirm": "Kustuta roll {name}? Liikmed kaotavad selle rolli.",
"team_view_by_dept": "Osakondade järgi",
"team_view_by_dept": "Valdkondade järgi",
"team_view_list": "Nimekirja vaade",
"team_member_count": "{count} liiget",
"team_assign_dept": "Määra osakonnad",
"team_assign_dept": "Määra valdkonnad",
"team_notes": "Märkmed",
"team_notes_placeholder": "Valikulised märkmed selle liikme kohta...",
"overview_subtitle": "Tere tagasi. Siin on ülevaade toimuvast.",
@@ -380,12 +451,609 @@
"overview_view_all_events": "Vaata kõiki üritusi",
"overview_more_members": "+{count} veel",
"chat_join_title": "Liitu vestlusega",
"chat_join_description": "Vestlus põhineb Matrixil avatud standardil turvalise ja detsentraliseeritud suhtluse jaoks.",
"chat_join_description": "Vestlus põhineb Matrixil - avatud standardil turvalise ja detsentraliseeritud suhtluse jaoks.",
"chat_join_consent": "Liitudes luuakse sulle Matrixi konto sinu praeguste profiiliandmete (nimi, e-post ja avatar) põhjal.",
"chat_join_learn_more": "Loe Matrixi kohta lähemalt",
"chat_join_button": "Liitu vestlusega",
"chat_joining": "Konto seadistamine...",
"chat_join_success": "Vestluskonto loodud! Tere tulemast.",
"chat_join_error": "Vestluse seadistamine ebaõnnestus. Proovi uuesti.",
"chat_disconnect": "Katkesta vestlusühendus"
"chat_disconnect": "Katkesta vestlusühendus",
"dept_dashboard_no_modules": "Mooduleid pole veel seadistatud",
"dept_dashboard_add_first": "Lisa oma esimene moodul",
"dept_dashboard_add_module": "Lisa moodul",
"dept_dashboard_all_added": "Kõik moodulid on juba lisatud",
"dept_dashboard_expand": "Laienda",
"dept_dashboard_remove_module": "Eemalda moodul",
"dept_dashboard_coming_soon": "Tulekul",
"dept_dashboard_module_coming_soon": "Moodul tulekul",
"dept_dashboard_departments": "Valdkonnad",
"dept_checklist_no_items": "Kontrollnimekirju pole veel",
"dept_checklist_add": "Lisa kontrollnimekiri",
"dept_checklist_add_item": "Lisa üksus...",
"dept_notes_no_notes": "Märkmeid pole veel",
"dept_notes_new": "Uus märge",
"dept_notes_select": "Vali märge",
"dept_notes_placeholder": "Alusta kirjutamist...",
"dept_notes_title_placeholder": "Märkme pealkiri...",
"dept_kanban_open": "Ava ülesannete tahvel",
"dept_kanban_desc": "Selle valdkonna ülesannete tahvel",
"dept_files_open": "Ava failid",
"dept_files_desc": "Valdkonna failid ja dokumendid",
"dept_quick_add": "Kiirvalik",
"dept_modules_label": "Moodulid",
"dept_dashboard_collapse": "Ahenda",
"dept_dashboard_move_left": "Liiguta vasakule",
"dept_dashboard_move_right": "Liiguta paremale",
"dept_dashboard_layout_1col": "1 veerg",
"dept_dashboard_layout_2col": "2 veergu",
"dept_dashboard_layout_3col": "3 veergu",
"dept_dashboard_layout_grid": "2Ć—2 ruudustik",
"dept_module_kanban": "Kanban",
"dept_module_files": "Failid",
"dept_module_checklist": "Kontrollnimekiri",
"dept_module_notes": "Märkmed",
"dept_module_schedule": "Ajakava",
"dept_module_contacts": "Kontaktid",
"dept_module_budget": "Eelarve",
"dept_module_sponsors": "Sponsorid",
"dept_module_map": "Kaart",
"toast_error_update_layout": "Paigutuse uuendamine ebaõnnestus",
"toast_error_add_module": "Mooduli lisamine ebaõnnestus",
"toast_error_remove_module": "Mooduli eemaldamine ebaõnnestus",
"toast_error_reorder_modules": "Moodulite ümberjärjestamine ebaõnnestus",
"toast_error_add_item": "Üksuse lisamine ebaõnnestus",
"toast_error_update_item": "Üksuse uuendamine ebaõnnestus",
"toast_error_delete_item": "Üksuse kustutamine ebaõnnestus",
"toast_error_create_checklist": "Kontrollnimekirja loomine ebaõnnestus",
"toast_error_delete_checklist": "Kontrollnimekirja kustutamine ebaõnnestus",
"toast_error_rename_checklist": "Kontrollnimekirja ümbernimetamine ebaõnnestus",
"toast_error_create_note": "Märkme loomine ebaõnnestus",
"toast_error_update_note": "Märkme uuendamine ebaõnnestus",
"toast_error_delete_note": "Märkme kustutamine ebaõnnestus",
"toast_error_create_stage": "Lava loomine ebaõnnestus",
"toast_error_delete_stage": "Lava kustutamine ebaõnnestus",
"toast_error_create_block": "Ajakavaploki loomine ebaõnnestus",
"toast_error_update_block": "Ajakavaploki uuendamine ebaõnnestus",
"toast_error_delete_block": "Ajakavaploki kustutamine ebaõnnestus",
"toast_error_create_contact": "Kontakti loomine ebaõnnestus",
"toast_error_update_contact": "Kontakti uuendamine ebaõnnestus",
"toast_error_delete_contact": "Kontakti kustutamine ebaõnnestus",
"toast_error_create_category": "Kategooria loomine ebaõnnestus",
"toast_error_delete_category": "Kategooria kustutamine ebaõnnestus",
"toast_error_create_budget_item": "Eelarveüksuse loomine ebaõnnestus",
"toast_error_update_budget_item": "Eelarveüksuse uuendamine ebaõnnestus",
"toast_error_delete_budget_item": "Eelarveüksuse kustutamine ebaõnnestus",
"toast_error_upload_receipt": "Kviitungi üleslaadimine ebaõnnestus",
"toast_success_receipt_attached": "Kviitung \"{name}\" lisatud",
"toast_error_create_tier": "Taseme loomine ebaõnnestus",
"toast_error_delete_tier": "Taseme kustutamine ebaõnnestus",
"toast_error_create_sponsor": "Sponsori loomine ebaõnnestus",
"toast_error_update_sponsor": "Sponsori uuendamine ebaõnnestus",
"toast_error_delete_sponsor": "Sponsori kustutamine ebaõnnestus",
"toast_error_create_deliverable": "Kohustuse loomine ebaõnnestus",
"toast_error_update_deliverable": "Kohustuse uuendamine ebaõnnestus",
"toast_error_delete_deliverable": "Kohustuse kustutamine ebaõnnestus",
"checklist_rename": "Nimeta ümber",
"checklist_delete": "Kustuta nimekiri",
"checklist_add_item_placeholder": "Lisa üksus...",
"checklist_name_placeholder": "Nimekirja nimi...",
"checklist_no_items": "Kontrollnimekirju pole veel",
"checklist_add": "Lisa kontrollnimekiri",
"notes_new": "Uus märge",
"notes_title_placeholder": "Märkme pealkiri...",
"notes_placeholder": "Alusta kirjutamist...",
"notes_no_notes": "Märkmeid pole veel",
"notes_select": "Vali märge",
"notes_export_document": "Ekspordi dokumendina",
"notes_delete": "Kustuta märge",
"notes_exported": "Eksporditud \"{title}\" dokumendina",
"notes_export_error": "Märkme eksportimine ebaõnnestus",
"schedule_timeline": "Ajajoon",
"schedule_list": "Nimekiri",
"schedule_add_block": "Lisa plokk",
"schedule_manage_stages": "Halda lavasid",
"schedule_no_blocks": "Ajakavaplokke pole veel",
"schedule_add_first": "Lisa oma esimene plokk ajakava koostamise alustamiseks.",
"schedule_all_day": "Terve päev",
"schedule_no_stage": "Lava puudub",
"schedule_add_stage_title": "Lisa lava / ruum",
"schedule_stage_name_placeholder": "nt Peamine lava",
"schedule_add_stage": "Lisa lava",
"schedule_existing_stages": "Olemasolevad lavad",
"schedule_no_stages": "Lavasid pole veel",
"schedule_add_block_title": "Lisa ajakavaplokk",
"schedule_edit_block_title": "Muuda ajakavaplokki",
"schedule_block_title_label": "Pealkiri",
"schedule_block_title_placeholder": "nt Avamine",
"schedule_block_speaker_label": "Esineja / Juht",
"schedule_block_speaker_placeholder": "nt Jaan Tamm",
"schedule_block_start_label": "Algusaeg",
"schedule_block_end_label": "Lõpuaeg",
"schedule_block_stage_label": "Lava / Ruum",
"schedule_block_no_stage": "Lava puudub",
"schedule_block_description_label": "Kirjeldus",
"schedule_block_description_placeholder": "Valikuline kirjeldus...",
"schedule_block_color_label": "Värv",
"schedule_block_delete": "Kustuta plokk",
"contacts_search_placeholder": "Otsi kontakte...",
"contacts_add": "Lisa kontakt",
"contacts_no_contacts": "Kontakte pole veel",
"contacts_add_first": "Lisa oma esimene kontakt kataloogi loomiseks.",
"contacts_no_results": "Otsingutulemusi ei leitud",
"contacts_category_all": "Kõik",
"contacts_category_venue": "Toimumiskoht",
"contacts_category_catering": "Toitlustus",
"contacts_category_av": "AV / Tehnika",
"contacts_category_security": "Turvalisus",
"contacts_category_transport": "Transport",
"contacts_category_entertainment": "Meelelahutus",
"contacts_category_media": "Meedia",
"contacts_category_sponsor": "Sponsor",
"contacts_category_other": "Muu",
"contacts_add_title": "Lisa kontakt",
"contacts_edit_title": "Muuda kontakti",
"contacts_name_label": "Nimi",
"contacts_name_placeholder": "Kontakti nimi",
"contacts_role_label": "Roll / Ametinimetus",
"contacts_role_placeholder": "nt Ürituse juht",
"contacts_company_label": "Ettevõte",
"contacts_company_placeholder": "Ettevõtte nimi",
"contacts_email_label": "E-post",
"contacts_email_placeholder": "email@näide.ee",
"contacts_phone_label": "Telefon",
"contacts_phone_placeholder": "+372 ...",
"contacts_website_label": "Veebileht",
"contacts_website_placeholder": "https://...",
"contacts_category_label": "Kategooria",
"contacts_notes_label": "Märkmed",
"contacts_notes_placeholder": "Valikulised märkmed...",
"contacts_delete_confirm": "Kustuta see kontakt?",
"budget_income": "Tulud",
"budget_expenses": "Kulud",
"budget_planned": "Planeeritud: {amount}",
"budget_planned_balance": "Planeeritud saldo",
"budget_actual_balance": "Tegelik saldo",
"budget_view_all": "Kõik",
"budget_view_income": "Tulud",
"budget_view_expenses": "Kulud",
"budget_add_category": "Kategooria",
"budget_add_item": "Lisa üksus",
"budget_no_items": "Eelarveüksusi pole veel",
"budget_col_type": "Tüüp",
"budget_col_description": "Kirjeldus",
"budget_col_category": "Kategooria",
"budget_col_planned": "Planeeritud",
"budget_col_actual": "Tegelik",
"budget_col_diff": "Vahe",
"budget_col_receipt": "Kviitung",
"budget_uncategorized": "Kategoriseerimata",
"budget_total": "Kokku",
"budget_missing_receipt": "Arve/kviitung puudub - kliki üleslaadimiseks",
"budget_missing_receipt_short": "Arve/kviitung puudub",
"budget_receipt_attached": "Kviitung lisatud",
"budget_add_item_title": "Lisa eelarveüksus",
"budget_edit_item_title": "Muuda eelarveüksust",
"budget_description_label": "Kirjeldus",
"budget_description_placeholder": "nt Ruumi rent",
"budget_type_label": "Tüüp",
"budget_type_expense": "Kulu",
"budget_type_income": "Tulu",
"budget_category_label": "Kategooria",
"budget_planned_amount_label": "Planeeritud summa",
"budget_actual_amount_label": "Tegelik summa",
"budget_notes_label": "Märkmed",
"budget_notes_placeholder": "Valikulised märkmed...",
"budget_add_category_title": "Lisa kategooria",
"budget_category_name_label": "Nimi",
"budget_category_name_placeholder": "nt Toimumiskoht",
"budget_category_color_label": "Värv",
"budget_select_color": "Vali värv {color}",
"budget_existing_categories": "Olemasolevad kategooriad",
"sponsors_search_placeholder": "Otsi sponsoreid...",
"sponsors_add_tier": "Lisa tase",
"sponsors_add_sponsor": "Lisa sponsor",
"sponsors_no_sponsors": "Sponsoreid pole veel",
"sponsors_add_first": "Lisa sponsoritasemed ja alusta sponsorite jälgimist.",
"sponsors_no_results": "Filtritele vastavaid sponsoreid ei leitud",
"sponsors_filter_all_statuses": "Kõik staatused",
"sponsors_filter_all_tiers": "Kõik tasemed",
"sponsors_status_prospect": "Potentsiaalne",
"sponsors_status_contacted": "Kontakteeritud",
"sponsors_status_confirmed": "Kinnitatud",
"sponsors_status_declined": "Keeldunud",
"sponsors_status_active": "Aktiivne",
"sponsors_contact_label": "Kontakt",
"sponsors_amount_label": "Summa",
"sponsors_deliverables_label": "Kohustused",
"sponsors_deliverable_placeholder": "Lisa kohustus...",
"sponsors_no_deliverables": "Kohustusi pole veel",
"sponsors_notes_label": "Märkmed",
"sponsors_add_tier_title": "Lisa sponsoritase",
"sponsors_tier_name_label": "Taseme nimi",
"sponsors_tier_name_placeholder": "nt Kuld",
"sponsors_tier_amount_label": "Minimaalne summa",
"sponsors_tier_color_label": "Värv",
"sponsors_existing_tiers": "Olemasolevad tasemed",
"sponsors_no_tiers": "Tasemeid pole veel",
"sponsors_add_sponsor_title": "Lisa sponsor",
"sponsors_edit_sponsor_title": "Muuda sponsorit",
"sponsors_name_label": "Sponsori nimi",
"sponsors_name_placeholder": "Ettevõtte nimi",
"sponsors_tier_label": "Tase",
"sponsors_no_tier": "Tase puudub",
"sponsors_status_label": "Staatus",
"sponsors_sponsor_amount_label": "Summa (€)",
"sponsors_sponsor_amount_placeholder": "0",
"sponsors_contact_name_label": "Kontaktisiku nimi",
"sponsors_contact_name_placeholder": "Kontaktisik",
"sponsors_contact_email_label": "Kontakti e-post",
"sponsors_contact_email_placeholder": "email@näide.ee",
"sponsors_contact_phone_label": "Kontakti telefon",
"sponsors_contact_phone_placeholder": "+372 ...",
"sponsors_website_label": "Veebileht",
"sponsors_website_placeholder": "https://...",
"sponsors_notes_placeholder": "Valikulised märkmed...",
"sponsors_delete_confirm": "Kustuta see sponsor?",
"sponsors_select_color": "Vali värv {color}",
"files_widget_loading": "Failide laadimine...",
"files_widget_no_folder": "Valdkonna kausta pole veel loodud",
"files_widget_empty": "Faile pole veel",
"files_widget_full_view": "Täisvaade",
"files_widget_create_title": "Loo uus",
"files_widget_type_document": "Dokument",
"files_widget_type_folder": "Kaust",
"files_widget_type_kanban": "Kanban tahvel",
"files_widget_name_label": "Nimi",
"files_widget_doc_placeholder": "Dokumendi nimi",
"files_widget_folder_placeholder": "Kausta nimi",
"files_widget_kanban_placeholder": "Tahvli nimi",
"files_widget_drop_files": "Lohista failid üleslaadimiseks",
"files_widget_uploading": "Üleslaadimine {name}...",
"kanban_widget_loading": "Tahvlite laadimine...",
"kanban_widget_no_folder": "Valdkonna kausta pole veel loodud",
"kanban_widget_no_boards": "Kanban tahvleid pole veel",
"kanban_widget_create": "Loo tahvel",
"kanban_widget_create_title": "Loo Kanban tahvel",
"kanban_widget_name_label": "Tahvli nimi",
"kanban_widget_name_placeholder": "nt Ülesannete jälgija",
"finances_title": "Ürituse rahandus",
"finances_subtitle": "Eelarve ülevaade kõigi valdkondade lõikes",
"finances_total_income": "Tulud kokku",
"finances_total_expenses": "Kulud kokku",
"finances_net_balance": "Netosaldo",
"finances_missing_receipts": "Puuduvad kviitungid",
"finances_items_without_receipts": "{count} üksust ilma kviitungita",
"finances_planned": "planeeritud",
"finances_view_by_dept": "Valdkondade järgi",
"finances_view_by_category": "Kategooriate järgi",
"finances_filter_all": "Kõik",
"finances_filter_income": "Tulud",
"finances_filter_expenses": "Kulud",
"finances_no_items": "Eelarveüksusi pole veel",
"finances_no_items_desc": "Eelarveüksused ilmuvad siia, kui valdkonnad neid lisavad.",
"finances_col_description": "Kirjeldus",
"finances_col_department": "Valdkond",
"finances_col_category": "Kategooria",
"finances_col_planned": "Planeeritud",
"finances_col_actual": "Tegelik",
"finances_col_diff": "Vahe",
"finances_col_receipt": "Kviitung",
"finances_uncategorized": "Kategoriseerimata",
"finances_no_department": "Valdkond puudub",
"finances_group_total": "Vahesumma",
"btn_creating": "Loomine...",
"page_chat": "Vestlus",
"page_department": "Valdkond",
"page_invite": "Kutse",
"map_tool_grab": "Liiguta",
"map_tool_select": "Vali",
"map_tool_pin": "Marker",
"map_tool_polygon": "Hulknurk",
"map_tool_rectangle": "Ristkülik",
"map_add_layer": "Lisa kaardikiht",
"map_add_image_layer": "Lisa pildikaart",
"map_layer_name": "Kihi nimi",
"map_street_map": "Tänavakaart",
"map_custom_image": "Kohandatud pilt",
"map_osm_tiles": "OpenStreetMap kaardikihid",
"map_upload_venue": "Laadi üles koha kaart",
"map_choose_image": "Vali pildifail",
"map_uploading": "Üleslaadimine...",
"map_image_url": "Pildi URL",
"map_image_desc": "Laadi üles koha plaan või kaardipilt. Kuvasuhe säilitatakse.",
"map_objects": "Objektid",
"map_no_objects": "Objekte pole veel",
"map_export_png": "Ekspordi PNG",
"map_rename_layer": "Nimeta kiht ümber",
"map_edit_pin": "Muuda markerit",
"map_add_pin": "Lisa marker",
"map_edit_shape": "Muuda kujundit",
"map_label": "Silt",
"map_description": "Kirjeldus",
"map_color": "Värv",
"map_pen_hint_points": "{count} punkt(i)",
"map_pen_hint_close": "kliki esimese punkti lähedal sulgemiseks või vajuta Esc tühistamiseks",
"map_show_pin_labels": "Näita markerite silte",
"map_show_shape_labels": "Näita kujundite silte",
"map_address": "Aadress",
"map_address_placeholder": "nt. Tallinn, Eesti",
"map_change_address": "Muuda aadressi",
"map_rename": "Nimeta ümber",
"map_duplicate": "Dubleeri",
"map_or": "või",
"map_load": "Laadi",
"map_go": "Mine",
"event_nav_finances": "Rahandus",
"event_nav_sponsors": "Sponsorid",
"event_nav_contacts": "Kontaktid",
"event_nav_files": "Failid",
"event_nav_departments": "Valdkonnad",
"module_kanban_label": "Kanban",
"module_kanban_desc": "Ülesannete tahvlid veergude ja kaartidega töö jälgimiseks",
"module_files_label": "Failid",
"module_files_desc": "Jagatud dokumendid, kaustad ja failihoidla",
"module_checklist_label": "Kontrollnimekiri",
"module_checklist_desc": "Lihtsad ülesannete nimekirjad edenemise jälgimisega",
"module_notes_label": "Märkmed",
"module_notes_desc": "Vabas vormis märkmed ja dokumentatsioon",
"module_schedule_label": "Ajakava",
"module_schedule_desc": "Ajajoon ja ajakava lavade ja sessioonide jaoks",
"module_contacts_label": "Kontaktid",
"module_contacts_desc": "Tarnijate ja kontaktide kataloog kategooriatega",
"module_budget_label": "Eelarve",
"module_budget_desc": "Tulude ja kulude jälgimine planeeritud vs tegelik",
"module_sponsors_label": "Sponsorid",
"module_sponsors_desc": "Sponsorite haldus tasemete, kohustuste ja müügitoruga",
"module_map_label": "Kaart",
"module_map_desc": "Interaktiivne koha kaart markerite ja aladega",
"contact_cat_general": "Üldine",
"contact_cat_vendor": "Tarnija",
"contact_cat_sponsor": "Sponsor",
"contact_cat_speaker": "Esineja",
"contact_cat_venue": "Toimumiskoht",
"contact_cat_catering": "Toitlustus",
"contact_cat_av_tech": "AV / Tehnika",
"contact_cat_transport": "Transport",
"contact_cat_security": "Turvalisus",
"contact_cat_media": "Meedia",
"contacts_all_categories": "Kõik kategooriad",
"sponsor_status_prospect": "Potentsiaalne",
"sponsor_status_contacted": "Kontakteeritud",
"sponsor_status_confirmed": "Kinnitatud",
"sponsor_status_declined": "Keeldunud",
"sponsor_status_active": "Aktiivne",
"sponsors_all_statuses": "Kõik staatused",
"sponsors_all_tiers": "Kõik tasemed",
"sponsors_no_tier_filter": "Tase puudub",
"accent_blue": "Sinine (vaikimisi)",
"accent_green": "Roheline",
"accent_red": "Punane",
"accent_amber": "Merevaik",
"accent_purple": "Lilla",
"accent_pink": "Roosa",
"accent_indigo": "Indigo",
"accent_teal": "Sinakasroheline",
"role_color_red": "Punane",
"role_color_amber": "Merevaik",
"role_color_emerald": "Smaragd",
"role_color_blue": "Sinine",
"role_color_indigo": "Indigo",
"role_color_violet": "Violetne",
"role_color_pink": "Roosa",
"role_color_gray": "Hall",
"kanban_import_json": "Impordi JSON",
"kanban_export_json": "Ekspordi JSON",
"kanban_rename_from_list": "Nimeta ümber dokumentide nimekirjast.",
"admin_tab_overview": "Ülevaade",
"admin_tab_organizations": "Organisatsioonid",
"admin_tab_users": "Kasutajad",
"admin_tab_events": "Üritused",
"card_add_title": "Lisa kaart",
"card_details_title": "Kaardi üksikasjad",
"card_title_label": "Pealkiri",
"card_title_placeholder": "Kaardi pealkiri",
"card_description_label": "Kirjeldus",
"card_description_placeholder": "Lisa üksikasjalikum kirjeldus...",
"card_tags": "Sildid",
"card_tags_done": "Valmis",
"card_tags_manage": "Halda",
"card_tags_new_placeholder": "Uue sildi nimi...",
"card_tags_new_short": "Uus silt...",
"card_tags_add": "+ Lisa silt",
"card_tags_all_added": "Kõik sildid lisatud",
"card_assignee": "Vastutaja",
"card_due_date": "Tähtaeg",
"card_priority": "Prioriteet",
"card_priority_low": "Madal",
"card_priority_medium": "Keskmine",
"card_priority_high": "Kõrge",
"card_priority_urgent": "Kiire",
"card_checklist": "Kontrollnimekiri",
"card_checklist_add_placeholder": "Lisa element...",
"card_checklist_add_item_placeholder": "Lisa kontrollnimekirja element...",
"card_comments": "Kommentaarid",
"card_comments_none": "Kommentaare pole veel",
"card_comments_add_placeholder": "Lisa kommentaar...",
"card_comments_unknown": "Tundmatu",
"card_delete": "Kustuta kaart",
"card_save_changes": "Salvesta muudatused",
"card_loading": "Laadimine...",
"kanban_add_col": "Lisa veerg",
"kanban_confirm_delete_column": "Kustuta see veerg ja kõik selle kaardid?",
"kanban_confirm_delete_card": "Kustuta see kaart?",
"kanban_toast_changes_saved": "Muudatused salvestatud",
"kanban_toast_save_failed": "Muudatuste salvestamine ebaõnnestus",
"kanban_toast_card_created": "Kaart loodud",
"kanban_toast_create_failed": "Kaardi loomine ebaõnnestus",
"tasks_no_columns": "Ülesannete veerge pole veel",
"tasks_add_column": "Lisa veerg",
"tasks_add_column_title": "Lisa veerg",
"tasks_column_name": "Veeru nimi",
"tasks_column_placeholder": "nt Ülevaatamisel",
"tasks_add_task_title": "Lisa ülesanne",
"tasks_task_title_label": "Ülesande pealkiri",
"tasks_task_placeholder": "Mida on vaja teha?",
"tasks_add_task_btn": "Lisa ülesanne",
"chat_confirm_delete_message": "Kustuta see sõnum?",
"chat_toast_message_deleted": "Sõnum kustutatud",
"btn_send": "Saada",
"btn_add": "Lisa",
"settings_confirm_disconnect_cal": "Katkesta Google Calendar ühendus?",
"kanban_confirm_delete_tag": "Kustuta see silt organisatsioonist?",
"tasks_toast_create_column_failed": "Veeru loomine ebaõnnestus",
"tasks_toast_delete_column_failed": "Veeru kustutamine ebaõnnestus",
"tasks_toast_rename_column_failed": "Veeru ümbernimetamine ebaõnnestus",
"tasks_toast_create_task_failed": "Ülesande loomine ebaõnnestus",
"tasks_toast_delete_task_failed": "Ülesande kustutamine ebaõnnestus",
"toast_success_sponsor_updated": "Sponsor uuendatud",
"toast_success_sponsor_added": "Sponsor lisatud",
"toast_error_save_sponsor": "Sponsori salvestamine ebaõnnestus",
"toast_success_sponsor_removed": "Sponsor eemaldatud",
"toast_success_contact_updated": "Kontakt uuendatud",
"toast_success_contact_added": "Kontakt lisatud",
"toast_error_save_contact": "Kontakti salvestamine ebaõnnestus",
"toast_success_contact_removed": "Kontakt eemaldatud",
"toast_error_update_planned_budget": "Planeeritud eelarve uuendamine ebaõnnestus",
"toast_error_save_allocation": "Jaotuse salvestamine ebaõnnestus",
"toast_error_delete_allocation": "Jaotuse kustutamine ebaõnnestus",
"toast_error_no_google_avatar": "Google avatari ei leitud.",
"toast_error_sync_avatar": "Avatari sünkroonimine ebaõnnestus.",
"toast_success_sync_avatar": "Google avatar sünkroonitud.",
"toast_error_select_image": "Palun vali pildifail.",
"toast_error_image_too_large": "Pilt peab olema alla 2MB.",
"toast_error_upload_avatar": "Avatari üleslaadimine ebaõnnestus.",
"toast_error_save_avatar": "Avatari salvestamine ebaõnnestus.",
"toast_success_avatar_updated": "Avatar uuendatud.",
"toast_error_avatar_upload": "Avatari üleslaadimine ebaõnnestus.",
"toast_error_remove_avatar": "Avatari eemaldamine ebaõnnestus.",
"toast_success_avatar_removed": "Avatar eemaldatud.",
"toast_error_save_profile": "Profiili salvestamine ebaõnnestus.",
"toast_success_profile_saved": "Profiil salvestatud.",
"toast_error_save_preferences": "Eelistuste salvestamine ebaõnnestus.",
"toast_success_preferences_saved": "Eelistused salvestatud.",
"toast_error_save_settings": "Seadete salvestamine ebaõnnestus.",
"toast_success_settings_saved": "Seaded salvestatud.",
"toast_error_save_slug": "Lühinime uuendamine ebaõnnestus.",
"toast_success_slug_saved": "Lühinimi uuendatud.",
"toast_error_save_links": "Linkide salvestamine ebaõnnestus.",
"toast_success_links_saved": "Lingid salvestatud.",
"toast_error_create_tag": "Sildi loomine ebaõnnestus.",
"toast_success_tag_created": "Silt loodud.",
"toast_error_delete_tag": "Sildi kustutamine ebaõnnestus.",
"toast_error_update_tag": "Sildi uuendamine ebaõnnestus.",
"toast_error_create_role": "Rolli loomine ebaõnnestus.",
"toast_error_delete_role_system": "Süsteemirolle ei saa kustutada.",
"toast_error_create_document": "Dokumendi loomine ebaõnnestus.",
"toast_error_create_folder": "Kausta loomine ebaõnnestus.",
"toast_error_rename_document": "Dokumendi ümbernimetamine ebaõnnestus.",
"toast_error_delete_document": "Dokumendi kustutamine ebaõnnestus.",
"toast_error_move_document": "Dokumendi teisaldamine ebaõnnestus.",
"toast_error_upload_file": "Faili üleslaadimine ebaõnnestus.",
"toast_success_document_created": "Dokument loodud.",
"toast_success_folder_created": "Kaust loodud.",
"toast_error_import_json": "Tahvli importimine ebaõnnestus.",
"toast_success_import_json": "Tahvel edukalt imporditud.",
"toast_error_export_json": "Tahvli eksportimine ebaõnnestus.",
"toast_success_export_json": "Tahvel eksporditud.",
"toast_error_save_avatar_url": "Avatari URL-i salvestamine ebaõnnestus.",
"toast_error_save_event_defaults": "Ürituse vaikeväärtuste salvestamine ebaõnnestus.",
"toast_success_event_defaults_saved": "Ürituse vaikeväärtused salvestatud.",
"toast_error_save_features": "Funktsioonide seadete salvestamine ebaõnnestus.",
"toast_success_features_saved": "Funktsioonide seaded salvestatud.",
"toast_error_save_social": "Sotsiaalmeedia linkide salvestamine ebaõnnestus.",
"toast_success_social_saved": "Sotsiaalmeedia lingid salvestatud.",
"toast_error_save_document": "Dokumendi salvestamine ebaõnnestus",
"toast_error_load_kanban": "Kanban tahvli laadimine ebaõnnestus",
"toast_error_move_card": "Kaardi teisaldamine ebaõnnestus",
"toast_error_no_columns_import": "Veerge pole, kuhu kaarte importida",
"toast_error_import_json_format": "JSON importimine ebaõnnestus - kontrolli faili formaati",
"toast_success_board_exported": "Tahvel eksporditud JSON-ina",
"toast_error_move_shape": "Kujundi teisaldamine ebaõnnestus",
"toast_error_create_shape": "Kujundi loomine ebaõnnestus",
"toast_error_move_pin": "Nõela teisaldamine ebaõnnestus",
"toast_error_update_pin": "Nõela uuendamine ebaõnnestus",
"toast_error_create_pin": "Nõela loomine ebaõnnestus",
"toast_error_delete_pin": "Nõela kustutamine ebaõnnestus",
"toast_error_update_shape": "Kujundi uuendamine ebaõnnestus",
"toast_error_delete_shape": "Kujundi kustutamine ebaõnnestus",
"toast_error_duplicate_shape": "Kujundi dubleerimine ebaõnnestus",
"toast_error_create_layer": "Kihi loomine ebaõnnestus",
"toast_error_delete_layer": "Kihi kustutamine ebaõnnestus",
"toast_error_rename_layer": "Kihi ümbernimetamine ebaõnnestus",
"toast_error_reorder_objects": "Objektide järjestamine ebaõnnestus",
"toast_error_load_image": "Pildi laadimine ebaõnnestus",
"toast_error_upload_image": "Pildi üleslaadimine ebaõnnestus",
"toast_error_load_image_url": "Pildi laadimine URL-ilt ebaõnnestus",
"toast_error_export_map": "Kaardi eksportimine ebaõnnestus",
"toast_error_create_board_widget": "Tahvli loomine ebaõnnestus",
"toast_success_message_edited": "Sõnum muudetud",
"toast_error_file_too_large": "Fail on liiga suur. Maksimaalne suurus on 50MB.",
"toast_success_file_sent": "Fail saadetud!",
"toast_error_create_room": "Ruumi loomine ebaõnnestus",
"toast_success_room_created": "Ruum loodud",
"toast_error_create_space": "Ruumi loomine ebaõnnestus",
"toast_success_space_created": "Ruum loodud",
"toast_error_send_file": "Faili saatmine ebaõnnestus",
"toast_error_upload_failed": "Üleslaadimine ebaõnnestus",
"toast_error_update_room": "Ruumi seadete uuendamine ebaõnnestus",
"toast_success_room_updated": "Ruumi seaded uuendatud",
"toast_error_leave_room": "Ruumist lahkumine ebaõnnestus",
"toast_error_enter_room_name": "Palun sisesta ruumi nimi",
"toast_error_enter_space_name": "Palun sisesta ruumi nimi",
"toast_error_notification_settings": "Teavituste seadete muutmine ebaõnnestus",
"toast_success_board_created": "Kanban tahvel loodud",
"login_name_label": "Kuvatav nimi",
"login_name_placeholder": "Sinu täisnimi",
"login_name_required": "Palun sisesta oma nimi",
"invite_title": "Kutse",
"invite_invalid_title": "Vigane kutse",
"invite_go_home": "Avalehele",
"invite_youre_invited": "Oled kutsutud!",
"invite_join_text": "Sind on kutsutud liituma",
"invite_as_role": "kui",
"invite_signed_in_as": "Sisse logitud kui",
"invite_accept_btn": "Nõustu ja liitu",
"invite_wrong_account": "Vale konto?",
"invite_sign_out": "Logi välja",
"invite_not_logged_in": "Logi sisse või loo konto, et kutse vastu võtta.",
"invite_sign_in": "Logi sisse",
"invite_create_account": "Loo konto",
"invite_email_mismatch": "See kutse saadeti aadressile {email}. Palun logi sisse selle e-posti aadressiga.",
"invite_already_member": "Oled juba selle organisatsiooni liige.",
"invite_join_failed": "Organisatsiooniga liitumine ebaõnnestus. Palun proovi uuesti.",
"invite_generic_error": "Midagi läks valesti. Palun proovi uuesti.",
"onboarding_title": "Täida oma profiil",
"onboarding_subtitle": "Aita oma meeskonnal sind tundma õppida",
"onboarding_phone_label": "Telefoninumber",
"onboarding_phone_placeholder": "+372 5xx xxxx",
"onboarding_discord_label": "Discordi kasutajanimi",
"onboarding_discord_placeholder": "kasutajanimi#1234",
"onboarding_shirt_label": "Särgi suurus",
"onboarding_hoodie_label": "Pusa suurus",
"onboarding_save": "Salvesta ja jätka",
"onboarding_skip": "Jäta vahele",
"invite_email_subject": "{orgName} — Oled kutsutud liituma",
"invite_email_sent": "Kutse e-kiri saadetud aadressile {email}",
"toast_error_send_invite_email": "Kutse e-kirja saatmine ebaõnnestus",
"settings_transfer_ownership": "Kanna omanikõigused üle",
"settings_transfer_confirm": "Kanna omanikõigused üle kasutajale {name}? Sind alandatakse adminiks. See toiming on kohene.",
"toast_error_transfer_ownership": "Omanikõiguste ülekandmine ebaõnnestus",
"toast_success_transfer_ownership": "Omanikõigused kanti üle kasutajale {name}",
"home_nav_organizations": "Organisatsioonid",
"home_title": "Sinu organisatsioonid",
"home_subtitle": "Vali organisatsioon alustamiseks.",
"home_empty_title": "Organisatsioone pole veel",
"home_empty_desc": "Loo oma esimene organisatsioon koostöö alustamiseks",
"home_pending_invitations": "Ootel kutsed",
"home_invite_click_accept": "Kliki nõustumiseks",
"home_invite_joining": "Liitun...",
"home_create_org_title": "Loo organisatsioon",
"home_create_org_name_label": "Organisatsiooni nimi",
"home_create_org_name_placeholder": "nt. Acme OÜ",
"home_create_org_url_preview": "URL: /{slug}",
"account_settings_title": "Sinu seaded",
"account_settings_subtitle": "Halda oma seadeid siin.",
"user_settings_title": "Kasutaja seaded",
"user_settings_subtitle": "Halda oma isiklikke konto seadeid."
}

265
package-lock.json generated
View File

@@ -8,7 +8,6 @@
"name": "root-org",
"version": "0.0.1",
"dependencies": {
"@inlang/paraglide-js": "^2.10.0",
"@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.94.0",
"@tanstack/svelte-virtual": "^3.13.18",
@@ -16,8 +15,10 @@
"@tiptap/extension-placeholder": "^3.19.0",
"@tiptap/pm": "^3.19.0",
"@tiptap/starter-kit": "^3.19.0",
"dom-to-image-more": "^3.7.2",
"google-auth-library": "^10.5.0",
"highlight.js": "^11.11.1",
"leaflet": "^1.9.4",
"marked": "^17.0.1",
"matrix-js-sdk": "^40.2.0-rc.0",
"twemoji": "^14.0.2"
@@ -31,9 +32,11 @@
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18",
"@types/leaflet": "^1.9.21",
"@types/marked": "^5.0.2",
"@types/twemoji": "^13.1.1",
"@vitest/browser-playwright": "^4.0.18",
"@vitest/coverage-v8": "^4.0.18",
"playwright": "^1.58.0",
"supabase": "^2.76.1",
"svelte": "^5.48.2",
@@ -45,6 +48,42 @@
"vitest-browser-svelte": "^2.0.2"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.0"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
@@ -54,6 +93,30 @@
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
@@ -2154,6 +2217,23 @@
"integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==",
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
@@ -2230,6 +2310,7 @@
"integrity": "sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/mocker": "4.0.18",
"@vitest/utils": "4.0.18",
@@ -2272,6 +2353,37 @@
}
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz",
"integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.0.18",
"ast-v8-to-istanbul": "^0.3.10",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.2.0",
"magicast": "^0.5.1",
"obug": "^2.1.1",
"std-env": "^3.10.0",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.0.18",
"vitest": "4.0.18"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
}
}
},
"node_modules/@vitest/expect": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
@@ -2477,6 +2589,28 @@
"node": ">=12"
}
},
"node_modules/ast-v8-to-istanbul": {
"version": "0.3.11",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz",
"integrity": "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"estree-walker": "^3.0.3",
"js-tokens": "^10.0.0"
}
},
"node_modules/ast-v8-to-istanbul/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -2809,6 +2943,12 @@
"integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==",
"license": "MIT"
},
"node_modules/dom-to-image-more": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/dom-to-image-more/-/dom-to-image-more-3.7.2.tgz",
"integrity": "sha512-uQf+pHv6eQhgfI8t2bFuinV0KsPyT8TZgCLwcSU8uBVgN9v6leb0mMpvp6HQAlAcplP3NCcGjxbdqef6pTzvmw==",
"license": "MIT"
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -3191,6 +3331,16 @@
"node": ">=18"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -3213,6 +3363,13 @@
"node": ">=12.0.0"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
@@ -3315,6 +3472,45 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
@@ -3347,6 +3543,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/js-tokens": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/json-bigint": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
@@ -3432,6 +3635,12 @@
"node": ">=14.0.0"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/lightningcss": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
@@ -3742,6 +3951,34 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/magicast": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz",
"integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"source-map-js": "^1.2.1"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/markdown-it": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
@@ -4537,6 +4774,19 @@
"sdp-verify": "checker.js"
}
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/set-cookie-parser": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz",
@@ -4751,6 +5001,19 @@
"npm": ">=8"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",

View File

@@ -25,9 +25,11 @@
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18",
"@types/leaflet": "^1.9.21",
"@types/marked": "^5.0.2",
"@types/twemoji": "^13.1.1",
"@vitest/browser-playwright": "^4.0.18",
"@vitest/coverage-v8": "^4.0.18",
"playwright": "^1.58.0",
"supabase": "^2.76.1",
"svelte": "^5.48.2",
@@ -39,7 +41,6 @@
"vitest-browser-svelte": "^2.0.2"
},
"dependencies": {
"@inlang/paraglide-js": "^2.10.0",
"@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.94.0",
"@tanstack/svelte-virtual": "^3.13.18",
@@ -47,10 +48,12 @@
"@tiptap/extension-placeholder": "^3.19.0",
"@tiptap/pm": "^3.19.0",
"@tiptap/starter-kit": "^3.19.0",
"dom-to-image-more": "^3.7.2",
"google-auth-library": "^10.5.0",
"highlight.js": "^11.11.1",
"leaflet": "^1.9.4",
"marked": "^17.0.1",
"matrix-js-sdk": "^40.2.0-rc.0",
"twemoji": "^14.0.2"
}
}
}

View File

@@ -1,13 +1,21 @@
<!doctype html>
<html lang="%paraglide.lang%">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="root" />
<link rel="manifest" href="/site.webmanifest" />
%sveltekit.head%
</head>
@@ -15,4 +23,5 @@
<body data-sveltekit-preload-data="tap">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
</html>

View File

@@ -46,18 +46,14 @@ const originalHandle: Handle = async ({ event, resolve }) => {
});
event.locals.safeGetSession = async () => {
const { data: { session } } = await event.locals.supabase.auth.getSession();
if (!session) {
return { session: null, user: null };
}
const { data: { user }, error } = await event.locals.supabase.auth.getUser();
if (error) {
if (error || !user) {
return { session: null, user: null };
}
const { data: { session } } = await event.locals.supabase.auth.getSession();
return { session, user };
};

View File

@@ -0,0 +1,63 @@
import { describe, it, expect, vi } from 'vitest';
import { logActivity } from './activity';
// ── Supabase mock builder ────────────────────────────────────────────────────
function mockChain(resolvedValue: { data: any; error: any }) {
const chain: any = {};
const methods = ['from', 'insert'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.then = (resolve: any) => resolve(resolvedValue);
return chain;
}
function mockSupabase(resolvedValue: { data: any; error: any }) {
const chain = mockChain(resolvedValue);
return { from: vi.fn(() => chain), _chain: chain } as any;
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe('logActivity', () => {
it('inserts activity log entry', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(
logActivity(sb, {
orgId: 'org1',
userId: 'user1',
action: 'create',
entityType: 'document',
entityId: 'doc1',
entityName: 'Test Doc',
})
).resolves.toBeUndefined();
expect(sb.from).toHaveBeenCalledWith('activity_log');
});
it('does not throw on error (fire-and-forget)', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
// logActivity should warn but not throw
await expect(
logActivity(sb, {
orgId: 'org1',
userId: 'user1',
action: 'delete',
entityType: 'folder',
})
).resolves.toBeUndefined();
});
it('passes metadata as JSON', async () => {
const sb = mockSupabase({ data: null, error: null });
await logActivity(sb, {
orgId: 'org1',
userId: 'user1',
action: 'move',
entityType: 'kanban_card',
metadata: { from: 'col1', to: 'col2' },
});
expect(sb.from).toHaveBeenCalledWith('activity_log');
});
});

View File

@@ -32,7 +32,7 @@ export async function logActivity(
});
if (error) {
// Activity logging should never block the main action just warn
// Activity logging should never block the main action - just warn
log.warn('Failed to log activity', { error: { message: error.message } });
}
}

190
src/lib/api/budget.test.ts Normal file
View File

@@ -0,0 +1,190 @@
import { describe, it, expect, vi } from 'vitest';
import {
fetchBudgetCategories,
createBudgetCategory,
updateBudgetCategory,
deleteBudgetCategory,
fetchEventBudgetCategories,
fetchEventBudgetItems,
fetchBudgetItems,
createBudgetItem,
updateBudgetItem,
deleteBudgetItem,
} from './budget';
// ── Supabase mock builder ────────────────────────────────────────────────────
function mockChain(resolvedValue: { data: any; error: any }) {
const chain: any = {};
const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'order', 'single', 'in'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.single = vi.fn(() => Promise.resolve(resolvedValue));
chain.then = (resolve: any) => resolve(resolvedValue);
return chain;
}
function mockSupabase(resolvedValue: { data: any; error: any }) {
const chain = mockChain(resolvedValue);
return { from: vi.fn(() => chain), _chain: chain } as any;
}
// ── Budget Categories ────────────────────────────────────────────────────────
describe('fetchBudgetCategories', () => {
it('returns categories for a department', async () => {
const cats = [{ id: 'c1', name: 'Travel', department_id: 'd1' }];
const sb = mockSupabase({ data: cats, error: null });
const result = await fetchBudgetCategories(sb, 'd1');
expect(result).toEqual(cats);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchBudgetCategories(sb, 'd1')).rejects.toEqual({ message: 'fail' });
});
});
describe('createBudgetCategory', () => {
it('creates a category with default color', async () => {
const cat = { id: 'c1', name: 'Food', department_id: 'd1', color: '#6366f1' };
const sb = mockSupabase({ data: cat, error: null });
const result = await createBudgetCategory(sb, 'd1', 'Food');
expect(result).toEqual(cat);
});
it('creates a category with custom color', async () => {
const cat = { id: 'c2', name: 'AV', department_id: 'd1', color: '#ff0000' };
const sb = mockSupabase({ data: cat, error: null });
const result = await createBudgetCategory(sb, 'd1', 'AV', '#ff0000');
expect(result).toEqual(cat);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'dup' } });
await expect(createBudgetCategory(sb, 'd1', 'X')).rejects.toEqual({ message: 'dup' });
});
});
describe('updateBudgetCategory', () => {
it('updates and returns the category', async () => {
const cat = { id: 'c1', name: 'Updated' };
const sb = mockSupabase({ data: cat, error: null });
const result = await updateBudgetCategory(sb, 'c1', { name: 'Updated' });
expect(result).toEqual(cat);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'nope' } });
await expect(updateBudgetCategory(sb, 'c1', { name: 'X' })).rejects.toEqual({ message: 'nope' });
});
});
describe('deleteBudgetCategory', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteBudgetCategory(sb, 'c1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'del fail' } });
await expect(deleteBudgetCategory(sb, 'c1')).rejects.toEqual({ message: 'del fail' });
});
});
describe('fetchEventBudgetCategories', () => {
it('returns categories stripped of join data', async () => {
const raw = [{ id: 'c1', name: 'Cat', event_departments: { event_id: 'e1' } }];
const sb = mockSupabase({ data: raw, error: null });
const result = await fetchEventBudgetCategories(sb, 'e1');
expect(result).toEqual([{ id: 'c1', name: 'Cat' }]);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchEventBudgetCategories(sb, 'e1')).rejects.toEqual({ message: 'fail' });
});
});
describe('fetchEventBudgetItems', () => {
it('returns items stripped of join data', async () => {
const raw = [{ id: 'i1', description: 'Item', event_departments: { event_id: 'e1' } }];
const sb = mockSupabase({ data: raw, error: null });
const result = await fetchEventBudgetItems(sb, 'e1');
expect(result).toEqual([{ id: 'i1', description: 'Item' }]);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchEventBudgetItems(sb, 'e1')).rejects.toEqual({ message: 'fail' });
});
});
// ── Budget Items ─────────────────────────────────────────────────────────────
describe('fetchBudgetItems', () => {
it('returns items for a department', async () => {
const items = [{ id: 'i1', description: 'Mic', department_id: 'd1' }];
const sb = mockSupabase({ data: items, error: null });
const result = await fetchBudgetItems(sb, 'd1');
expect(result).toEqual(items);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchBudgetItems(sb, 'd1')).rejects.toEqual({ message: 'fail' });
});
});
describe('createBudgetItem', () => {
it('creates an expense item with defaults', async () => {
const item = { id: 'i1', description: 'Mic', item_type: 'expense', planned_amount: 0, actual_amount: 0 };
const sb = mockSupabase({ data: item, error: null });
const result = await createBudgetItem(sb, 'd1', { description: 'Mic', item_type: 'expense' });
expect(result).toEqual(item);
});
it('creates an income item with amounts', async () => {
const item = { id: 'i2', description: 'Ticket', item_type: 'income', planned_amount: 100, actual_amount: 50 };
const sb = mockSupabase({ data: item, error: null });
const result = await createBudgetItem(sb, 'd1', {
description: 'Ticket',
item_type: 'income',
planned_amount: 100,
actual_amount: 50,
});
expect(result).toEqual(item);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(createBudgetItem(sb, 'd1', { description: 'X', item_type: 'expense' })).rejects.toEqual({ message: 'fail' });
});
});
describe('updateBudgetItem', () => {
it('updates and returns the item', async () => {
const item = { id: 'i1', description: 'Updated' };
const sb = mockSupabase({ data: item, error: null });
const result = await updateBudgetItem(sb, 'i1', { description: 'Updated' });
expect(result).toEqual(item);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateBudgetItem(sb, 'i1', { description: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('deleteBudgetItem', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteBudgetItem(sb, 'i1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteBudgetItem(sb, 'i1')).rejects.toEqual({ message: 'fail' });
});
});

222
src/lib/api/budget.ts Normal file
View File

@@ -0,0 +1,222 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { BudgetCategory, BudgetItem } from '$lib/supabase/types';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api.budget');
// Helper to cast supabase for tables not yet in generated types
function db(supabase: SupabaseClient) {
return supabase as any;
}
// ============================================================
// Budget Categories
// ============================================================
export async function fetchBudgetCategories(
supabase: SupabaseClient,
departmentId: string
): Promise<BudgetCategory[]> {
const { data, error } = await db(supabase)
.from('budget_categories')
.select('*')
.eq('department_id', departmentId)
.order('sort_order');
if (error) {
log.error('fetchBudgetCategories failed', { error, data: { departmentId } });
throw error;
}
return (data ?? []) as BudgetCategory[];
}
export async function createBudgetCategory(
supabase: SupabaseClient,
departmentId: string,
name: string,
color?: string
): Promise<BudgetCategory> {
const { data, error } = await db(supabase)
.from('budget_categories')
.insert({
department_id: departmentId,
name,
color: color ?? '#6366f1',
})
.select()
.single();
if (error) {
log.error('createBudgetCategory failed', { error, data: { departmentId, name } });
throw error;
}
return data as BudgetCategory;
}
export async function updateBudgetCategory(
supabase: SupabaseClient,
categoryId: string,
params: Partial<Pick<BudgetCategory, 'name' | 'color' | 'sort_order'>>
): Promise<BudgetCategory> {
const { data, error } = await db(supabase)
.from('budget_categories')
.update(params)
.eq('id', categoryId)
.select()
.single();
if (error) {
log.error('updateBudgetCategory failed', { error, data: { categoryId } });
throw error;
}
return data as BudgetCategory;
}
export async function deleteBudgetCategory(
supabase: SupabaseClient,
categoryId: string
): Promise<void> {
const { error } = await db(supabase)
.from('budget_categories')
.delete()
.eq('id', categoryId);
if (error) {
log.error('deleteBudgetCategory failed', { error, data: { categoryId } });
throw error;
}
}
/**
* Fetch all budget categories for all departments in an event.
*/
export async function fetchEventBudgetCategories(
supabase: SupabaseClient,
eventId: string
): Promise<BudgetCategory[]> {
const { data, error } = await db(supabase)
.from('budget_categories')
.select('*, event_departments!inner(event_id)')
.eq('event_departments.event_id', eventId)
.order('sort_order');
if (error) {
log.error('fetchEventBudgetCategories failed', { error, data: { eventId } });
throw error;
}
return (data ?? []).map((d: any) => {
const { event_departments, ...cat } = d;
return cat;
}) as BudgetCategory[];
}
/**
* Fetch all budget items for all departments in an event.
*/
export async function fetchEventBudgetItems(
supabase: SupabaseClient,
eventId: string
): Promise<BudgetItem[]> {
const { data, error } = await db(supabase)
.from('budget_items')
.select('*, event_departments!inner(event_id)')
.eq('event_departments.event_id', eventId)
.order('sort_order');
if (error) {
log.error('fetchEventBudgetItems failed', { error, data: { eventId } });
throw error;
}
return (data ?? []).map((d: any) => {
const { event_departments, ...item } = d;
return item;
}) as BudgetItem[];
}
// ============================================================
// Budget Items
// ============================================================
export async function fetchBudgetItems(
supabase: SupabaseClient,
departmentId: string
): Promise<BudgetItem[]> {
const { data, error } = await db(supabase)
.from('budget_items')
.select('*')
.eq('department_id', departmentId)
.order('sort_order');
if (error) {
log.error('fetchBudgetItems failed', { error, data: { departmentId } });
throw error;
}
return (data ?? []) as BudgetItem[];
}
export async function createBudgetItem(
supabase: SupabaseClient,
departmentId: string,
params: {
description: string;
item_type: 'income' | 'expense';
planned_amount?: number;
actual_amount?: number;
category_id?: string | null;
notes?: string;
}
): Promise<BudgetItem> {
const { data, error } = await db(supabase)
.from('budget_items')
.insert({
department_id: departmentId,
description: params.description,
item_type: params.item_type,
planned_amount: params.planned_amount ?? 0,
actual_amount: params.actual_amount ?? 0,
category_id: params.category_id ?? null,
notes: params.notes ?? null,
})
.select()
.single();
if (error) {
log.error('createBudgetItem failed', { error, data: { departmentId, description: params.description } });
throw error;
}
return data as BudgetItem;
}
export async function updateBudgetItem(
supabase: SupabaseClient,
itemId: string,
params: Partial<Pick<BudgetItem, 'description' | 'item_type' | 'planned_amount' | 'actual_amount' | 'category_id' | 'notes' | 'receipt_document_id' | 'sort_order'>>
): Promise<BudgetItem> {
const { data, error } = await db(supabase)
.from('budget_items')
.update({ ...params, updated_at: new Date().toISOString() })
.eq('id', itemId)
.select()
.single();
if (error) {
log.error('updateBudgetItem failed', { error, data: { itemId } });
throw error;
}
return data as BudgetItem;
}
export async function deleteBudgetItem(
supabase: SupabaseClient,
itemId: string
): Promise<void> {
const { error } = await db(supabase)
.from('budget_items')
.delete()
.eq('id', itemId);
if (error) {
log.error('deleteBudgetItem failed', { error, data: { itemId } });
throw error;
}
}

View File

@@ -1,5 +1,90 @@
import { describe, it, expect } from 'vitest';
import { getMonthDays, isSameDay, formatTime } from './calendar';
import { describe, it, expect, vi } from 'vitest';
import { getMonthDays, isSameDay, formatTime, fetchEvents, createEvent, updateEvent, deleteEvent } from './calendar';
// ── Supabase mock builder ────────────────────────────────────────────────────
function mockChain(resolvedValue: { data: any; error: any }) {
const chain: any = {};
const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'gte', 'lte', 'order', 'single', 'channel', 'on', 'subscribe'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.single = vi.fn(() => Promise.resolve(resolvedValue));
chain.then = (resolve: any) => resolve(resolvedValue);
return chain;
}
function mockSupabase(resolvedValue: { data: any; error: any }) {
const chain = mockChain(resolvedValue);
return { from: vi.fn(() => chain), _chain: chain } as any;
}
// ── Calendar CRUD ────────────────────────────────────────────────────────────
describe('calendar fetchEvents', () => {
it('returns events for date range', async () => {
const events = [{ id: 'ce1', title: 'Meeting', org_id: 'o1' }];
const sb = mockSupabase({ data: events, error: null });
const result = await fetchEvents(sb, 'o1', new Date('2024-01-01'), new Date('2024-01-31'));
expect(result).toEqual(events);
});
it('returns empty array when null', async () => {
const sb = mockSupabase({ data: null, error: null });
expect(await fetchEvents(sb, 'o1', new Date(), new Date())).toEqual([]);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchEvents(sb, 'o1', new Date(), new Date())).rejects.toEqual({ message: 'fail' });
});
});
describe('calendar createEvent', () => {
it('creates and returns event', async () => {
const event = { id: 'ce1', title: 'New Meeting' };
const sb = mockSupabase({ data: event, error: null });
const result = await createEvent(sb, 'o1', {
title: 'New Meeting',
start_time: '2024-01-15T10:00:00Z',
end_time: '2024-01-15T11:00:00Z',
}, 'user1');
expect(result).toEqual(event);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(createEvent(sb, 'o1', {
title: 'X',
start_time: '2024-01-15T10:00:00Z',
end_time: '2024-01-15T11:00:00Z',
}, 'user1')).rejects.toEqual({ message: 'fail' });
});
});
describe('calendar updateEvent', () => {
it('updates without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(updateEvent(sb, 'ce1', { title: 'Updated' })).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateEvent(sb, 'ce1', { title: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('calendar deleteEvent', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteEvent(sb, 'ce1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteEvent(sb, 'ce1')).rejects.toEqual({ message: 'fail' });
});
});
describe('getMonthDays', () => {
it('returns exactly 42 days (6 weeks grid)', () => {

View File

@@ -0,0 +1,131 @@
import { describe, it, expect, vi } from 'vitest';
import {
fetchContacts,
createContact,
updateContact,
deleteContact,
CONTACT_CATEGORIES,
CATEGORY_LABELS,
CATEGORY_ICONS,
} from './contacts';
// ── Supabase mock builder ────────────────────────────────────────────────────
function mockChain(resolvedValue: { data: any; error: any }) {
const chain: any = {};
const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'order', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.single = vi.fn(() => Promise.resolve(resolvedValue));
chain.then = (resolve: any) => resolve(resolvedValue);
return chain;
}
function mockSupabase(resolvedValue: { data: any; error: any }) {
const chain = mockChain(resolvedValue);
return { from: vi.fn(() => chain), _chain: chain } as any;
}
// ── Constants ────────────────────────────────────────────────────────────────
describe('contacts constants', () => {
it('CONTACT_CATEGORIES has expected entries', () => {
expect(CONTACT_CATEGORIES).toContain('general');
expect(CONTACT_CATEGORIES).toContain('vendor');
expect(CONTACT_CATEGORIES).toContain('speaker');
expect(CONTACT_CATEGORIES).toContain('media');
expect(CONTACT_CATEGORIES.length).toBe(10);
});
it('CATEGORY_LABELS has a label for every category', () => {
for (const cat of CONTACT_CATEGORIES) {
expect(CATEGORY_LABELS[cat]).toBeDefined();
expect(typeof CATEGORY_LABELS[cat]).toBe('string');
}
});
it('CATEGORY_ICONS has an icon for every category', () => {
for (const cat of CONTACT_CATEGORIES) {
expect(CATEGORY_ICONS[cat]).toBeDefined();
}
});
});
// ── CRUD ─────────────────────────────────────────────────────────────────────
describe('fetchContacts', () => {
it('returns contacts for a department', async () => {
const contacts = [{ id: 'ct1', name: 'Alice', department_id: 'd1' }];
const sb = mockSupabase({ data: contacts, error: null });
const result = await fetchContacts(sb, 'd1');
expect(result).toEqual(contacts);
});
it('returns empty array when data is null', async () => {
const sb = mockSupabase({ data: null, error: null });
const result = await fetchContacts(sb, 'd1');
expect(result).toEqual([]);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchContacts(sb, 'd1')).rejects.toEqual({ message: 'fail' });
});
});
describe('createContact', () => {
it('creates a contact with minimal params', async () => {
const contact = { id: 'ct1', name: 'Bob', department_id: 'd1', category: 'general' };
const sb = mockSupabase({ data: contact, error: null });
const result = await createContact(sb, 'd1', { name: 'Bob' });
expect(result).toEqual(contact);
});
it('creates a contact with all params', async () => {
const contact = { id: 'ct2', name: 'Eve', department_id: 'd1', category: 'vendor', email: 'eve@test.com' };
const sb = mockSupabase({ data: contact, error: null });
const result = await createContact(sb, 'd1', {
name: 'Eve',
category: 'vendor',
email: 'eve@test.com',
phone: '+1234',
company: 'Acme',
role: 'Manager',
website: 'https://acme.com',
notes: 'VIP',
}, 'user1');
expect(result).toEqual(contact);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(createContact(sb, 'd1', { name: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('updateContact', () => {
it('updates and returns the contact', async () => {
const contact = { id: 'ct1', name: 'Updated' };
const sb = mockSupabase({ data: contact, error: null });
const result = await updateContact(sb, 'ct1', { name: 'Updated' });
expect(result).toEqual(contact);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateContact(sb, 'ct1', { name: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('deleteContact', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteContact(sb, 'ct1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteContact(sb, 'ct1')).rejects.toEqual({ message: 'fail' });
});
});

147
src/lib/api/contacts.ts Normal file
View File

@@ -0,0 +1,147 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { DepartmentContact } from '$lib/supabase/types';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api.contacts');
// Helper to cast supabase for tables not yet in generated types
function db(supabase: SupabaseClient) {
return supabase as any;
}
export const CONTACT_CATEGORIES = [
'general',
'vendor',
'sponsor',
'speaker',
'venue',
'catering',
'av_tech',
'transport',
'security',
'media',
] as const;
export type ContactCategory = (typeof CONTACT_CATEGORIES)[number];
export const CATEGORY_LABELS: Record<string, string> = {
general: 'General',
vendor: 'Vendor',
sponsor: 'Sponsor',
speaker: 'Speaker',
venue: 'Venue',
catering: 'Catering',
av_tech: 'AV / Tech',
transport: 'Transport',
security: 'Security',
media: 'Media',
};
export const CATEGORY_ICONS: Record<string, string> = {
general: 'person',
vendor: 'storefront',
sponsor: 'handshake',
speaker: 'mic',
venue: 'location_on',
catering: 'restaurant',
av_tech: 'settings_input_hdmi',
transport: 'local_shipping',
security: 'shield',
media: 'videocam',
};
// ============================================================
// CRUD
// ============================================================
export async function fetchContacts(
supabase: SupabaseClient,
departmentId: string
): Promise<DepartmentContact[]> {
const { data, error } = await db(supabase)
.from('department_contacts')
.select('*')
.eq('department_id', departmentId)
.order('name');
if (error) {
log.error('fetchContacts failed', { error, data: { departmentId } });
throw error;
}
return (data ?? []) as DepartmentContact[];
}
export async function createContact(
supabase: SupabaseClient,
departmentId: string,
params: {
name: string;
role?: string;
company?: string;
email?: string;
phone?: string;
website?: string;
notes?: string;
category?: string;
color?: string;
},
userId?: string
): Promise<DepartmentContact> {
const { data, error } = await db(supabase)
.from('department_contacts')
.insert({
department_id: departmentId,
name: params.name,
role: params.role ?? null,
company: params.company ?? null,
email: params.email ?? null,
phone: params.phone ?? null,
website: params.website ?? null,
notes: params.notes ?? null,
category: params.category ?? 'general',
color: params.color ?? '#00A3E0',
created_by: userId ?? null,
})
.select()
.single();
if (error) {
log.error('createContact failed', { error, data: { departmentId, name: params.name } });
throw error;
}
return data as DepartmentContact;
}
export async function updateContact(
supabase: SupabaseClient,
contactId: string,
params: Partial<Pick<DepartmentContact, 'name' | 'role' | 'company' | 'email' | 'phone' | 'website' | 'notes' | 'category' | 'color'>>
): Promise<DepartmentContact> {
const { data, error } = await db(supabase)
.from('department_contacts')
.update({ ...params, updated_at: new Date().toISOString() })
.eq('id', contactId)
.select()
.single();
if (error) {
log.error('updateContact failed', { error, data: { contactId } });
throw error;
}
return data as DepartmentContact;
}
export async function deleteContact(
supabase: SupabaseClient,
contactId: string
): Promise<void> {
const { error } = await db(supabase)
.from('department_contacts')
.delete()
.eq('id', contactId);
if (error) {
log.error('deleteContact failed', { error, data: { contactId } });
throw error;
}
}

View File

@@ -0,0 +1,311 @@
import { describe, it, expect, vi } from 'vitest';
import {
fetchDashboard,
updateDashboardLayout,
addPanel,
updatePanel,
removePanel,
fetchChecklists,
createChecklist,
deleteChecklist,
renameChecklist,
addChecklistItem,
updateChecklistItem,
deleteChecklistItem,
toggleChecklistItem,
fetchNotes,
createNote,
updateNote,
deleteNote,
} from './department-dashboard';
// ── Supabase mock builder ────────────────────────────────────────────────────
function mockChain(resolvedValue: { data: any; error: any }) {
const chain: any = {};
const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'in', 'order', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.single = vi.fn(() => Promise.resolve(resolvedValue));
chain.then = (resolve: any) => resolve(resolvedValue);
return chain;
}
function mockSupabase(resolvedValue: { data: any; error: any }) {
const chain = mockChain(resolvedValue);
return { from: vi.fn(() => chain), _chain: chain } as any;
}
// ── Dashboard ────────────────────────────────────────────────────────────────
describe('fetchDashboard', () => {
it('returns dashboard with sorted panels', async () => {
const dash = {
id: 'd1',
department_id: 'dept1',
panels: [
{ id: 'p2', position: 1 },
{ id: 'p1', position: 0 },
],
};
const sb = mockSupabase({ data: dash, error: null });
const result = await fetchDashboard(sb, 'dept1');
expect(result).not.toBeNull();
expect(result!.panels[0].id).toBe('p1');
expect(result!.panels[1].id).toBe('p2');
});
it('returns null when not found (PGRST116)', async () => {
const sb = mockSupabase({ data: null, error: { code: 'PGRST116', message: 'not found' } });
const result = await fetchDashboard(sb, 'dept1');
expect(result).toBeNull();
});
it('throws on other errors', async () => {
const sb = mockSupabase({ data: null, error: { code: '42000', message: 'fail' } });
await expect(fetchDashboard(sb, 'dept1')).rejects.toEqual({ code: '42000', message: 'fail' });
});
});
describe('updateDashboardLayout', () => {
it('updates and returns dashboard', async () => {
const dash = { id: 'd1', layout: 'grid' };
const sb = mockSupabase({ data: dash, error: null });
expect(await updateDashboardLayout(sb, 'd1', 'grid' as any)).toEqual(dash);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateDashboardLayout(sb, 'd1', 'grid' as any)).rejects.toEqual({ message: 'fail' });
});
});
// ── Panels ───────────────────────────────────────────────────────────────────
describe('addPanel', () => {
it('adds and returns panel', async () => {
const panel = { id: 'p1', dashboard_id: 'd1', module: 'checklist', position: 0 };
const sb = mockSupabase({ data: panel, error: null });
expect(await addPanel(sb, 'd1', 'checklist' as any, 0)).toEqual(panel);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(addPanel(sb, 'd1', 'checklist' as any, 0)).rejects.toEqual({ message: 'fail' });
});
});
describe('updatePanel', () => {
it('updates and returns panel', async () => {
const panel = { id: 'p1', width: 'full' };
const sb = mockSupabase({ data: panel, error: null });
expect(await updatePanel(sb, 'p1', { width: 'full' } as any)).toEqual(panel);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updatePanel(sb, 'p1', { width: 'full' } as any)).rejects.toEqual({ message: 'fail' });
});
});
describe('removePanel', () => {
it('removes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(removePanel(sb, 'p1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(removePanel(sb, 'p1')).rejects.toEqual({ message: 'fail' });
});
});
// ── Checklists ───────────────────────────────────────────────────────────────
describe('fetchChecklists', () => {
it('returns checklists with items grouped', async () => {
const checklists = [{ id: 'cl1', department_id: 'dept1', title: 'Pre-event' }];
const items = [{ id: 'i1', checklist_id: 'cl1', content: 'Book venue', is_completed: false }];
const chain: any = {};
const methods = ['from', 'select', 'eq', 'in', 'order'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
let callIdx = 0;
chain.then = (resolve: any) => {
callIdx++;
if (callIdx === 1) return resolve({ data: checklists, error: null });
return resolve({ data: items, error: null });
};
const sb = { from: vi.fn(() => chain) } as any;
const result = await fetchChecklists(sb, 'dept1');
expect(result).toHaveLength(1);
expect(result[0].items).toHaveLength(1);
expect(result[0].items[0].content).toBe('Book venue');
});
it('returns empty array when no checklists', async () => {
const sb = mockSupabase({ data: [], error: null });
const result = await fetchChecklists(sb, 'dept1');
expect(result).toEqual([]);
});
it('throws on error', async () => {
const chain: any = {};
const methods = ['from', 'select', 'eq', 'order'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.then = (resolve: any) => resolve({ data: null, error: { message: 'fail' } });
const sb = { from: vi.fn(() => chain) } as any;
await expect(fetchChecklists(sb, 'dept1')).rejects.toEqual({ message: 'fail' });
});
});
describe('createChecklist', () => {
it('creates and returns checklist', async () => {
const cl = { id: 'cl1', title: 'Setup', department_id: 'dept1' };
const sb = mockSupabase({ data: cl, error: null });
expect(await createChecklist(sb, 'dept1', 'Setup', 'user1')).toEqual(cl);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(createChecklist(sb, 'dept1', 'X')).rejects.toEqual({ message: 'fail' });
});
});
describe('deleteChecklist', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteChecklist(sb, 'cl1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteChecklist(sb, 'cl1')).rejects.toEqual({ message: 'fail' });
});
});
describe('renameChecklist', () => {
it('renames and returns checklist', async () => {
const cl = { id: 'cl1', title: 'Renamed' };
const sb = mockSupabase({ data: cl, error: null });
expect(await renameChecklist(sb, 'cl1', 'Renamed')).toEqual(cl);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(renameChecklist(sb, 'cl1', 'X')).rejects.toEqual({ message: 'fail' });
});
});
// ── Checklist Items ──────────────────────────────────────────────────────────
describe('addChecklistItem', () => {
it('adds and returns item', async () => {
const item = { id: 'i1', checklist_id: 'cl1', content: 'Task', sort_order: 0 };
const sb = mockSupabase({ data: item, error: null });
expect(await addChecklistItem(sb, 'cl1', 'Task')).toEqual(item);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(addChecklistItem(sb, 'cl1', 'X')).rejects.toEqual({ message: 'fail' });
});
});
describe('updateChecklistItem', () => {
it('updates and returns item', async () => {
const item = { id: 'i1', content: 'Updated' };
const sb = mockSupabase({ data: item, error: null });
expect(await updateChecklistItem(sb, 'i1', { content: 'Updated' })).toEqual(item);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateChecklistItem(sb, 'i1', { content: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('deleteChecklistItem', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteChecklistItem(sb, 'i1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteChecklistItem(sb, 'i1')).rejects.toEqual({ message: 'fail' });
});
});
describe('toggleChecklistItem', () => {
it('toggles completion via updateChecklistItem', async () => {
const item = { id: 'i1', is_completed: true };
const sb = mockSupabase({ data: item, error: null });
expect(await toggleChecklistItem(sb, 'i1', true)).toEqual(item);
});
});
// ── Notes ────────────────────────────────────────────────────────────────────
describe('fetchNotes', () => {
it('returns notes for a department', async () => {
const notes = [{ id: 'n1', title: 'Meeting Notes', department_id: 'dept1' }];
const sb = mockSupabase({ data: notes, error: null });
expect(await fetchNotes(sb, 'dept1')).toEqual(notes);
});
it('returns empty array when null', async () => {
const sb = mockSupabase({ data: null, error: null });
expect(await fetchNotes(sb, 'dept1')).toEqual([]);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchNotes(sb, 'dept1')).rejects.toEqual({ message: 'fail' });
});
});
describe('createNote', () => {
it('creates and returns note', async () => {
const note = { id: 'n1', title: 'New Note', department_id: 'dept1' };
const sb = mockSupabase({ data: note, error: null });
expect(await createNote(sb, 'dept1', 'New Note', 'user1')).toEqual(note);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(createNote(sb, 'dept1', 'X')).rejects.toEqual({ message: 'fail' });
});
});
describe('updateNote', () => {
it('updates and returns note', async () => {
const note = { id: 'n1', title: 'Updated' };
const sb = mockSupabase({ data: note, error: null });
expect(await updateNote(sb, 'n1', { title: 'Updated' })).toEqual(note);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateNote(sb, 'n1', { title: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('deleteNote', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteNote(sb, 'n1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteNote(sb, 'n1')).rejects.toEqual({ message: 'fail' });
});
});

View File

@@ -0,0 +1,354 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database, DepartmentDashboard, DashboardPanel, DepartmentChecklist, DepartmentChecklistItem, DepartmentNote, ModuleType, LayoutPreset } from '$lib/supabase/types';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api.department-dashboard');
// ============================================================
// Dashboard
// ============================================================
export interface DashboardWithPanels extends DepartmentDashboard {
panels: DashboardPanel[];
}
export async function fetchDashboard(
supabase: SupabaseClient<Database>,
departmentId: string
): Promise<DashboardWithPanels | null> {
const { data, error } = await supabase
.from('department_dashboards')
.select('*, panels:dashboard_panels(*)')
.eq('department_id', departmentId)
.single();
if (error) {
if (error.code === 'PGRST116') return null;
log.error('fetchDashboard failed', { error, data: { departmentId } });
throw error;
}
const dashboard = data as any;
return {
...dashboard,
panels: (dashboard.panels ?? []).sort((a: DashboardPanel, b: DashboardPanel) => a.position - b.position),
};
}
export async function updateDashboardLayout(
supabase: SupabaseClient<Database>,
dashboardId: string,
layout: LayoutPreset
): Promise<DepartmentDashboard> {
const { data, error } = await supabase
.from('department_dashboards')
.update({ layout, updated_at: new Date().toISOString() })
.eq('id', dashboardId)
.select()
.single();
if (error) {
log.error('updateDashboardLayout failed', { error, data: { dashboardId, layout } });
throw error;
}
return data as unknown as DepartmentDashboard;
}
// ============================================================
// Panels
// ============================================================
export async function addPanel(
supabase: SupabaseClient<Database>,
dashboardId: string,
module: ModuleType,
position: number,
width: string = 'half'
): Promise<DashboardPanel> {
const { data, error } = await supabase
.from('dashboard_panels')
.insert({ dashboard_id: dashboardId, module, position, width })
.select()
.single();
if (error) {
log.error('addPanel failed', { error, data: { dashboardId, module } });
throw error;
}
return data as unknown as DashboardPanel;
}
export async function updatePanel(
supabase: SupabaseClient<Database>,
panelId: string,
params: Partial<Pick<DashboardPanel, 'position' | 'width' | 'config'>>
): Promise<DashboardPanel> {
const { data, error } = await supabase
.from('dashboard_panels')
.update(params)
.eq('id', panelId)
.select()
.single();
if (error) {
log.error('updatePanel failed', { error, data: { panelId } });
throw error;
}
return data as unknown as DashboardPanel;
}
export async function removePanel(
supabase: SupabaseClient<Database>,
panelId: string
): Promise<void> {
const { error } = await supabase
.from('dashboard_panels')
.delete()
.eq('id', panelId);
if (error) {
log.error('removePanel failed', { error, data: { panelId } });
throw error;
}
}
// ============================================================
// Checklists
// ============================================================
export interface ChecklistWithItems extends DepartmentChecklist {
items: DepartmentChecklistItem[];
}
export async function fetchChecklists(
supabase: SupabaseClient<Database>,
departmentId: string
): Promise<ChecklistWithItems[]> {
const { data: checklists, error } = await supabase
.from('department_checklists')
.select('*')
.eq('department_id', departmentId)
.order('sort_order');
if (error) {
log.error('fetchChecklists failed', { error, data: { departmentId } });
throw error;
}
if (!checklists || checklists.length === 0) return [];
const checklistIds = checklists.map(c => c.id);
const { data: items, error: itemsError } = await supabase
.from('department_checklist_items')
.select('*')
.in('checklist_id', checklistIds)
.order('sort_order');
if (itemsError) {
log.error('fetchChecklistItems failed', { error: itemsError });
throw itemsError;
}
const itemsByChecklist: Record<string, DepartmentChecklistItem[]> = {};
for (const item of (items ?? [])) {
if (!itemsByChecklist[item.checklist_id]) itemsByChecklist[item.checklist_id] = [];
itemsByChecklist[item.checklist_id].push(item as unknown as DepartmentChecklistItem);
}
return checklists.map(c => ({
...(c as unknown as DepartmentChecklist),
items: itemsByChecklist[c.id] ?? [],
}));
}
export async function createChecklist(
supabase: SupabaseClient<Database>,
departmentId: string,
title: string,
userId?: string
): Promise<DepartmentChecklist> {
const { data, error } = await supabase
.from('department_checklists')
.insert({ department_id: departmentId, title, created_by: userId ?? null })
.select()
.single();
if (error) {
log.error('createChecklist failed', { error, data: { departmentId, title } });
throw error;
}
return data as unknown as DepartmentChecklist;
}
export async function deleteChecklist(
supabase: SupabaseClient<Database>,
checklistId: string
): Promise<void> {
const { error } = await supabase
.from('department_checklists')
.delete()
.eq('id', checklistId);
if (error) {
log.error('deleteChecklist failed', { error, data: { checklistId } });
throw error;
}
}
export async function renameChecklist(
supabase: SupabaseClient<Database>,
checklistId: string,
title: string
): Promise<DepartmentChecklist> {
const { data, error } = await supabase
.from('department_checklists')
.update({ title })
.eq('id', checklistId)
.select()
.single();
if (error) {
log.error('renameChecklist failed', { error, data: { checklistId, title } });
throw error;
}
return data as unknown as DepartmentChecklist;
}
// ============================================================
// Checklist Items
// ============================================================
export async function addChecklistItem(
supabase: SupabaseClient<Database>,
checklistId: string,
content: string,
sortOrder: number = 0
): Promise<DepartmentChecklistItem> {
const { data, error } = await supabase
.from('department_checklist_items')
.insert({ checklist_id: checklistId, content, sort_order: sortOrder })
.select()
.single();
if (error) {
log.error('addChecklistItem failed', { error, data: { checklistId, content } });
throw error;
}
return data as unknown as DepartmentChecklistItem;
}
export async function updateChecklistItem(
supabase: SupabaseClient<Database>,
itemId: string,
params: Partial<Pick<DepartmentChecklistItem, 'content' | 'is_completed' | 'assigned_to' | 'due_date' | 'sort_order'>>
): Promise<DepartmentChecklistItem> {
const { data, error } = await supabase
.from('department_checklist_items')
.update({ ...params, updated_at: new Date().toISOString() })
.eq('id', itemId)
.select()
.single();
if (error) {
log.error('updateChecklistItem failed', { error, data: { itemId } });
throw error;
}
return data as unknown as DepartmentChecklistItem;
}
export async function deleteChecklistItem(
supabase: SupabaseClient<Database>,
itemId: string
): Promise<void> {
const { error } = await supabase
.from('department_checklist_items')
.delete()
.eq('id', itemId);
if (error) {
log.error('deleteChecklistItem failed', { error, data: { itemId } });
throw error;
}
}
export async function toggleChecklistItem(
supabase: SupabaseClient<Database>,
itemId: string,
isCompleted: boolean
): Promise<DepartmentChecklistItem> {
return updateChecklistItem(supabase, itemId, { is_completed: isCompleted });
}
// ============================================================
// Notes
// ============================================================
export async function fetchNotes(
supabase: SupabaseClient<Database>,
departmentId: string
): Promise<DepartmentNote[]> {
const { data, error } = await supabase
.from('department_notes')
.select('*')
.eq('department_id', departmentId)
.order('sort_order');
if (error) {
log.error('fetchNotes failed', { error, data: { departmentId } });
throw error;
}
return (data ?? []) as unknown as DepartmentNote[];
}
export async function createNote(
supabase: SupabaseClient<Database>,
departmentId: string,
title: string,
userId?: string
): Promise<DepartmentNote> {
const { data, error } = await supabase
.from('department_notes')
.insert({ department_id: departmentId, title, created_by: userId ?? null })
.select()
.single();
if (error) {
log.error('createNote failed', { error, data: { departmentId, title } });
throw error;
}
return data as unknown as DepartmentNote;
}
export async function updateNote(
supabase: SupabaseClient<Database>,
noteId: string,
params: Partial<Pick<DepartmentNote, 'title' | 'content' | 'sort_order'>>
): Promise<DepartmentNote> {
const { data, error } = await supabase
.from('department_notes')
.update({ ...params, updated_at: new Date().toISOString() })
.eq('id', noteId)
.select()
.single();
if (error) {
log.error('updateNote failed', { error, data: { noteId } });
throw error;
}
return data as unknown as DepartmentNote;
}
export async function deleteNote(
supabase: SupabaseClient<Database>,
noteId: string
): Promise<void> {
const { error } = await supabase
.from('department_notes')
.delete()
.eq('id', noteId);
if (error) {
log.error('deleteNote failed', { error, data: { noteId } });
throw error;
}
}

View File

@@ -0,0 +1,146 @@
import { describe, it, expect, vi } from 'vitest';
import {
getLockInfo,
acquireLock,
heartbeatLock,
releaseLock,
startHeartbeat,
} from './document-locks';
// ── Supabase mock builder ────────────────────────────────────────────────────
function mockChain(resolvedValue: { data: any; error: any }) {
const chain: any = {};
const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'gt', 'lt', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.single = vi.fn(() => Promise.resolve(resolvedValue));
chain.then = (resolve: any) => resolve(resolvedValue);
return chain;
}
function mockSupabase(resolvedValue: { data: any; error: any }) {
const chain = mockChain(resolvedValue);
return { from: vi.fn(() => chain), _chain: chain } as any;
}
// ── getLockInfo ──────────────────────────────────────────────────────────────
describe('getLockInfo', () => {
it('returns unlocked when no lock exists', async () => {
const sb = mockSupabase({ data: null, error: null });
const info = await getLockInfo(sb, 'doc1', 'user1');
expect(info).toEqual({
isLocked: false,
lockedBy: null,
lockedByName: null,
isOwnLock: false,
});
});
it('returns locked with own lock', async () => {
// First call returns lock, second returns profile
const lockData = { id: 'l1', document_id: 'doc1', user_id: 'user1', locked_at: new Date().toISOString(), last_heartbeat: new Date().toISOString() };
const profileData = { full_name: 'Alice', email: 'alice@test.com' };
const chain: any = {};
const methods = ['from', 'select', 'eq', 'gt', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
let callCount = 0;
chain.single = vi.fn(() => {
callCount++;
if (callCount === 1) return Promise.resolve({ data: lockData, error: null });
return Promise.resolve({ data: profileData, error: null });
});
const sb = { from: vi.fn(() => chain) } as any;
const info = await getLockInfo(sb, 'doc1', 'user1');
expect(info.isLocked).toBe(true);
expect(info.isOwnLock).toBe(true);
expect(info.lockedByName).toBe('Alice');
});
it('returns locked with other user lock', async () => {
const lockData = { id: 'l1', document_id: 'doc1', user_id: 'user2', locked_at: new Date().toISOString(), last_heartbeat: new Date().toISOString() };
const profileData = { full_name: 'Bob', email: 'bob@test.com' };
const chain: any = {};
const methods = ['from', 'select', 'eq', 'gt', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
let callCount = 0;
chain.single = vi.fn(() => {
callCount++;
if (callCount === 1) return Promise.resolve({ data: lockData, error: null });
return Promise.resolve({ data: profileData, error: null });
});
const sb = { from: vi.fn(() => chain) } as any;
const info = await getLockInfo(sb, 'doc1', 'user1');
expect(info.isLocked).toBe(true);
expect(info.isOwnLock).toBe(false);
expect(info.lockedByName).toBe('Bob');
});
});
// ── acquireLock ──────────────────────────────────────────────────────────────
describe('acquireLock', () => {
it('returns true when lock acquired', async () => {
const sb = mockSupabase({ data: null, error: null });
expect(await acquireLock(sb, 'doc1', 'user1')).toBe(true);
});
it('returns false on unique constraint violation', async () => {
const sb = mockSupabase({ data: null, error: { code: '23505', message: 'unique' } });
expect(await acquireLock(sb, 'doc1', 'user1')).toBe(false);
});
it('returns false on other errors', async () => {
const sb = mockSupabase({ data: null, error: { code: '42000', message: 'other' } });
expect(await acquireLock(sb, 'doc1', 'user1')).toBe(false);
});
});
// ── heartbeatLock ────────────────────────────────────────────────────────────
describe('heartbeatLock', () => {
it('returns true on success', async () => {
const sb = mockSupabase({ data: null, error: null });
expect(await heartbeatLock(sb, 'doc1', 'user1')).toBe(true);
});
it('returns false on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
expect(await heartbeatLock(sb, 'doc1', 'user1')).toBe(false);
});
});
// ── releaseLock ──────────────────────────────────────────────────────────────
describe('releaseLock', () => {
it('releases without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(releaseLock(sb, 'doc1', 'user1')).resolves.toBeUndefined();
});
it('does not throw on error (just logs)', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(releaseLock(sb, 'doc1', 'user1')).resolves.toBeUndefined();
});
});
// ── startHeartbeat ───────────────────────────────────────────────────────────
describe('startHeartbeat', () => {
it('returns a cleanup function', () => {
const sb = mockSupabase({ data: null, error: null });
const cleanup = startHeartbeat(sb, 'doc1', 'user1');
expect(typeof cleanup).toBe('function');
cleanup(); // should not throw
});
});

View File

@@ -36,7 +36,7 @@ export async function getLockInfo(
return { isLocked: false, lockedBy: null, lockedByName: null, isOwnLock: false };
}
// Fetch profile separately document_locks.user_id FK points to auth.users, not profiles
// Fetch profile separately - document_locks.user_id FK points to auth.users, not profiles
let lockedByName = 'Someone';
if (lock.user_id) {
const { data: profile } = await supabase
@@ -87,7 +87,7 @@ export async function acquireLock(
if (error) {
if (error.code === '23505') {
// Unique constraint violation someone else holds the lock
// Unique constraint violation - someone else holds the lock
log.debug('Lock already held', { data: { documentId } });
return false;
}

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest';
import { createDocument, updateDocument, deleteDocument, moveDocument, copyDocument, fetchDocuments } from './documents';
import { createDocument, updateDocument, deleteDocument, moveDocument, copyDocument, fetchDocuments, fetchFolderContents, findDepartmentFolder, findFinanceFolder, ensureEventsFolder, createEventFolder, createDepartmentFolder, ensureFinanceFolder, ensureFinanceDeptFolder, uploadFile, deleteFileFromStorage, subscribeToDocuments, getFileMetadata, formatFileSize } from './documents';
// Lightweight Supabase mock builder
function mockSupabase(response: { data?: unknown; error?: unknown }) {
@@ -108,7 +108,7 @@ describe('deleteDocument', () => {
describe('fetchDocuments', () => {
it('returns documents array on success', async () => {
const docs = [fakeDoc];
// fetchDocuments calls .from().select().eq().order().order() need deeper chain
// fetchDocuments calls .from().select().eq().order().order() - need deeper chain
const orderFn2 = vi.fn().mockResolvedValue({ data: docs, error: null });
const orderFn1 = vi.fn().mockReturnValue({ order: orderFn2 });
const eqFn = vi.fn().mockReturnValue({ order: orderFn1 });
@@ -130,3 +130,538 @@ describe('fetchDocuments', () => {
await expect(fetchDocuments(sb, 'org-1')).rejects.toEqual({ message: 'fetch failed' });
});
});
// ── updateDocument ───────────────────────────────────────────────────────────
describe('updateDocument', () => {
it('updates and returns document', async () => {
const updated = { ...fakeDoc, name: 'Renamed' };
const sb = mockSupabaseSuccess(updated);
const result = await updateDocument(sb, 'doc-1', { name: 'Renamed' });
expect(result.name).toBe('Renamed');
});
it('throws on error', async () => {
const sb = mockSupabaseError('update failed');
await expect(updateDocument(sb, 'doc-1', { name: 'X' }))
.rejects.toEqual({ message: 'update failed', code: 'ERROR' });
});
});
// ── moveDocument ─────────────────────────────────────────────────────────────
describe('moveDocument', () => {
it('moves document to new parent', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(moveDocument(sb, 'doc-1', 'folder-2')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'move failed', code: 'ERROR' } });
await expect(moveDocument(sb, 'doc-1', 'folder-2'))
.rejects.toEqual({ message: 'move failed', code: 'ERROR' });
});
});
// ── fetchFolderContents ──────────────────────────────────────────────────────
describe('fetchFolderContents', () => {
it('returns folder contents', async () => {
const docs = [fakeDoc];
const orderFn2 = vi.fn().mockResolvedValue({ data: docs, error: null });
const orderFn1 = vi.fn().mockReturnValue({ order: orderFn2 });
const eqFn = vi.fn().mockReturnValue({ order: orderFn1 });
const selectFn = vi.fn().mockReturnValue({ eq: eqFn });
const sb = { from: vi.fn().mockReturnValue({ select: selectFn }) } as any;
const result = await fetchFolderContents(sb, 'folder-1');
expect(result).toEqual(docs);
});
it('returns empty array when null', async () => {
const orderFn2 = vi.fn().mockResolvedValue({ data: null, error: null });
const orderFn1 = vi.fn().mockReturnValue({ order: orderFn2 });
const eqFn = vi.fn().mockReturnValue({ order: orderFn1 });
const selectFn = vi.fn().mockReturnValue({ eq: eqFn });
const sb = { from: vi.fn().mockReturnValue({ select: selectFn }) } as any;
expect(await fetchFolderContents(sb, 'folder-1')).toEqual([]);
});
it('throws on error', async () => {
const orderFn2 = vi.fn().mockResolvedValue({ data: null, error: { message: 'fail' } });
const orderFn1 = vi.fn().mockReturnValue({ order: orderFn2 });
const eqFn = vi.fn().mockReturnValue({ order: orderFn1 });
const selectFn = vi.fn().mockReturnValue({ eq: eqFn });
const sb = { from: vi.fn().mockReturnValue({ select: selectFn }) } as any;
await expect(fetchFolderContents(sb, 'folder-1')).rejects.toEqual({ message: 'fail' });
});
});
// ── findDepartmentFolder ─────────────────────────────────────────────────────
describe('findDepartmentFolder', () => {
it('returns folder when found', async () => {
const folder = { id: 'f1', name: 'Dept', type: 'folder' };
const chain: any = {};
const methods = ['from', 'select', 'eq', 'limit', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.single = vi.fn(() => Promise.resolve({ data: folder, error: null }));
const sb = { from: vi.fn(() => chain) } as any;
const result = await findDepartmentFolder(sb, 'dept-1');
expect(result).toEqual(folder);
});
it('returns null when not found', async () => {
const chain: any = {};
const methods = ['from', 'select', 'eq', 'limit', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.single = vi.fn(() => Promise.resolve({ data: null, error: null }));
const sb = { from: vi.fn(() => chain) } as any;
expect(await findDepartmentFolder(sb, 'dept-1')).toBeNull();
});
});
// ── Pure utility functions ───────────────────────────────────────────────────
describe('getFileMetadata', () => {
it('returns metadata for file type with storage_path', () => {
const doc = { type: 'file', content: { storage_path: '/path/to/file', mime_type: 'image/png', size: 1024 } } as any;
const result = getFileMetadata(doc);
expect(result).not.toBeNull();
expect(result!.storage_path).toBe('/path/to/file');
});
it('returns null for non-file type', () => {
const doc = { type: 'folder', content: null } as any;
expect(getFileMetadata(doc)).toBeNull();
});
it('returns null when content is null', () => {
const doc = { type: 'file', content: null } as any;
expect(getFileMetadata(doc)).toBeNull();
});
it('returns null when no storage_path', () => {
const doc = { type: 'file', content: { mime_type: 'text/plain' } } as any;
expect(getFileMetadata(doc)).toBeNull();
});
});
describe('formatFileSize', () => {
it('formats bytes', () => {
expect(formatFileSize(500)).toBe('500 B');
});
it('formats kilobytes', () => {
expect(formatFileSize(2048)).toBe('2.0 KB');
});
it('formats megabytes', () => {
expect(formatFileSize(5 * 1024 * 1024)).toBe('5.0 MB');
});
it('formats gigabytes', () => {
expect(formatFileSize(2 * 1024 * 1024 * 1024)).toBe('2.0 GB');
});
});
// ── findFinanceFolder ────────────────────────────────────────────────────────
describe('findFinanceFolder', () => {
it('returns finance folder when event folder and finance folder exist', async () => {
const eventFolder = { id: 'ef1' };
const financeFolder = { id: 'ff1', name: 'Finance', type: 'folder' };
const chain: any = {};
const methods = ['from', 'select', 'eq', 'is', 'limit', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
let callIdx = 0;
chain.single = vi.fn(() => {
callIdx++;
if (callIdx === 1) return Promise.resolve({ data: eventFolder, error: null });
return Promise.resolve({ data: financeFolder, error: null });
});
const sb = { from: vi.fn(() => chain) } as any;
const result = await findFinanceFolder(sb, 'event-1');
expect(result).toEqual(financeFolder);
});
it('returns null when event folder not found', async () => {
const chain: any = {};
const methods = ['from', 'select', 'eq', 'is', 'limit', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.single = vi.fn(() => Promise.resolve({ data: null, error: null }));
const sb = { from: vi.fn(() => chain) } as any;
expect(await findFinanceFolder(sb, 'event-1')).toBeNull();
});
it('returns null when finance folder not found inside event folder', async () => {
const eventFolder = { id: 'ef1' };
const chain: any = {};
const methods = ['from', 'select', 'eq', 'is', 'limit', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
let callIdx = 0;
chain.single = vi.fn(() => {
callIdx++;
if (callIdx === 1) return Promise.resolve({ data: eventFolder, error: null });
return Promise.resolve({ data: null, error: null });
});
const sb = { from: vi.fn(() => chain) } as any;
expect(await findFinanceFolder(sb, 'event-1')).toBeNull();
});
});
// ── deleteFileFromStorage ────────────────────────────────────────────────────
describe('deleteFileFromStorage', () => {
it('deletes file from storage', async () => {
const removeFn = vi.fn().mockResolvedValue({ error: null });
const sb = { storage: { from: vi.fn().mockReturnValue({ remove: removeFn }) } } as any;
await expect(deleteFileFromStorage(sb, 'org/root/file.txt')).resolves.toBeUndefined();
expect(removeFn).toHaveBeenCalledWith(['org/root/file.txt']);
});
it('throws on storage error', async () => {
const removeFn = vi.fn().mockResolvedValue({ error: { message: 'storage fail' } });
const sb = { storage: { from: vi.fn().mockReturnValue({ remove: removeFn }) } } as any;
await expect(deleteFileFromStorage(sb, 'org/root/file.txt')).rejects.toEqual({ message: 'storage fail' });
});
});
// ── ensureEventsFolder ───────────────────────────────────────────────────────
describe('ensureEventsFolder', () => {
it('returns existing Events folder if found', async () => {
const folder = { id: 'ef1', name: 'Events', type: 'folder' };
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.single = vi.fn(() => Promise.resolve({ data: folder, error: null }));
const sb = { from: vi.fn(() => chain) } as any;
const result = await ensureEventsFolder(sb, 'org-1', 'user-1');
expect(result).toEqual(folder);
});
it('creates Events folder when not found', async () => {
const newFolder = { id: 'ef2', name: 'Events', type: 'folder' };
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
let singleIdx = 0;
chain.single = vi.fn(() => {
singleIdx++;
if (singleIdx === 1) return Promise.resolve({ data: null, error: null }); // not found
return Promise.resolve({ data: newFolder, error: null }); // created
});
const sb = { from: vi.fn(() => chain) } as any;
const result = await ensureEventsFolder(sb, 'org-1', 'user-1');
expect(result).toEqual(newFolder);
});
});
// ── uploadFile ───────────────────────────────────────────────────────────────
describe('uploadFile', () => {
it('uploads file and creates document row', async () => {
const doc = { id: 'doc-1', name: 'photo.png', type: 'file' };
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.single = vi.fn(() => Promise.resolve({ data: doc, error: null }));
const sb = {
from: vi.fn(() => chain),
storage: {
from: vi.fn().mockReturnValue({
upload: vi.fn().mockResolvedValue({ error: null }),
getPublicUrl: vi.fn().mockReturnValue({ data: { publicUrl: 'https://example.com/photo.png' } }),
remove: vi.fn().mockResolvedValue({ error: null }),
}),
},
} as any;
const file = new File(['data'], 'photo.png', { type: 'image/png' });
const result = await uploadFile(sb, 'org-1', 'folder-1', 'user-1', file);
expect(result).toEqual(doc);
});
it('throws on upload error', async () => {
const sb = {
from: vi.fn(),
storage: {
from: vi.fn().mockReturnValue({
upload: vi.fn().mockResolvedValue({ error: { message: 'upload fail' } }),
}),
},
} as any;
const file = new File(['data'], 'test.txt', { type: 'text/plain' });
await expect(uploadFile(sb, 'org-1', null, 'user-1', file)).rejects.toEqual({ message: 'upload fail' });
});
it('cleans up storage on DB insert error', async () => {
const removeFn = vi.fn().mockResolvedValue({ error: null });
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.single = vi.fn(() => Promise.resolve({ data: null, error: { message: 'db fail' } }));
const sb = {
from: vi.fn(() => chain),
storage: {
from: vi.fn().mockReturnValue({
upload: vi.fn().mockResolvedValue({ error: null }),
getPublicUrl: vi.fn().mockReturnValue({ data: { publicUrl: 'https://example.com/f.txt' } }),
remove: removeFn,
}),
},
} as any;
const file = new File(['data'], 'test.txt', { type: 'text/plain' });
await expect(uploadFile(sb, 'org-1', null, 'user-1', file)).rejects.toEqual({ message: 'db fail' });
expect(removeFn).toHaveBeenCalled();
});
});
// ── createEventFolder ────────────────────────────────────────────────────────
describe('createEventFolder', () => {
it('returns existing event folder if found', async () => {
const eventsFolder = { id: 'ef1', name: 'Events' };
const eventFolder = { id: 'evf1', name: 'Conf', type: 'folder' };
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
let singleIdx = 0;
chain.single = vi.fn(() => {
singleIdx++;
if (singleIdx === 1) return Promise.resolve({ data: eventsFolder, error: null }); // ensureEventsFolder
if (singleIdx === 2) return Promise.resolve({ data: eventFolder, error: null }); // existing event folder
return Promise.resolve({ data: null, error: null });
});
const sb = { from: vi.fn(() => chain) } as any;
const result = await createEventFolder(sb, 'org-1', 'user-1', 'ev1', 'Conf');
expect(result).toEqual(eventFolder);
});
it('creates event folder when not found', async () => {
const eventsFolder = { id: 'ef1', name: 'Events' };
const newFolder = { id: 'evf2', name: 'Conf', type: 'folder' };
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
let singleIdx = 0;
chain.single = vi.fn(() => {
singleIdx++;
if (singleIdx === 1) return Promise.resolve({ data: eventsFolder, error: null }); // ensureEventsFolder
if (singleIdx === 2) return Promise.resolve({ data: null, error: null }); // not found
return Promise.resolve({ data: newFolder, error: null }); // created
});
const sb = { from: vi.fn(() => chain) } as any;
const result = await createEventFolder(sb, 'org-1', 'user-1', 'ev1', 'Conf');
expect(result).toEqual(newFolder);
});
});
// ── createDepartmentFolder ───────────────────────────────────────────────────
describe('createDepartmentFolder', () => {
it('returns existing dept folder if found', async () => {
const eventsFolder = { id: 'ef1', name: 'Events' };
const eventFolder = { id: 'evf1', name: 'Conf' };
const deptFolder = { id: 'df1', name: 'Logistics', type: 'folder' };
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
let singleIdx = 0;
chain.single = vi.fn(() => {
singleIdx++;
if (singleIdx === 1) return Promise.resolve({ data: eventsFolder, error: null }); // ensureEventsFolder
if (singleIdx === 2) return Promise.resolve({ data: eventFolder, error: null }); // createEventFolder existing
if (singleIdx === 3) return Promise.resolve({ data: deptFolder, error: null }); // existing dept folder
return Promise.resolve({ data: null, error: null });
});
const sb = { from: vi.fn(() => chain) } as any;
const result = await createDepartmentFolder(sb, 'org-1', 'user-1', 'ev1', 'Conf', 'd1', 'Logistics');
expect(result).toEqual(deptFolder);
});
it('creates dept folder when not found', async () => {
const eventsFolder = { id: 'ef1', name: 'Events' };
const eventFolder = { id: 'evf1', name: 'Conf' };
const newDeptFolder = { id: 'df2', name: 'Marketing', type: 'folder' };
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
let singleIdx = 0;
chain.single = vi.fn(() => {
singleIdx++;
if (singleIdx === 1) return Promise.resolve({ data: eventsFolder, error: null });
if (singleIdx === 2) return Promise.resolve({ data: eventFolder, error: null });
if (singleIdx === 3) return Promise.resolve({ data: null, error: null }); // not found
return Promise.resolve({ data: newDeptFolder, error: null }); // created
});
const sb = { from: vi.fn(() => chain) } as any;
const result = await createDepartmentFolder(sb, 'org-1', 'user-1', 'ev1', 'Conf', 'd1', 'Marketing');
expect(result).toEqual(newDeptFolder);
});
});
// ── ensureFinanceFolder ──────────────────────────────────────────────────────
describe('ensureFinanceFolder', () => {
it('returns existing Finance folder if found', async () => {
const eventsFolder = { id: 'ef1', name: 'Events' };
const eventFolder = { id: 'evf1', name: 'Conf' };
const financeFolder = { id: 'ff1', name: 'Finance', type: 'folder' };
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
let singleIdx = 0;
chain.single = vi.fn(() => {
singleIdx++;
if (singleIdx === 1) return Promise.resolve({ data: eventsFolder, error: null });
if (singleIdx === 2) return Promise.resolve({ data: eventFolder, error: null });
if (singleIdx === 3) return Promise.resolve({ data: financeFolder, error: null }); // existing
return Promise.resolve({ data: null, error: null });
});
const sb = { from: vi.fn(() => chain) } as any;
const result = await ensureFinanceFolder(sb, 'org-1', 'user-1', 'ev1', 'Conf');
expect(result).toEqual(financeFolder);
});
it('creates Finance folder when not found', async () => {
const eventsFolder = { id: 'ef1', name: 'Events' };
const eventFolder = { id: 'evf1', name: 'Conf' };
const newFinance = { id: 'ff2', name: 'Finance', type: 'folder' };
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
let singleIdx = 0;
chain.single = vi.fn(() => {
singleIdx++;
if (singleIdx === 1) return Promise.resolve({ data: eventsFolder, error: null });
if (singleIdx === 2) return Promise.resolve({ data: eventFolder, error: null });
if (singleIdx === 3) return Promise.resolve({ data: null, error: null }); // not found
return Promise.resolve({ data: newFinance, error: null }); // created
});
const sb = { from: vi.fn(() => chain) } as any;
const result = await ensureFinanceFolder(sb, 'org-1', 'user-1', 'ev1', 'Conf');
expect(result).toEqual(newFinance);
});
});
// ── ensureFinanceDeptFolder ──────────────────────────────────────────────────
describe('ensureFinanceDeptFolder', () => {
it('returns existing finance dept folder if found', async () => {
const eventsFolder = { id: 'ef1' };
const eventFolder = { id: 'evf1' };
const financeFolder = { id: 'ff1' };
const deptFolder = { id: 'fdf1', name: 'Marketing', type: 'folder' };
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
let singleIdx = 0;
chain.single = vi.fn(() => {
singleIdx++;
if (singleIdx === 1) return Promise.resolve({ data: eventsFolder, error: null });
if (singleIdx === 2) return Promise.resolve({ data: eventFolder, error: null });
if (singleIdx === 3) return Promise.resolve({ data: financeFolder, error: null });
if (singleIdx === 4) return Promise.resolve({ data: deptFolder, error: null }); // existing
return Promise.resolve({ data: null, error: null });
});
const sb = { from: vi.fn(() => chain) } as any;
const result = await ensureFinanceDeptFolder(sb, 'org-1', 'user-1', 'ev1', 'Conf', 'd1', 'Marketing');
expect(result).toEqual(deptFolder);
});
it('creates finance dept folder when not found', async () => {
const eventsFolder = { id: 'ef1' };
const eventFolder = { id: 'evf1' };
const financeFolder = { id: 'ff1' };
const newDeptFolder = { id: 'fdf2', name: 'Marketing', type: 'folder' };
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', 'is', 'limit', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
let singleIdx = 0;
chain.single = vi.fn(() => {
singleIdx++;
if (singleIdx === 1) return Promise.resolve({ data: eventsFolder, error: null });
if (singleIdx === 2) return Promise.resolve({ data: eventFolder, error: null });
if (singleIdx === 3) return Promise.resolve({ data: financeFolder, error: null });
if (singleIdx === 4) return Promise.resolve({ data: null, error: null }); // not found
return Promise.resolve({ data: newDeptFolder, error: null }); // created
});
const sb = { from: vi.fn(() => chain) } as any;
const result = await ensureFinanceDeptFolder(sb, 'org-1', 'user-1', 'ev1', 'Conf', 'd1', 'Marketing');
expect(result).toEqual(newDeptFolder);
});
});
// ── subscribeToDocuments ─────────────────────────────────────────────────────
describe('subscribeToDocuments', () => {
it('sets up realtime subscription', () => {
const channel: any = {};
channel.on = vi.fn(() => channel);
channel.subscribe = vi.fn(() => channel);
const sb = { channel: vi.fn(() => channel) } as any;
const result = subscribeToDocuments(sb, 'org-1', vi.fn(), vi.fn(), vi.fn());
expect(sb.channel).toHaveBeenCalledWith('documents:org-1');
expect(channel.on).toHaveBeenCalledTimes(3);
expect(channel.subscribe).toHaveBeenCalledOnce();
expect(result).toBe(channel);
});
});

View File

@@ -133,6 +133,420 @@ export async function copyDocument(
return data;
}
// ============================================================
// Auto-folder helpers for Events & Departments
// ============================================================
/**
* Find or create the org-level "Events" root folder.
* Uses a name + null parent_id lookup first to avoid duplicates.
*/
export async function ensureEventsFolder(
supabase: SupabaseClient<Database>,
orgId: string,
userId: string
): Promise<Document> {
// Look for existing "Events" folder at root level
const { data: existing } = await supabase
.from('documents')
.select('*')
.eq('org_id', orgId)
.eq('type', 'folder')
.eq('name', 'Events')
.is('parent_id', null)
.limit(1)
.single();
if (existing) return existing;
// Create it
log.info('Creating Events root folder', { data: { orgId } });
return createDocument(supabase, orgId, 'Events', 'folder', null, userId);
}
/**
* Create a folder for a specific event inside the "Events" root folder.
* Also stores event_id on the document row for reliable lookup.
*/
export async function createEventFolder(
supabase: SupabaseClient<Database>,
orgId: string,
userId: string,
eventId: string,
eventName: string
): Promise<Document> {
const eventsFolder = await ensureEventsFolder(supabase, orgId, userId);
// Check if folder already exists for this event
const { data: existing } = await (supabase as any)
.from('documents')
.select('*')
.eq('org_id', orgId)
.eq('type', 'folder')
.eq('event_id', eventId)
.eq('parent_id', eventsFolder.id)
.limit(1)
.single();
if (existing) return existing;
log.info('Creating event folder', { data: { orgId, eventId, eventName } });
const { data, error } = await (supabase as any)
.from('documents')
.insert({
org_id: orgId,
name: eventName,
type: 'folder',
parent_id: eventsFolder.id,
created_by: userId,
content: null,
event_id: eventId,
})
.select()
.single();
if (error) {
log.error('createEventFolder failed', { error, data: { orgId, eventId } });
throw error;
}
return data;
}
/**
* Create a folder for a department inside its event folder.
* Stores department_id on the document row for reliable lookup.
*/
export async function createDepartmentFolder(
supabase: SupabaseClient<Database>,
orgId: string,
userId: string,
eventId: string,
eventName: string,
departmentId: string,
departmentName: string
): Promise<Document> {
const eventFolder = await createEventFolder(supabase, orgId, userId, eventId, eventName);
// Check if folder already exists for this department
const { data: existing } = await (supabase as any)
.from('documents')
.select('*')
.eq('org_id', orgId)
.eq('type', 'folder')
.eq('department_id', departmentId)
.eq('parent_id', eventFolder.id)
.limit(1)
.single();
if (existing) return existing;
log.info('Creating department folder', { data: { orgId, eventId, departmentId, departmentName } });
const { data, error } = await (supabase as any)
.from('documents')
.insert({
org_id: orgId,
name: departmentName,
type: 'folder',
parent_id: eventFolder.id,
created_by: userId,
content: null,
event_id: eventId,
department_id: departmentId,
})
.select()
.single();
if (error) {
log.error('createDepartmentFolder failed', { error, data: { orgId, departmentId } });
throw error;
}
return data;
}
/**
* Fetch all documents inside a specific folder.
*/
export async function fetchFolderContents(
supabase: SupabaseClient<Database>,
folderId: string
): Promise<Document[]> {
const { data, error } = await supabase
.from('documents')
.select('*')
.eq('parent_id', folderId)
.order('type', { ascending: false }) // folders first
.order('name');
if (error) {
log.error('fetchFolderContents failed', { error, data: { folderId } });
throw error;
}
return data ?? [];
}
/**
* Find the department folder by department_id. Returns null if not found.
*/
export async function findDepartmentFolder(
supabase: SupabaseClient<Database>,
departmentId: string
): Promise<Document | null> {
const { data } = await (supabase as any)
.from('documents')
.select('*')
.eq('type', 'folder')
.eq('department_id', departmentId)
.limit(1)
.single();
return data ?? null;
}
// ============================================================
// Finance folder helpers
// ============================================================
/**
* Find or create a "Finance" folder inside the event folder.
* Used as the root for all department finance documents (invoices, receipts).
*/
export async function ensureFinanceFolder(
supabase: SupabaseClient<Database>,
orgId: string,
userId: string,
eventId: string,
eventName: string
): Promise<Document> {
const eventFolder = await createEventFolder(supabase, orgId, userId, eventId, eventName);
// Look for existing "Finance" folder inside event folder
const { data: existing } = await supabase
.from('documents')
.select('*')
.eq('org_id', orgId)
.eq('type', 'folder')
.eq('name', 'Finance')
.eq('parent_id', eventFolder.id)
.limit(1)
.single();
if (existing) return existing;
log.info('Creating Finance folder', { data: { orgId, eventId } });
const { data, error } = await (supabase as any)
.from('documents')
.insert({
org_id: orgId,
name: 'Finance',
type: 'folder',
parent_id: eventFolder.id,
created_by: userId,
content: null,
event_id: eventId,
})
.select()
.single();
if (error) {
log.error('ensureFinanceFolder failed', { error, data: { orgId, eventId } });
throw error;
}
return data;
}
/**
* Find or create a department subfolder inside the Finance folder.
* e.g. Events > [Event] > Finance > [Department Name]
*/
export async function ensureFinanceDeptFolder(
supabase: SupabaseClient<Database>,
orgId: string,
userId: string,
eventId: string,
eventName: string,
departmentId: string,
departmentName: string
): Promise<Document> {
const financeFolder = await ensureFinanceFolder(supabase, orgId, userId, eventId, eventName);
// Look for existing dept subfolder inside Finance folder
const { data: existing } = await (supabase as any)
.from('documents')
.select('*')
.eq('org_id', orgId)
.eq('type', 'folder')
.eq('department_id', departmentId)
.eq('parent_id', financeFolder.id)
.limit(1)
.single();
if (existing) return existing;
log.info('Creating finance dept folder', { data: { orgId, departmentId, departmentName } });
const { data, error } = await (supabase as any)
.from('documents')
.insert({
org_id: orgId,
name: departmentName,
type: 'folder',
parent_id: financeFolder.id,
created_by: userId,
content: null,
event_id: eventId,
department_id: departmentId,
})
.select()
.single();
if (error) {
log.error('ensureFinanceDeptFolder failed', { error, data: { orgId, departmentId } });
throw error;
}
return data;
}
/**
* Find the Finance folder for an event. Returns null if not found.
*/
export async function findFinanceFolder(
supabase: SupabaseClient<Database>,
eventId: string
): Promise<Document | null> {
// Find the event folder first
const { data: eventFolder } = await (supabase as any)
.from('documents')
.select('id')
.eq('type', 'folder')
.eq('event_id', eventId)
.is('department_id', null)
.limit(1)
.single();
if (!eventFolder) return null;
const { data } = await (supabase as any)
.from('documents')
.select('*')
.eq('type', 'folder')
.eq('name', 'Finance')
.eq('parent_id', eventFolder.id)
.limit(1)
.single();
return data ?? null;
}
// ============================================================
// File Upload helpers
// ============================================================
export interface FileMetadata {
storage_path: string;
file_name: string;
file_size: number;
mime_type: string;
public_url: string;
}
/**
* Upload a file to Supabase Storage and create a document row of type "file".
* Files are stored under: {orgId}/{parentId}/{timestamp}_{filename}
*/
export async function uploadFile(
supabase: SupabaseClient<Database>,
orgId: string,
parentId: string | null,
userId: string,
file: File
): Promise<Document> {
const timestamp = Date.now();
const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_');
const storagePath = `${orgId}/${parentId ?? 'root'}/${timestamp}_${safeName}`;
log.info('Uploading file', { data: { orgId, fileName: file.name, size: file.size, storagePath } });
const { error: uploadError } = await supabase.storage
.from('files')
.upload(storagePath, file, {
cacheControl: '3600',
upsert: false,
});
if (uploadError) {
log.error('File upload failed', { error: uploadError, data: { storagePath } });
throw uploadError;
}
// Get public URL
const { data: urlData } = supabase.storage.from('files').getPublicUrl(storagePath);
const metadata: FileMetadata = {
storage_path: storagePath,
file_name: file.name,
file_size: file.size,
mime_type: file.type || 'application/octet-stream',
public_url: urlData.publicUrl,
};
// Create document row with type "file"
const { data, error } = await supabase
.from('documents')
.insert({
org_id: orgId,
name: file.name,
type: 'file',
parent_id: parentId,
created_by: userId,
content: metadata as unknown as import('$lib/supabase/types').Json,
})
.select()
.single();
if (error) {
// Clean up storage on DB failure
await supabase.storage.from('files').remove([storagePath]);
log.error('File document creation failed', { error, data: { storagePath } });
throw error;
}
log.info('File uploaded successfully', { data: { id: data.id, name: file.name, size: file.size } });
return data;
}
/**
* Delete a file from Storage when its document row is deleted.
*/
export async function deleteFileFromStorage(
supabase: SupabaseClient<Database>,
storagePath: string
): Promise<void> {
const { error } = await supabase.storage.from('files').remove([storagePath]);
if (error) {
log.error('deleteFileFromStorage failed', { error, data: { storagePath } });
throw error;
}
}
/**
* Get the file metadata from a document's content field.
*/
export function getFileMetadata(doc: Document): FileMetadata | null {
if (doc.type !== 'file' || !doc.content) return null;
const content = doc.content as unknown as FileMetadata;
if (!content.storage_path) return null;
return content;
}
/**
* Format file size in human-readable form.
*/
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
export function subscribeToDocuments(
supabase: SupabaseClient<Database>,
orgId: string,

View File

@@ -0,0 +1,206 @@
import { describe, it, expect, vi } from 'vitest';
import {
fetchTaskColumns,
createTaskColumn,
renameTaskColumn,
deleteTaskColumn,
createTask,
updateTask,
deleteTask,
moveTask,
subscribeToEventTasks,
} from './event-tasks';
// ── Supabase mock builder ────────────────────────────────────────────────────
function mockChain(resolvedValue: { data: any; error: any; count?: number }) {
const chain: any = {};
const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'in', 'order', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.single = vi.fn(() => Promise.resolve(resolvedValue));
chain.then = (resolve: any) => resolve(resolvedValue);
// For count queries
if (resolvedValue.count !== undefined) {
chain.count = resolvedValue.count;
}
return chain;
}
function mockSupabase(resolvedValue: { data: any; error: any; count?: number }) {
const chain = mockChain(resolvedValue);
return { from: vi.fn(() => chain), _chain: chain } as any;
}
// ── Columns ──────────────────────────────────────────────────────────────────
describe('fetchTaskColumns', () => {
it('returns columns with tasks grouped', async () => {
const columns = [{ id: 'c1', name: 'To Do', event_id: 'e1', position: 0 }];
const tasks = [{ id: 't1', title: 'Task 1', event_id: 'e1', column_id: 'c1', position: 0 }];
const chain: any = {};
const methods = ['from', 'select', 'eq', 'order'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
let callIdx = 0;
chain.then = (resolve: any) => {
callIdx++;
if (callIdx === 1) return resolve({ data: columns, error: null });
return resolve({ data: tasks, error: null });
};
const sb = { from: vi.fn(() => chain) } as any;
const result = await fetchTaskColumns(sb, 'e1');
expect(result).toHaveLength(1);
expect(result[0].cards).toHaveLength(1);
expect(result[0].cards[0].title).toBe('Task 1');
});
it('throws when column fetch fails', async () => {
const chain: any = {};
const methods = ['from', 'select', 'eq', 'order'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.then = (resolve: any) => resolve({ data: null, error: { message: 'col fail' } });
const sb = { from: vi.fn(() => chain) } as any;
await expect(fetchTaskColumns(sb, 'e1')).rejects.toEqual({ message: 'col fail' });
});
});
describe('createTaskColumn', () => {
it('creates column with explicit position', async () => {
const col = { id: 'c1', name: 'Done', event_id: 'e1', position: 2 };
const sb = mockSupabase({ data: col, error: null });
expect(await createTaskColumn(sb, 'e1', 'Done', 2)).toEqual(col);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(createTaskColumn(sb, 'e1', 'X', 0)).rejects.toEqual({ message: 'fail' });
});
});
describe('renameTaskColumn', () => {
it('renames without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(renameTaskColumn(sb, 'c1', 'Renamed')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(renameTaskColumn(sb, 'c1', 'X')).rejects.toEqual({ message: 'fail' });
});
});
describe('deleteTaskColumn', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteTaskColumn(sb, 'c1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteTaskColumn(sb, 'c1')).rejects.toEqual({ message: 'fail' });
});
});
// ── Tasks ────────────────────────────────────────────────────────────────────
describe('createTask', () => {
it('creates task', async () => {
const task = { id: 't1', title: 'New Task', event_id: 'e1', column_id: 'c1', position: 0 };
// Need two calls: count query + insert
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', 'order', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
let callIdx = 0;
chain.then = (resolve: any) => {
callIdx++;
if (callIdx === 1) return resolve({ count: 0, error: null });
return resolve({ data: task, error: null });
};
chain.single = vi.fn(() => {
return Promise.resolve({ data: task, error: null });
});
const sb = { from: vi.fn(() => chain) } as any;
const result = await createTask(sb, 'e1', 'c1', 'New Task', 'user1');
expect(result).toEqual(task);
});
});
describe('updateTask', () => {
it('updates without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(updateTask(sb, 't1', { title: 'Updated' })).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateTask(sb, 't1', { title: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('deleteTask', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteTask(sb, 't1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteTask(sb, 't1')).rejects.toEqual({ message: 'fail' });
});
});
describe('moveTask', () => {
it('reorders tasks in target column', async () => {
const colTasks = [
{ id: 't1', position: 0 },
{ id: 't2', position: 1 },
];
const chain: any = {};
const methods = ['from', 'select', 'update', 'eq', 'order'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.then = (resolve: any) => resolve({ data: colTasks, error: null });
const sb = { from: vi.fn(() => chain) } as any;
await expect(moveTask(sb, 't3', 'c1', 1)).resolves.toBeUndefined();
});
it('throws when fetch fails', async () => {
const chain: any = {};
const methods = ['from', 'select', 'eq', 'order'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.then = (resolve: any) => resolve({ data: null, error: { message: 'fetch fail' } });
const sb = { from: vi.fn(() => chain) } as any;
await expect(moveTask(sb, 't1', 'c1', 0)).rejects.toEqual({ message: 'fetch fail' });
});
});
describe('subscribeToEventTasks', () => {
it('sets up realtime subscription and returns channel', () => {
const channel: any = {};
channel.on = vi.fn(() => channel);
channel.subscribe = vi.fn(() => channel);
const sb = { channel: vi.fn(() => channel) } as any;
const result = subscribeToEventTasks(sb, 'e1', ['c1'], vi.fn(), vi.fn());
expect(sb.channel).toHaveBeenCalledWith('event-tasks:e1');
expect(channel.on).toHaveBeenCalledTimes(2);
expect(channel.subscribe).toHaveBeenCalledOnce();
expect(result).toBe(channel);
});
});

View File

@@ -1,48 +1,410 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import {
fetchEvents,
fetchEvent,
fetchEventBySlug,
createEvent,
updateEvent,
deleteEvent,
fetchEventMembers,
addEventMember,
removeEventMember,
fetchEventRoles,
createEventRole,
updateEventRole,
deleteEventRole,
fetchEventDepartments,
createEventDepartment,
updateEventDepartment,
updateDepartmentPlannedBudget,
deleteEventDepartment,
assignMemberDepartment,
unassignMemberDepartment,
} from './events';
// Test the slugify logic (extracted inline since it's not exported)
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 60) || 'event';
// ── Supabase mock builder ────────────────────────────────────────────────────
function mockChain(resolvedValue: { data: any; error: any }) {
const chain: any = {};
const methods = ['from', 'select', 'insert', 'upsert', 'update', 'delete', 'eq', 'in', 'like', 'order', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.single = vi.fn(() => Promise.resolve(resolvedValue));
chain.then = (resolve: any) => resolve(resolvedValue);
return chain;
}
describe('events API - slugify', () => {
it('converts simple name to slug', () => {
expect(slugify('Summer Conference')).toBe('summer-conference');
function mockSupabase(resolvedValue: { data: any; error: any }) {
const chain = mockChain(resolvedValue);
return { from: vi.fn(() => chain), _chain: chain } as any;
}
// ── fetchEvents ──────────────────────────────────────────────────────────────
describe('fetchEvents', () => {
it('returns events with member counts', async () => {
const raw = [{ id: 'e1', name: 'Conf', event_members: [{ count: 5 }] }];
const sb = mockSupabase({ data: raw, error: null });
const result = await fetchEvents(sb, 'o1');
expect(result).toHaveLength(1);
expect(result[0].member_count).toBe(5);
});
it('handles special characters', () => {
expect(slugify('Music & Arts Festival 2026!')).toBe('music-arts-festival-2026');
it('returns empty array when null', async () => {
const sb = mockSupabase({ data: null, error: null });
expect(await fetchEvents(sb, 'o1')).toEqual([]);
});
it('collapses multiple spaces and dashes', () => {
expect(slugify('My Big Event')).toBe('my-big-event');
});
it('trims leading/trailing dashes', () => {
expect(slugify('--hello--')).toBe('hello');
});
it('truncates to 60 characters', () => {
const longName = 'A'.repeat(100);
expect(slugify(longName).length).toBeLessThanOrEqual(60);
});
it('returns "event" for empty string', () => {
expect(slugify('')).toBe('event');
});
it('handles unicode characters', () => {
const result = slugify('Ürituse Korraldamine');
expect(result).toBe('rituse-korraldamine');
});
it('handles numbers', () => {
expect(slugify('Event 2026 Q1')).toBe('event-2026-q1');
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchEvents(sb, 'o1')).rejects.toEqual({ message: 'fail' });
});
});
// ── fetchEvent ───────────────────────────────────────────────────────────────
describe('fetchEvent', () => {
it('returns event by id', async () => {
const event = { id: 'e1', name: 'Conf' };
const sb = mockSupabase({ data: event, error: null });
expect(await fetchEvent(sb, 'e1')).toEqual(event);
});
it('returns null when not found', async () => {
const sb = mockSupabase({ data: null, error: { code: 'PGRST116', message: 'not found' } });
expect(await fetchEvent(sb, 'e1')).toBeNull();
});
it('throws on other errors', async () => {
const sb = mockSupabase({ data: null, error: { code: '42000', message: 'fail' } });
await expect(fetchEvent(sb, 'e1')).rejects.toEqual({ code: '42000', message: 'fail' });
});
});
// ── fetchEventBySlug ─────────────────────────────────────────────────────────
describe('fetchEventBySlug', () => {
it('returns event by slug', async () => {
const event = { id: 'e1', slug: 'conf' };
const sb = mockSupabase({ data: event, error: null });
expect(await fetchEventBySlug(sb, 'o1', 'conf')).toEqual(event);
});
it('returns null when not found', async () => {
const sb = mockSupabase({ data: null, error: { code: 'PGRST116', message: 'not found' } });
expect(await fetchEventBySlug(sb, 'o1', 'nope')).toBeNull();
});
it('throws on other errors', async () => {
const sb = mockSupabase({ data: null, error: { code: '42000', message: 'fail' } });
await expect(fetchEventBySlug(sb, 'o1', 'x')).rejects.toEqual({ code: '42000', message: 'fail' });
});
});
// ── createEvent ──────────────────────────────────────────────────────────────
describe('createEvent', () => {
it('creates event with unique slug', async () => {
const event = { id: 'e1', name: 'Conf', slug: 'conf' };
// Two calls: slug check + insert
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', 'like', 'order', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
let callIdx = 0;
chain.then = (resolve: any) => {
callIdx++;
if (callIdx === 1) return resolve({ data: [], error: null }); // no existing slugs
return resolve({ data: event, error: null });
};
chain.single = vi.fn(() => Promise.resolve({ data: event, error: null }));
const sb = { from: vi.fn(() => chain) } as any;
const result = await createEvent(sb, 'o1', 'u1', { name: 'Conf' });
expect(result).toEqual(event);
});
it('throws on insert error', async () => {
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', 'like', 'order', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
let callIdx = 0;
chain.then = (resolve: any) => {
callIdx++;
if (callIdx === 1) return resolve({ data: [], error: null });
return resolve({ data: null, error: { message: 'fail' } });
};
chain.single = vi.fn(() => Promise.resolve({ data: null, error: { message: 'fail' } }));
const sb = { from: vi.fn(() => chain) } as any;
await expect(createEvent(sb, 'o1', 'u1', { name: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
// ── updateEvent ──────────────────────────────────────────────────────────────
describe('updateEvent', () => {
it('updates and returns event', async () => {
const event = { id: 'e1', name: 'Updated' };
const sb = mockSupabase({ data: event, error: null });
expect(await updateEvent(sb, 'e1', { name: 'Updated' })).toEqual(event);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateEvent(sb, 'e1', { name: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
// ── deleteEvent ──────────────────────────────────────────────────────────────
describe('deleteEvent', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteEvent(sb, 'e1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteEvent(sb, 'e1')).rejects.toEqual({ message: 'fail' });
});
});
// ── fetchEventMembers ────────────────────────────────────────────────────────
describe('fetchEventMembers', () => {
it('returns empty array when no members', async () => {
const sb = mockSupabase({ data: [], error: null });
expect(await fetchEventMembers(sb, 'e1')).toEqual([]);
});
it('returns members with profiles, roles, and departments', async () => {
const members = [{ id: 'm1', event_id: 'e1', user_id: 'u1', role_id: 'r1' }];
const profiles = [{ id: 'u1', email: 'a@b.com', full_name: 'Alice' }];
const roles = [{ id: 'r1', event_id: 'e1', name: 'Lead' }];
const memberDepts = [{ event_member_id: 'm1', department_id: 'd1' }];
const departments = [{ id: 'd1', event_id: 'e1', name: 'Logistics' }];
const chain: any = {};
const methods = ['from', 'select', 'eq', 'in', 'order'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
let callIdx = 0;
chain.then = (resolve: any) => {
callIdx++;
switch (callIdx) {
case 1: return resolve({ data: members, error: null }); // members
case 2: return resolve({ data: profiles, error: null }); // profiles
case 3: return resolve({ data: roles, error: null }); // roles
case 4: return resolve({ data: memberDepts, error: null }); // member-depts
case 5: return resolve({ data: departments, error: null }); // departments
default: return resolve({ data: [], error: null });
}
};
const sb = { from: vi.fn(() => chain) } as any;
const result = await fetchEventMembers(sb, 'e1');
expect(result).toHaveLength(1);
expect(result[0].profile?.full_name).toBe('Alice');
expect(result[0].event_role?.name).toBe('Lead');
expect(result[0].departments).toHaveLength(1);
expect(result[0].departments[0].name).toBe('Logistics');
});
it('throws on error', async () => {
const chain: any = {};
const methods = ['from', 'select', 'eq', 'in', 'order'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.then = (resolve: any) => resolve({ data: null, error: { message: 'fail' } });
const sb = { from: vi.fn(() => chain) } as any;
await expect(fetchEventMembers(sb, 'e1')).rejects.toEqual({ message: 'fail' });
});
});
// ── addEventMember ───────────────────────────────────────────────────────────
describe('addEventMember', () => {
it('adds member', async () => {
const member = { id: 'm1', event_id: 'e1', user_id: 'u1', role: 'member' };
const sb = mockSupabase({ data: member, error: null });
expect(await addEventMember(sb, 'e1', 'u1')).toEqual(member);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(addEventMember(sb, 'e1', 'u1')).rejects.toEqual({ message: 'fail' });
});
});
// ── removeEventMember ────────────────────────────────────────────────────────
describe('removeEventMember', () => {
it('removes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(removeEventMember(sb, 'e1', 'u1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(removeEventMember(sb, 'e1', 'u1')).rejects.toEqual({ message: 'fail' });
});
});
// ── Event Roles ──────────────────────────────────────────────────────────────
describe('fetchEventRoles', () => {
it('returns roles', async () => {
const roles = [{ id: 'r1', name: 'Lead' }];
const sb = mockSupabase({ data: roles, error: null });
expect(await fetchEventRoles(sb, 'e1')).toEqual(roles);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchEventRoles(sb, 'e1')).rejects.toEqual({ message: 'fail' });
});
});
describe('createEventRole', () => {
it('creates role', async () => {
const role = { id: 'r1', name: 'Lead', color: '#6366f1' };
const sb = mockSupabase({ data: role, error: null });
expect(await createEventRole(sb, 'e1', { name: 'Lead' })).toEqual(role);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(createEventRole(sb, 'e1', { name: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('updateEventRole', () => {
it('updates role', async () => {
const role = { id: 'r1', name: 'Updated' };
const sb = mockSupabase({ data: role, error: null });
expect(await updateEventRole(sb, 'r1', { name: 'Updated' })).toEqual(role);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateEventRole(sb, 'r1', { name: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('deleteEventRole', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteEventRole(sb, 'r1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteEventRole(sb, 'r1')).rejects.toEqual({ message: 'fail' });
});
});
// ── Event Departments ────────────────────────────────────────────────────────
describe('fetchEventDepartments', () => {
it('returns departments', async () => {
const depts = [{ id: 'd1', name: 'Logistics' }];
const sb = mockSupabase({ data: depts, error: null });
expect(await fetchEventDepartments(sb, 'e1')).toEqual(depts);
});
it('returns empty array when null', async () => {
const sb = mockSupabase({ data: null, error: null });
expect(await fetchEventDepartments(sb, 'e1')).toEqual([]);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchEventDepartments(sb, 'e1')).rejects.toEqual({ message: 'fail' });
});
});
describe('createEventDepartment', () => {
it('creates department', async () => {
const dept = { id: 'd1', name: 'Marketing', color: '#00A3E0' };
const sb = mockSupabase({ data: dept, error: null });
expect(await createEventDepartment(sb, 'e1', { name: 'Marketing' })).toEqual(dept);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(createEventDepartment(sb, 'e1', { name: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('updateEventDepartment', () => {
it('updates department', async () => {
const dept = { id: 'd1', name: 'Updated' };
const sb = mockSupabase({ data: dept, error: null });
expect(await updateEventDepartment(sb, 'd1', { name: 'Updated' })).toEqual(dept);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateEventDepartment(sb, 'd1', { name: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('updateDepartmentPlannedBudget', () => {
it('updates planned budget', async () => {
const dept = { id: 'd1', planned_budget: 5000 };
const sb = mockSupabase({ data: dept, error: null });
expect(await updateDepartmentPlannedBudget(sb, 'd1', 5000)).toEqual(dept);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateDepartmentPlannedBudget(sb, 'd1', 0)).rejects.toEqual({ message: 'fail' });
});
});
describe('deleteEventDepartment', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteEventDepartment(sb, 'd1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteEventDepartment(sb, 'd1')).rejects.toEqual({ message: 'fail' });
});
});
// ── Member-Department Assignments ────────────────────────────────────────────
describe('assignMemberDepartment', () => {
it('assigns member to department', async () => {
const md = { id: 'md1', event_member_id: 'm1', department_id: 'd1' };
const sb = mockSupabase({ data: md, error: null });
expect(await assignMemberDepartment(sb, 'm1', 'd1')).toEqual(md);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(assignMemberDepartment(sb, 'm1', 'd1')).rejects.toEqual({ message: 'fail' });
});
});
describe('unassignMemberDepartment', () => {
it('unassigns without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(unassignMemberDepartment(sb, 'm1', 'd1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(unassignMemberDepartment(sb, 'm1', 'd1')).rejects.toEqual({ message: 'fail' });
});
});

View File

@@ -48,6 +48,7 @@ export interface EventDepartment {
name: string;
color: string;
description: string | null;
planned_budget: number;
sort_order: number;
created_at: string | null;
}
@@ -492,6 +493,25 @@ export async function updateEventDepartment(
return data as unknown as EventDepartment;
}
export async function updateDepartmentPlannedBudget(
supabase: SupabaseClient<Database>,
deptId: string,
plannedBudget: number
): Promise<EventDepartment> {
const { data, error } = await (supabase as any)
.from('event_departments')
.update({ planned_budget: plannedBudget })
.eq('id', deptId)
.select()
.single();
if (error) {
log.error('updateDepartmentPlannedBudget failed', { error, data: { deptId, plannedBudget } });
throw error;
}
return data as unknown as EventDepartment;
}
export async function deleteEventDepartment(
supabase: SupabaseClient<Database>,
deptId: string

View File

@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest';
import { getServiceAccountEmail } from './google-calendar-push';
// ── getServiceAccountEmail (pure function, no network) ───────────────────────
describe('getServiceAccountEmail', () => {
it('extracts email from valid JSON key', () => {
const key = JSON.stringify({
client_email: 'test@project.iam.gserviceaccount.com',
private_key: 'fake-key',
});
expect(getServiceAccountEmail(key)).toBe('test@project.iam.gserviceaccount.com');
});
it('extracts email from base64-encoded JSON key', () => {
const json = JSON.stringify({
client_email: 'b64@project.iam.gserviceaccount.com',
private_key: 'fake-key',
});
const b64 = Buffer.from(json).toString('base64');
expect(getServiceAccountEmail(b64)).toBe('b64@project.iam.gserviceaccount.com');
});
it('returns null for invalid key', () => {
expect(getServiceAccountEmail('not-json-or-base64')).toBeNull();
});
it('returns null for empty string', () => {
expect(getServiceAccountEmail('')).toBeNull();
});
});

View File

@@ -75,7 +75,7 @@ export function getServiceAccountEmail(keyJson: string): string | null {
/**
* Fetch events from a Google Calendar using the service account.
* No need for the calendar to be public just shared with the service account.
* No need for the calendar to be public - just shared with the service account.
*/
export async function fetchCalendarEventsViaServiceAccount(
keyJson: string,
@@ -207,7 +207,7 @@ export async function deleteGoogleEvent(
}
);
// 410 Gone means already deleted treat as success
// 410 Gone means already deleted - treat as success
if (!response.ok && response.status !== 410) {
const errorText = await response.text();
log.error('Failed to delete Google Calendar event', { error: errorText, data: { calendarId, googleEventId } });

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { extractCalendarId, getCalendarSubscribeUrl } from './google-calendar';
import { describe, it, expect, vi } from 'vitest';
import { extractCalendarId, getCalendarSubscribeUrl, fetchPublicCalendarEvents } from './google-calendar';
describe('extractCalendarId', () => {
it('returns null for empty input', () => {
@@ -59,3 +59,55 @@ describe('getCalendarSubscribeUrl', () => {
expect(extractCalendarId(url)).toBe(calId);
});
});
// ── fetchPublicCalendarEvents ────────────────────────────────────────────────
describe('fetchPublicCalendarEvents', () => {
it('returns events on success', async () => {
const events = [{ id: 'e1', summary: 'Meeting' }];
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ items: events }),
});
vi.stubGlobal('fetch', mockFetch);
const result = await fetchPublicCalendarEvents(
'cal@gmail.com', 'api-key',
new Date('2024-01-01'), new Date('2024-01-31')
);
expect(result).toEqual(events);
expect(mockFetch).toHaveBeenCalledOnce();
vi.unstubAllGlobals();
});
it('returns empty array when no items', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({}),
}));
const result = await fetchPublicCalendarEvents(
'cal@gmail.com', 'key',
new Date(), new Date()
);
expect(result).toEqual([]);
vi.unstubAllGlobals();
});
it('throws on non-ok response', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
status: 403,
text: () => Promise.resolve('Forbidden'),
}));
await expect(fetchPublicCalendarEvents(
'cal@gmail.com', 'key',
new Date(), new Date()
)).rejects.toThrow('Failed to fetch calendar events (403)');
vi.unstubAllGlobals();
});
});

326
src/lib/api/kanban.test.ts Normal file
View File

@@ -0,0 +1,326 @@
import { describe, it, expect, vi } from 'vitest';
import {
fetchBoards,
fetchBoardWithColumns,
createBoard,
updateBoard,
deleteBoard,
createColumn,
updateColumn,
deleteColumn,
createCard,
updateCard,
deleteCard,
moveCard,
subscribeToBoard,
} from './kanban';
// ── Supabase mock builder ────────────────────────────────────────────────────
function mockChain(resolvedValue: { data: any; error: any }) {
const chain: any = {};
const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'in', 'order', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.single = vi.fn(() => Promise.resolve(resolvedValue));
chain.then = (resolve: any) => resolve(resolvedValue);
return chain;
}
function mockSupabase(resolvedValue: { data: any; error: any }) {
const chain = mockChain(resolvedValue);
return { from: vi.fn(() => chain), _chain: chain } as any;
}
// ── Boards ───────────────────────────────────────────────────────────────────
describe('fetchBoards', () => {
it('returns boards for an org', async () => {
const boards = [{ id: 'b1', name: 'Board 1', org_id: 'o1' }];
const sb = mockSupabase({ data: boards, error: null });
expect(await fetchBoards(sb, 'o1')).toEqual(boards);
});
it('returns empty array when null', async () => {
const sb = mockSupabase({ data: null, error: null });
expect(await fetchBoards(sb, 'o1')).toEqual([]);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchBoards(sb, 'o1')).rejects.toEqual({ message: 'fail' });
});
});
describe('fetchBoardWithColumns', () => {
it('returns board with columns and cards', async () => {
const board = { id: 'b1', name: 'Board', org_id: 'o1' };
const columns = [{ id: 'c1', board_id: 'b1', name: 'To Do', position: 0 }];
const cards = [{ id: 'k1', column_id: 'c1', title: 'Task', position: 0, assignee_id: null }];
const chain: any = {};
const methods = ['from', 'select', 'eq', 'in', 'order', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
// Track calls: board.single, columns.order (thenable), cards.order (thenable), then tags/checklists/profiles
let singleIdx = 0;
chain.single = vi.fn(() => {
singleIdx++;
if (singleIdx === 1) return Promise.resolve({ data: board, error: null });
return Promise.resolve({ data: null, error: null });
});
let thenIdx = 0;
chain.then = (resolve: any) => {
thenIdx++;
if (thenIdx === 1) return resolve({ data: columns, error: null }); // columns
if (thenIdx === 2) return resolve({ data: cards, error: null }); // cards
return resolve({ data: [], error: null }); // tags, checklists, profiles
};
const sb = { from: vi.fn(() => chain) } as any;
const result = await fetchBoardWithColumns(sb, 'b1');
expect(result).not.toBeNull();
expect(result!.id).toBe('b1');
expect(result!.columns).toHaveLength(1);
expect(result!.columns[0].cards).toHaveLength(1);
});
it('returns board with empty columns when no columns exist', async () => {
const board = { id: 'b1', name: 'Board', org_id: 'o1' };
const chain: any = {};
const methods = ['from', 'select', 'eq', 'in', 'order', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
let singleIdx = 0;
chain.single = vi.fn(() => {
singleIdx++;
if (singleIdx === 1) return Promise.resolve({ data: board, error: null });
return Promise.resolve({ data: null, error: null });
});
chain.then = (resolve: any) => resolve({ data: [], error: null }); // empty columns
const sb = { from: vi.fn(() => chain) } as any;
const result = await fetchBoardWithColumns(sb, 'b1');
expect(result).not.toBeNull();
expect(result!.columns).toEqual([]);
});
it('returns null when board not found', async () => {
const chain: any = {};
const methods = ['from', 'select', 'eq', 'in', 'order', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.single = vi.fn(() => Promise.resolve({ data: null, error: null }));
chain.then = (resolve: any) => resolve({ data: [], error: null });
const sb = { from: vi.fn(() => chain) } as any;
const result = await fetchBoardWithColumns(sb, 'b1');
expect(result).toBeNull();
});
it('throws when board fetch fails', async () => {
const chain: any = {};
const methods = ['from', 'select', 'eq', 'order', 'single', 'in'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.single = vi.fn(() => {
return Promise.resolve({ data: null, error: { message: 'board fail' } });
});
chain.then = (resolve: any) => resolve({ data: [], error: null });
const sb = { from: vi.fn(() => chain) } as any;
await expect(fetchBoardWithColumns(sb, 'b1')).rejects.toEqual({ message: 'board fail' });
});
it('throws when columns fetch fails', async () => {
const board = { id: 'b1', name: 'Board' };
const chain: any = {};
const methods = ['from', 'select', 'eq', 'in', 'order', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.single = vi.fn(() => Promise.resolve({ data: board, error: null }));
chain.then = (resolve: any) => resolve({ data: null, error: { message: 'col fail' } });
const sb = { from: vi.fn(() => chain) } as any;
await expect(fetchBoardWithColumns(sb, 'b1')).rejects.toEqual({ message: 'col fail' });
});
});
describe('subscribeToBoard', () => {
it('sets up realtime subscription and returns channel', () => {
const channel: any = {};
channel.on = vi.fn(() => channel);
channel.subscribe = vi.fn(() => channel);
const sb = { channel: vi.fn(() => channel) } as any;
const result = subscribeToBoard(sb, 'b1', ['c1'], vi.fn(), vi.fn());
expect(sb.channel).toHaveBeenCalledWith('kanban:b1');
expect(channel.on).toHaveBeenCalledTimes(2);
expect(channel.subscribe).toHaveBeenCalledOnce();
expect(result).toBe(channel);
});
});
describe('createBoard', () => {
it('creates board and default columns', async () => {
const board = { id: 'b1', name: 'New Board', org_id: 'o1' };
const sb = mockSupabase({ data: board, error: null });
const result = await createBoard(sb, 'o1', 'New Board');
expect(result).toEqual(board);
// Should have called from('kanban_columns') for default columns
expect(sb.from).toHaveBeenCalledWith('kanban_columns');
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(createBoard(sb, 'o1', 'X')).rejects.toEqual({ message: 'fail' });
});
});
describe('updateBoard', () => {
it('updates without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(updateBoard(sb, 'b1', 'Renamed')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateBoard(sb, 'b1', 'X')).rejects.toEqual({ message: 'fail' });
});
});
describe('deleteBoard', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteBoard(sb, 'b1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteBoard(sb, 'b1')).rejects.toEqual({ message: 'fail' });
});
});
// ── Columns ──────────────────────────────────────────────────────────────────
describe('createColumn', () => {
it('creates and returns column', async () => {
const col = { id: 'c1', name: 'To Do', board_id: 'b1', position: 0 };
const sb = mockSupabase({ data: col, error: null });
expect(await createColumn(sb, 'b1', 'To Do', 0)).toEqual(col);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(createColumn(sb, 'b1', 'X', 0)).rejects.toEqual({ message: 'fail' });
});
});
describe('updateColumn', () => {
it('updates without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(updateColumn(sb, 'c1', { name: 'Done' })).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateColumn(sb, 'c1', { name: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('deleteColumn', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteColumn(sb, 'c1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteColumn(sb, 'c1')).rejects.toEqual({ message: 'fail' });
});
});
// ── Cards ────────────────────────────────────────────────────────────────────
describe('createCard', () => {
it('creates and returns card', async () => {
const card = { id: 'k1', title: 'Task', column_id: 'c1', position: 0 };
const sb = mockSupabase({ data: card, error: null });
expect(await createCard(sb, 'c1', 'Task', 0, 'user1')).toEqual(card);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(createCard(sb, 'c1', 'X', 0, 'user1')).rejects.toEqual({ message: 'fail' });
});
});
describe('updateCard', () => {
it('updates without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(updateCard(sb, 'k1', { title: 'Updated' })).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateCard(sb, 'k1', { title: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('deleteCard', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteCard(sb, 'k1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteCard(sb, 'k1')).rejects.toEqual({ message: 'fail' });
});
});
// ── moveCard ─────────────────────────────────────────────────────────────────
describe('moveCard', () => {
it('reorders cards in target column', async () => {
const targetCards = [
{ id: 'k1', position: 0 },
{ id: 'k2', position: 1 },
];
const chain: any = {};
const methods = ['from', 'select', 'update', 'eq', 'in', 'order', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.then = (resolve: any) => resolve({ data: targetCards, error: null });
const sb = { from: vi.fn(() => chain) } as any;
await expect(moveCard(sb, 'k3', 'col1', 1)).resolves.toBeUndefined();
});
it('throws when fetch fails', async () => {
const chain: any = {};
const methods = ['from', 'select', 'eq', 'in', 'order'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.then = (resolve: any) => resolve({ data: null, error: { message: 'fetch fail' } });
const sb = { from: vi.fn(() => chain) } as any;
await expect(moveCard(sb, 'k1', 'col1', 0)).rejects.toEqual({ message: 'fetch fail' });
});
});

300
src/lib/api/map.ts Normal file
View File

@@ -0,0 +1,300 @@
import type { SupabaseClient } from '@supabase/supabase-js';
export interface MapLayer {
id: string;
department_id: string;
name: string;
layer_type: 'osm' | 'image';
image_url: string | null;
image_width: number | null;
image_height: number | null;
center_lat: number;
center_lng: number;
zoom_level: number;
sort_order: number;
created_by: string | null;
created_at: string;
updated_at: string;
}
export interface MapPin {
id: string;
layer_id: string;
label: string;
description: string;
color: string;
lat: number;
lng: number;
bounds_north: number | null;
bounds_south: number | null;
bounds_east: number | null;
bounds_west: number | null;
sort_order: number;
created_by: string | null;
created_at: string;
updated_at: string;
}
export interface MapShape {
id: string;
layer_id: string;
shape_type: 'polygon' | 'rectangle';
label: string;
color: string;
fill_opacity: number;
stroke_width: number;
vertices: [number, number][];
rotation: number;
sort_order: number;
created_by: string | null;
created_at: string;
updated_at: string;
}
export interface MapLayerWithPins extends MapLayer {
pins: MapPin[];
shapes: MapShape[];
}
// ── Layers ──
export async function fetchMapLayers(supabase: SupabaseClient, departmentId: string): Promise<MapLayerWithPins[]> {
const { data: layers, error: layerErr } = await (supabase as any)
.from('map_layers')
.select('*')
.eq('department_id', departmentId)
.order('sort_order');
if (layerErr) throw layerErr;
if (!layers || layers.length === 0) return [];
const layerIds = layers.map((l: any) => l.id);
const [pinResult, shapeResult] = await Promise.all([
(supabase as any).from('map_pins').select('*').in('layer_id', layerIds).order('sort_order'),
(supabase as any).from('map_shapes').select('*').in('layer_id', layerIds).order('sort_order'),
]);
if (pinResult.error) throw pinResult.error;
if (shapeResult.error) throw shapeResult.error;
const pins = pinResult.data ?? [];
const shapes = shapeResult.data ?? [];
return layers.map((layer: any) => ({
...layer,
pins: pins.filter((p: any) => p.layer_id === layer.id),
shapes: shapes.filter((s: any) => s.layer_id === layer.id),
}));
}
export async function createMapLayer(
supabase: SupabaseClient,
departmentId: string,
data: {
name: string;
layer_type: 'osm' | 'image';
image_url?: string;
image_width?: number;
image_height?: number;
center_lat?: number;
center_lng?: number;
zoom_level?: number;
sort_order?: number;
},
): Promise<MapLayer> {
const { data: layer, error } = await (supabase as any)
.from('map_layers')
.insert({
department_id: departmentId,
...data,
})
.select()
.single();
if (error) throw error;
return layer;
}
export async function updateMapLayer(
supabase: SupabaseClient,
layerId: string,
data: Partial<Pick<MapLayer, 'name' | 'layer_type' | 'image_url' | 'image_width' | 'image_height' | 'center_lat' | 'center_lng' | 'zoom_level' | 'sort_order'>>,
): Promise<MapLayer> {
const { data: layer, error } = await (supabase as any)
.from('map_layers')
.update({ ...data, updated_at: new Date().toISOString() })
.eq('id', layerId)
.select()
.single();
if (error) throw error;
return layer;
}
export async function deleteMapLayer(supabase: SupabaseClient, layerId: string): Promise<void> {
const { error } = await (supabase as any)
.from('map_layers')
.delete()
.eq('id', layerId);
if (error) throw error;
}
// ── Pins ──
export async function createMapPin(
supabase: SupabaseClient,
layerId: string,
data: {
label: string;
description?: string;
color?: string;
lat: number;
lng: number;
bounds_north?: number;
bounds_south?: number;
bounds_east?: number;
bounds_west?: number;
sort_order?: number;
},
): Promise<MapPin> {
const { data: pin, error } = await (supabase as any)
.from('map_pins')
.insert({
layer_id: layerId,
...data,
})
.select()
.single();
if (error) throw error;
return pin;
}
export async function updateMapPin(
supabase: SupabaseClient,
pinId: string,
data: Partial<Pick<MapPin, 'label' | 'description' | 'color' | 'lat' | 'lng' | 'bounds_north' | 'bounds_south' | 'bounds_east' | 'bounds_west' | 'sort_order'>>,
): Promise<MapPin> {
const { data: pin, error } = await (supabase as any)
.from('map_pins')
.update({ ...data, updated_at: new Date().toISOString() })
.eq('id', pinId)
.select()
.single();
if (error) throw error;
return pin;
}
export async function deleteMapPin(supabase: SupabaseClient, pinId: string): Promise<void> {
const { error } = await (supabase as any)
.from('map_pins')
.delete()
.eq('id', pinId);
if (error) throw error;
}
// ── Shapes ──
export async function createMapShape(
supabase: SupabaseClient,
layerId: string,
data: {
shape_type: 'polygon' | 'rectangle';
label?: string;
color?: string;
fill_opacity?: number;
stroke_width?: number;
vertices: [number, number][];
rotation?: number;
sort_order?: number;
},
): Promise<MapShape> {
const { data: shape, error } = await (supabase as any)
.from('map_shapes')
.insert({
layer_id: layerId,
...data,
})
.select()
.single();
if (error) throw error;
return shape;
}
export async function updateMapShape(
supabase: SupabaseClient,
shapeId: string,
data: Partial<Pick<MapShape, 'label' | 'color' | 'fill_opacity' | 'stroke_width' | 'vertices' | 'rotation' | 'sort_order'>>,
): Promise<MapShape> {
const { data: shape, error } = await (supabase as any)
.from('map_shapes')
.update({ ...data, updated_at: new Date().toISOString() })
.eq('id', shapeId)
.select()
.single();
if (error) throw error;
return shape;
}
export async function deleteMapShape(supabase: SupabaseClient, shapeId: string): Promise<void> {
const { error } = await (supabase as any)
.from('map_shapes')
.delete()
.eq('id', shapeId);
if (error) throw error;
}
// ── Realtime ──
export interface RealtimeMapPayload<T> {
event: 'INSERT' | 'UPDATE' | 'DELETE';
new: T;
old: Partial<T>;
}
export function subscribeToMapLayers(
supabase: SupabaseClient,
layerIds: string[],
onPinChange: (payload: RealtimeMapPayload<MapPin>) => void,
onShapeChange: (payload: RealtimeMapPayload<MapShape>) => void,
) {
const layerIdSet = new Set(layerIds);
const channelName = `map:${layerIds[0]?.slice(0, 8) ?? 'x'}-${Date.now()}`;
const channel = supabase.channel(channelName);
channel
.on('postgres_changes', { event: '*', schema: 'public', table: 'map_pins' },
(payload) => {
const pin = (payload.new ?? payload.old) as Partial<MapPin>;
const lid = pin.layer_id ?? (payload.old as Partial<MapPin>)?.layer_id;
if (lid && !layerIdSet.has(lid)) return;
onPinChange({
event: payload.eventType as 'INSERT' | 'UPDATE' | 'DELETE',
new: payload.new as MapPin,
old: payload.old as Partial<MapPin>,
});
}
)
.on('postgres_changes', { event: '*', schema: 'public', table: 'map_shapes' },
(payload) => {
const shape = (payload.new ?? payload.old) as Partial<MapShape>;
const lid = shape.layer_id ?? (payload.old as Partial<MapShape>)?.layer_id;
if (lid && !layerIdSet.has(lid)) return;
onShapeChange({
event: payload.eventType as 'INSERT' | 'UPDATE' | 'DELETE',
new: payload.new as MapShape,
old: payload.old as Partial<MapShape>,
});
}
)
.subscribe();
return channel;
}

View File

@@ -0,0 +1,203 @@
import { describe, it, expect, vi } from 'vitest';
import {
fetchOrgContacts,
createOrgContact,
updateOrgContact,
deleteOrgContact,
fetchPinnedContacts,
pinContact,
unpinContact,
} from './org-contacts';
// ── Supabase mock builder ────────────────────────────────────────────────────
function mockChain(resolvedValue: { data: any; error: any }) {
const chain: any = {};
const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'order', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.single = vi.fn(() => Promise.resolve(resolvedValue));
chain.then = (resolve: any) => resolve(resolvedValue);
return chain;
}
function mockSupabase(resolvedValue: { data: any; error: any }) {
const chain = mockChain(resolvedValue);
return { from: vi.fn(() => chain), _chain: chain } as any;
}
function mockDeleteSupabase(resolvedValue: { data: any; error: any }) {
const chain: any = {};
for (const m of ['from', 'delete', 'eq']) {
chain[m] = vi.fn(() => chain);
}
chain.then = (resolve: any) => resolve(resolvedValue);
return { from: vi.fn(() => chain) } as any;
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe('org-contacts API', () => {
describe('fetchOrgContacts', () => {
it('should return contacts ordered by name', async () => {
const contacts = [
{ id: 'c1', name: 'Alice', org_id: 'o1', category: 'vendor' },
{ id: 'c2', name: 'Bob', org_id: 'o1', category: 'general' },
];
const supabase = mockSupabase({ data: contacts, error: null });
const result = await fetchOrgContacts(supabase, 'o1');
expect(result).toHaveLength(2);
expect(result[0].name).toBe('Alice');
expect(result[1].name).toBe('Bob');
});
it('should return empty array when data is null', async () => {
const supabase = mockSupabase({ data: null, error: null });
const result = await fetchOrgContacts(supabase, 'o1');
expect(result).toEqual([]);
});
it('should throw on error', async () => {
const supabase = mockSupabase({ data: null, error: { message: 'fetch fail' } });
await expect(fetchOrgContacts(supabase, 'o1')).rejects.toEqual({ message: 'fetch fail' });
});
});
describe('createOrgContact', () => {
it('should create a contact with defaults', async () => {
const created = { id: 'c1', name: 'Test', org_id: 'o1', category: 'general', color: '#00A3E0' };
const supabase = mockSupabase({ data: created, error: null });
const result = await createOrgContact(supabase, 'o1', { name: 'Test' });
expect(result.id).toBe('c1');
expect(result.category).toBe('general');
});
it('should create a contact with all fields', async () => {
const created = {
id: 'c2', name: 'Full', org_id: 'o1', role: 'Manager', company: 'Acme',
email: 'a@b.com', phone: '+1234', website: 'https://acme.com',
notes: 'VIP', category: 'sponsor', color: '#FF0000',
};
const supabase = mockSupabase({ data: created, error: null });
const result = await createOrgContact(supabase, 'o1', {
name: 'Full', role: 'Manager', company: 'Acme',
email: 'a@b.com', phone: '+1234', website: 'https://acme.com',
notes: 'VIP', category: 'sponsor', color: '#FF0000',
});
expect(result.role).toBe('Manager');
expect(result.company).toBe('Acme');
expect(result.category).toBe('sponsor');
});
it('should throw on error', async () => {
const supabase = mockSupabase({ data: null, error: { message: 'create fail' } });
await expect(createOrgContact(supabase, 'o1', { name: 'X' })).rejects.toEqual({ message: 'create fail' });
});
});
describe('updateOrgContact', () => {
it('should update and return the contact', async () => {
const updated = { id: 'c1', name: 'Updated', org_id: 'o1', category: 'vendor' };
const supabase = mockSupabase({ data: updated, error: null });
const result = await updateOrgContact(supabase, 'c1', { name: 'Updated', category: 'vendor' });
expect(result.name).toBe('Updated');
expect(result.category).toBe('vendor');
});
it('should throw on error', async () => {
const supabase = mockSupabase({ data: null, error: { message: 'update fail' } });
await expect(updateOrgContact(supabase, 'c1', { name: 'X' })).rejects.toEqual({ message: 'update fail' });
});
});
describe('deleteOrgContact', () => {
it('should delete without error', async () => {
const supabase = mockDeleteSupabase({ data: null, error: null });
await expect(deleteOrgContact(supabase, 'c1')).resolves.toBeUndefined();
});
it('should throw on error', async () => {
const supabase = mockDeleteSupabase({ data: null, error: { message: 'delete fail' } });
await expect(deleteOrgContact(supabase, 'c1')).rejects.toEqual({ message: 'delete fail' });
});
});
describe('fetchPinnedContacts', () => {
it('should return pinned contacts for a department', async () => {
const pins = [
{ id: 'p1', department_id: 'd1', contact_id: 'c1' },
{ id: 'p2', department_id: 'd1', contact_id: 'c2' },
];
const supabase = mockSupabase({ data: pins, error: null });
const result = await fetchPinnedContacts(supabase, 'd1');
expect(result).toHaveLength(2);
expect(result[0].contact_id).toBe('c1');
});
it('should throw on error', async () => {
const supabase = mockSupabase({ data: null, error: { message: 'pin fetch fail' } });
await expect(fetchPinnedContacts(supabase, 'd1')).rejects.toEqual({ message: 'pin fetch fail' });
});
});
describe('pinContact', () => {
it('should pin a contact and return the record', async () => {
const pinned = { id: 'p1', department_id: 'd1', contact_id: 'c1' };
const supabase = mockSupabase({ data: pinned, error: null });
const result = await pinContact(supabase, 'd1', 'c1');
expect(result.department_id).toBe('d1');
expect(result.contact_id).toBe('c1');
});
it('should throw on error', async () => {
const supabase = mockSupabase({ data: null, error: { message: 'pin fail' } });
await expect(pinContact(supabase, 'd1', 'c1')).rejects.toEqual({ message: 'pin fail' });
});
});
describe('unpinContact', () => {
it('should unpin without error', async () => {
// unpinContact chains .delete().eq().eq() - no .single()
const chain: any = {};
for (const m of ['from', 'delete', 'eq']) {
chain[m] = vi.fn(() => chain);
}
chain.then = (resolve: any) => resolve({ data: null, error: null });
const supabase = { from: vi.fn(() => chain) } as any;
await expect(unpinContact(supabase, 'd1', 'c1')).resolves.toBeUndefined();
});
it('should throw on error', async () => {
const chain: any = {};
for (const m of ['from', 'delete', 'eq']) {
chain[m] = vi.fn(() => chain);
}
chain.then = (resolve: any) => resolve({ data: null, error: { message: 'unpin fail' } });
const supabase = { from: vi.fn(() => chain) } as any;
await expect(unpinContact(supabase, 'd1', 'c1')).rejects.toEqual({ message: 'unpin fail' });
});
});
});

158
src/lib/api/org-contacts.ts Normal file
View File

@@ -0,0 +1,158 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { OrgContact, DepartmentPinnedContact } from '$lib/supabase/types';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api.org-contacts');
function db(supabase: SupabaseClient) {
return supabase as any;
}
// ============================================================
// Org Contacts CRUD
// ============================================================
export async function fetchOrgContacts(
supabase: SupabaseClient,
orgId: string
): Promise<OrgContact[]> {
const { data, error } = await db(supabase)
.from('org_contacts')
.select('*')
.eq('org_id', orgId)
.order('name');
if (error) {
log.error('fetchOrgContacts failed', { error, data: { orgId } });
throw error;
}
return (data ?? []) as OrgContact[];
}
export async function createOrgContact(
supabase: SupabaseClient,
orgId: string,
params: {
name: string;
role?: string;
company?: string;
email?: string;
phone?: string;
website?: string;
notes?: string;
category?: string;
color?: string;
}
): Promise<OrgContact> {
const { data, error } = await db(supabase)
.from('org_contacts')
.insert({
org_id: orgId,
name: params.name,
role: params.role ?? null,
company: params.company ?? null,
email: params.email ?? null,
phone: params.phone ?? null,
website: params.website ?? null,
notes: params.notes ?? null,
category: params.category ?? 'general',
color: params.color ?? '#00A3E0',
})
.select()
.single();
if (error) {
log.error('createOrgContact failed', { error, data: { orgId, name: params.name } });
throw error;
}
return data as OrgContact;
}
export async function updateOrgContact(
supabase: SupabaseClient,
contactId: string,
params: Partial<Pick<OrgContact, 'name' | 'role' | 'company' | 'email' | 'phone' | 'website' | 'notes' | 'category' | 'color'>>
): Promise<OrgContact> {
const { data, error } = await db(supabase)
.from('org_contacts')
.update({ ...params, updated_at: new Date().toISOString() })
.eq('id', contactId)
.select()
.single();
if (error) {
log.error('updateOrgContact failed', { error, data: { contactId } });
throw error;
}
return data as OrgContact;
}
export async function deleteOrgContact(
supabase: SupabaseClient,
contactId: string
): Promise<void> {
const { error } = await db(supabase)
.from('org_contacts')
.delete()
.eq('id', contactId);
if (error) {
log.error('deleteOrgContact failed', { error, data: { contactId } });
throw error;
}
}
// ============================================================
// Department Pinned Contacts
// ============================================================
export async function fetchPinnedContacts(
supabase: SupabaseClient,
departmentId: string
): Promise<DepartmentPinnedContact[]> {
const { data, error } = await db(supabase)
.from('department_pinned_contacts')
.select('*')
.eq('department_id', departmentId);
if (error) {
log.error('fetchPinnedContacts failed', { error, data: { departmentId } });
throw error;
}
return (data ?? []) as DepartmentPinnedContact[];
}
export async function pinContact(
supabase: SupabaseClient,
departmentId: string,
contactId: string
): Promise<DepartmentPinnedContact> {
const { data, error } = await db(supabase)
.from('department_pinned_contacts')
.insert({ department_id: departmentId, contact_id: contactId })
.select()
.single();
if (error) {
log.error('pinContact failed', { error, data: { departmentId, contactId } });
throw error;
}
return data as DepartmentPinnedContact;
}
export async function unpinContact(
supabase: SupabaseClient,
departmentId: string,
contactId: string
): Promise<void> {
const { error } = await db(supabase)
.from('department_pinned_contacts')
.delete()
.eq('department_id', departmentId)
.eq('contact_id', contactId);
if (error) {
log.error('unpinContact failed', { error, data: { departmentId, contactId } });
throw error;
}
}

View File

@@ -0,0 +1,250 @@
import { describe, it, expect, vi } from 'vitest';
import {
fetchUserOrganizations,
createOrganization,
updateOrganization,
deleteOrganization,
fetchOrgMembers,
inviteMember,
updateMemberRole,
removeMember,
generateSlug,
} from './organizations';
// ── Supabase mock builder ────────────────────────────────────────────────────
function mockChain(resolvedValue: { data: any; error: any }) {
const chain: any = {};
const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'not', 'order', 'single', 'in'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.single = vi.fn(() => Promise.resolve(resolvedValue));
chain.then = (resolve: any) => resolve(resolvedValue);
return chain;
}
function mockSupabase(resolvedValue: { data: any; error: any }) {
const chain = mockChain(resolvedValue);
return { from: vi.fn(() => chain), _chain: chain } as any;
}
// ── generateSlug (pure function) ─────────────────────────────────────────────
describe('generateSlug', () => {
it('lowercases and replaces spaces with hyphens', () => {
expect(generateSlug('My Organization')).toBe('my-organization');
});
it('removes special characters', () => {
expect(generateSlug('Test & Co. (2024)')).toBe('test-co-2024');
});
it('trims leading/trailing hyphens', () => {
expect(generateSlug('---hello---')).toBe('hello');
});
it('truncates to 50 characters', () => {
const long = 'a'.repeat(100);
expect(generateSlug(long).length).toBeLessThanOrEqual(50);
});
it('handles empty string', () => {
expect(generateSlug('')).toBe('');
});
it('collapses multiple hyphens', () => {
expect(generateSlug('hello world')).toBe('hello-world');
});
});
// ── fetchUserOrganizations ───────────────────────────────────────────────────
describe('fetchUserOrganizations', () => {
it('returns orgs with roles', async () => {
const data = [
{ role: 'admin', organizations: { id: 'o1', name: 'Org1', slug: 'org1' } },
];
const sb = mockSupabase({ data, error: null });
const result = await fetchUserOrganizations(sb);
expect(result).toHaveLength(1);
expect(result[0].role).toBe('admin');
expect(result[0].id).toBe('o1');
});
it('filters out null organizations', async () => {
const data = [
{ role: 'admin', organizations: { id: 'o1', name: 'Org1', slug: 'org1' } },
{ role: 'viewer', organizations: null },
];
const sb = mockSupabase({ data, error: null });
const result = await fetchUserOrganizations(sb);
expect(result).toHaveLength(1);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchUserOrganizations(sb)).rejects.toEqual({ message: 'fail' });
});
});
// ── createOrganization ───────────────────────────────────────────────────────
describe('createOrganization', () => {
it('creates and returns org', async () => {
const org = { id: 'o1', name: 'New Org', slug: 'new-org' };
const sb = mockSupabase({ data: org, error: null });
expect(await createOrganization(sb, 'New Org', 'new-org')).toEqual(org);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'dup slug' } });
await expect(createOrganization(sb, 'X', 'x')).rejects.toEqual({ message: 'dup slug' });
});
});
// ── updateOrganization ───────────────────────────────────────────────────────
describe('updateOrganization', () => {
it('updates and returns org', async () => {
const org = { id: 'o1', name: 'Updated' };
const sb = mockSupabase({ data: org, error: null });
expect(await updateOrganization(sb, 'o1', { name: 'Updated' })).toEqual(org);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateOrganization(sb, 'o1', { name: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
// ── deleteOrganization ───────────────────────────────────────────────────────
describe('deleteOrganization', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteOrganization(sb, 'o1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteOrganization(sb, 'o1')).rejects.toEqual({ message: 'fail' });
});
});
// ── fetchOrgMembers ──────────────────────────────────────────────────────────
describe('fetchOrgMembers', () => {
it('returns members', async () => {
const members = [{ id: 'm1', user_id: 'u1', role: 'admin' }];
const sb = mockSupabase({ data: members, error: null });
expect(await fetchOrgMembers(sb, 'o1')).toEqual(members);
});
it('returns empty array when null', async () => {
const sb = mockSupabase({ data: null, error: null });
expect(await fetchOrgMembers(sb, 'o1')).toEqual([]);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchOrgMembers(sb, 'o1')).rejects.toEqual({ message: 'fail' });
});
});
// ── inviteMember ─────────────────────────────────────────────────────────────
describe('inviteMember', () => {
it('throws when user not found', async () => {
const sb = mockSupabase({ data: null, error: { message: 'not found' } });
await expect(inviteMember(sb, 'o1', 'nobody@test.com')).rejects.toThrow('User not found');
});
it('throws when user is already a member', async () => {
const profile = { id: 'u1', email: 'a@b.com' };
const existing = { id: 'om1' };
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', 'order', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
let singleIdx = 0;
chain.single = vi.fn(() => {
singleIdx++;
if (singleIdx === 1) return Promise.resolve({ data: profile, error: null }); // profile lookup
if (singleIdx === 2) return Promise.resolve({ data: existing, error: null }); // already member
return Promise.resolve({ data: null, error: null });
});
const sb = { from: vi.fn(() => chain) } as any;
await expect(inviteMember(sb, 'o1', 'a@b.com')).rejects.toThrow('already a member');
});
it('invites successfully when user exists and is not a member', async () => {
const profile = { id: 'u1', email: 'a@b.com' };
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', 'order', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
let singleIdx = 0;
chain.single = vi.fn(() => {
singleIdx++;
if (singleIdx === 1) return Promise.resolve({ data: profile, error: null }); // profile
if (singleIdx === 2) return Promise.resolve({ data: null, error: null }); // not a member
return Promise.resolve({ data: null, error: null });
});
chain.then = (resolve: any) => resolve({ data: null, error: null }); // insert success
const sb = { from: vi.fn(() => chain) } as any;
await expect(inviteMember(sb, 'o1', 'a@b.com')).resolves.toBeUndefined();
});
it('throws on insert error', async () => {
const profile = { id: 'u1', email: 'a@b.com' };
const chain: any = {};
const methods = ['from', 'select', 'insert', 'eq', 'order', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
let singleIdx = 0;
chain.single = vi.fn(() => {
singleIdx++;
if (singleIdx === 1) return Promise.resolve({ data: profile, error: null });
if (singleIdx === 2) return Promise.resolve({ data: null, error: null });
return Promise.resolve({ data: null, error: null });
});
chain.then = (resolve: any) => resolve({ data: null, error: { message: 'insert fail' } });
const sb = { from: vi.fn(() => chain) } as any;
await expect(inviteMember(sb, 'o1', 'a@b.com')).rejects.toEqual({ message: 'insert fail' });
});
});
// ── updateMemberRole ─────────────────────────────────────────────────────────
describe('updateMemberRole', () => {
it('updates without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(updateMemberRole(sb, 'm1', 'editor')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateMemberRole(sb, 'm1', 'admin')).rejects.toEqual({ message: 'fail' });
});
});
// ── removeMember ─────────────────────────────────────────────────────────────
describe('removeMember', () => {
it('removes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(removeMember(sb, 'm1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(removeMember(sb, 'm1')).rejects.toEqual({ message: 'fail' });
});
});

View File

@@ -0,0 +1,171 @@
import { describe, it, expect, vi } from 'vitest';
import {
fetchStages,
createStage,
updateStage,
deleteStage,
fetchBlocks,
createBlock,
updateBlock,
deleteBlock,
} from './schedule';
// ── Supabase mock builder ────────────────────────────────────────────────────
function mockChain(resolvedValue: { data: any; error: any }) {
const chain: any = {};
const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'order', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.single = vi.fn(() => Promise.resolve(resolvedValue));
chain.then = (resolve: any) => resolve(resolvedValue);
return chain;
}
function mockSupabase(resolvedValue: { data: any; error: any }) {
const chain = mockChain(resolvedValue);
return { from: vi.fn(() => chain), _chain: chain } as any;
}
// ── Stages ───────────────────────────────────────────────────────────────────
describe('fetchStages', () => {
it('returns stages for a department', async () => {
const stages = [{ id: 's1', name: 'Main Stage', department_id: 'd1' }];
const sb = mockSupabase({ data: stages, error: null });
expect(await fetchStages(sb, 'd1')).toEqual(stages);
});
it('returns empty array when null', async () => {
const sb = mockSupabase({ data: null, error: null });
expect(await fetchStages(sb, 'd1')).toEqual([]);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchStages(sb, 'd1')).rejects.toEqual({ message: 'fail' });
});
});
describe('createStage', () => {
it('creates with default color', async () => {
const stage = { id: 's1', name: 'VIP', color: '#6366f1' };
const sb = mockSupabase({ data: stage, error: null });
expect(await createStage(sb, 'd1', 'VIP')).toEqual(stage);
});
it('creates with custom color', async () => {
const stage = { id: 's2', name: 'Outdoor', color: '#00ff00' };
const sb = mockSupabase({ data: stage, error: null });
expect(await createStage(sb, 'd1', 'Outdoor', '#00ff00')).toEqual(stage);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(createStage(sb, 'd1', 'X')).rejects.toEqual({ message: 'fail' });
});
});
describe('updateStage', () => {
it('updates and returns stage', async () => {
const stage = { id: 's1', name: 'Renamed' };
const sb = mockSupabase({ data: stage, error: null });
expect(await updateStage(sb, 's1', { name: 'Renamed' })).toEqual(stage);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateStage(sb, 's1', { name: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('deleteStage', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteStage(sb, 's1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteStage(sb, 's1')).rejects.toEqual({ message: 'fail' });
});
});
// ── Blocks ───────────────────────────────────────────────────────────────────
describe('fetchBlocks', () => {
it('returns blocks for a department', async () => {
const blocks = [{ id: 'b1', title: 'Opening', department_id: 'd1' }];
const sb = mockSupabase({ data: blocks, error: null });
expect(await fetchBlocks(sb, 'd1')).toEqual(blocks);
});
it('returns empty array when null', async () => {
const sb = mockSupabase({ data: null, error: null });
expect(await fetchBlocks(sb, 'd1')).toEqual([]);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchBlocks(sb, 'd1')).rejects.toEqual({ message: 'fail' });
});
});
describe('createBlock', () => {
it('creates a block with minimal params', async () => {
const block = { id: 'b1', title: 'Keynote', start_time: '09:00', end_time: '10:00' };
const sb = mockSupabase({ data: block, error: null });
const result = await createBlock(sb, 'd1', {
title: 'Keynote',
start_time: '09:00',
end_time: '10:00',
});
expect(result).toEqual(block);
});
it('creates a block with all params', async () => {
const block = { id: 'b2', title: 'Panel', speaker: 'John' };
const sb = mockSupabase({ data: block, error: null });
const result = await createBlock(sb, 'd1', {
title: 'Panel',
start_time: '10:00',
end_time: '11:00',
stage_id: 's1',
description: 'A panel',
color: '#ff0000',
speaker: 'John',
}, 'user1');
expect(result).toEqual(block);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(createBlock(sb, 'd1', { title: 'X', start_time: '09:00', end_time: '10:00' })).rejects.toEqual({ message: 'fail' });
});
});
describe('updateBlock', () => {
it('updates and returns block', async () => {
const block = { id: 'b1', title: 'Updated' };
const sb = mockSupabase({ data: block, error: null });
expect(await updateBlock(sb, 'b1', { title: 'Updated' })).toEqual(block);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateBlock(sb, 'b1', { title: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('deleteBlock', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteBlock(sb, 'b1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteBlock(sb, 'b1')).rejects.toEqual({ message: 'fail' });
});
});

176
src/lib/api/schedule.ts Normal file
View File

@@ -0,0 +1,176 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { ScheduleStage, ScheduleBlock } from '$lib/supabase/types';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api.schedule');
// Helper to cast supabase for tables not yet in generated types
function db(supabase: SupabaseClient) {
return supabase as any;
}
// ============================================================
// Stages
// ============================================================
export async function fetchStages(
supabase: SupabaseClient,
departmentId: string
): Promise<ScheduleStage[]> {
const { data, error } = await db(supabase)
.from('schedule_stages')
.select('*')
.eq('department_id', departmentId)
.order('sort_order');
if (error) {
log.error('fetchStages failed', { error, data: { departmentId } });
throw error;
}
return (data ?? []) as ScheduleStage[];
}
export async function createStage(
supabase: SupabaseClient,
departmentId: string,
name: string,
color?: string
): Promise<ScheduleStage> {
const { data, error } = await db(supabase)
.from('schedule_stages')
.insert({ department_id: departmentId, name, color: color ?? '#6366f1' })
.select()
.single();
if (error) {
log.error('createStage failed', { error, data: { departmentId, name } });
throw error;
}
return data as ScheduleStage;
}
export async function updateStage(
supabase: SupabaseClient,
stageId: string,
params: Partial<Pick<ScheduleStage, 'name' | 'color' | 'sort_order'>>
): Promise<ScheduleStage> {
const { data, error } = await db(supabase)
.from('schedule_stages')
.update(params)
.eq('id', stageId)
.select()
.single();
if (error) {
log.error('updateStage failed', { error, data: { stageId } });
throw error;
}
return data as ScheduleStage;
}
export async function deleteStage(
supabase: SupabaseClient,
stageId: string
): Promise<void> {
const { error } = await db(supabase)
.from('schedule_stages')
.delete()
.eq('id', stageId);
if (error) {
log.error('deleteStage failed', { error, data: { stageId } });
throw error;
}
}
// ============================================================
// Blocks
// ============================================================
export async function fetchBlocks(
supabase: SupabaseClient,
departmentId: string
): Promise<ScheduleBlock[]> {
const { data, error } = await db(supabase)
.from('schedule_blocks')
.select('*')
.eq('department_id', departmentId)
.order('start_time');
if (error) {
log.error('fetchBlocks failed', { error, data: { departmentId } });
throw error;
}
return (data ?? []) as ScheduleBlock[];
}
export async function createBlock(
supabase: SupabaseClient,
departmentId: string,
params: {
title: string;
start_time: string;
end_time: string;
stage_id?: string | null;
description?: string;
color?: string;
speaker?: string;
},
userId?: string
): Promise<ScheduleBlock> {
const { data, error } = await db(supabase)
.from('schedule_blocks')
.insert({
department_id: departmentId,
title: params.title,
start_time: params.start_time,
end_time: params.end_time,
stage_id: params.stage_id ?? null,
description: params.description ?? null,
color: params.color ?? '#6366f1',
speaker: params.speaker ?? null,
created_by: userId ?? null,
})
.select()
.single();
if (error) {
log.error('createBlock failed', { error, data: { departmentId, title: params.title } });
throw error;
}
return data as ScheduleBlock;
}
export async function updateBlock(
supabase: SupabaseClient,
blockId: string,
params: Partial<Pick<ScheduleBlock, 'title' | 'description' | 'start_time' | 'end_time' | 'stage_id' | 'color' | 'speaker' | 'sort_order'>>
): Promise<ScheduleBlock> {
const { data, error } = await db(supabase)
.from('schedule_blocks')
.update({ ...params, updated_at: new Date().toISOString() })
.eq('id', blockId)
.select()
.single();
if (error) {
log.error('updateBlock failed', { error, data: { blockId } });
throw error;
}
return data as ScheduleBlock;
}
export async function deleteBlock(
supabase: SupabaseClient,
blockId: string
): Promise<void> {
const { error } = await db(supabase)
.from('schedule_blocks')
.delete()
.eq('id', blockId);
if (error) {
log.error('deleteBlock failed', { error, data: { blockId } });
throw error;
}
}

View File

@@ -0,0 +1,166 @@
import { describe, it, expect, vi } from 'vitest';
import {
fetchEventSponsorAllocations,
fetchDepartmentSponsorAllocations,
createSponsorAllocation,
updateSponsorAllocation,
deleteSponsorAllocation,
} from './sponsor-allocations';
// ── Supabase mock builder ────────────────────────────────────────────────────
function mockChain(resolvedValue: { data: any; error: any }) {
const chain: any = {};
const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'order', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
// Terminal - resolve the promise
chain.single = vi.fn(() => Promise.resolve(resolvedValue));
// For non-single queries, make the chain itself thenable
chain.then = (resolve: any) => resolve(resolvedValue);
return chain;
}
function mockSupabase(resolvedValue: { data: any; error: any }) {
const chain = mockChain(resolvedValue);
return { from: vi.fn(() => chain), _chain: chain } as any;
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe('sponsor-allocations API', () => {
describe('fetchEventSponsorAllocations', () => {
it('should return allocations stripped of join data', async () => {
const raw = [
{ id: 'a1', sponsor_id: 's1', department_id: 'd1', allocated_amount: 500, used_amount: 100, event_departments: { event_id: 'e1' } },
{ id: 'a2', sponsor_id: 's2', department_id: 'd2', allocated_amount: 300, used_amount: 0, event_departments: { event_id: 'e1' } },
];
const supabase = mockSupabase({ data: raw, error: null });
const result = await fetchEventSponsorAllocations(supabase, 'e1');
expect(result).toHaveLength(2);
expect(result[0]).not.toHaveProperty('event_departments');
expect(result[0].id).toBe('a1');
expect(result[1].allocated_amount).toBe(300);
});
it('should throw on error', async () => {
const supabase = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchEventSponsorAllocations(supabase, 'e1')).rejects.toEqual({ message: 'fail' });
});
it('should return empty array when data is null', async () => {
const supabase = mockSupabase({ data: null, error: null });
const result = await fetchEventSponsorAllocations(supabase, 'e1');
expect(result).toEqual([]);
});
});
describe('fetchDepartmentSponsorAllocations', () => {
it('should return allocations for a department', async () => {
const raw = [{ id: 'a1', sponsor_id: 's1', department_id: 'd1', allocated_amount: 200, used_amount: 50 }];
const supabase = mockSupabase({ data: raw, error: null });
const result = await fetchDepartmentSponsorAllocations(supabase, 'd1');
expect(result).toHaveLength(1);
expect(result[0].department_id).toBe('d1');
});
it('should throw on error', async () => {
const supabase = mockSupabase({ data: null, error: { message: 'dept fail' } });
await expect(fetchDepartmentSponsorAllocations(supabase, 'd1')).rejects.toEqual({ message: 'dept fail' });
});
});
describe('createSponsorAllocation', () => {
it('should create and return an allocation', async () => {
const created = { id: 'new1', sponsor_id: 's1', department_id: 'd1', allocated_amount: 1000, used_amount: 0, notes: null };
const supabase = mockSupabase({ data: created, error: null });
const result = await createSponsorAllocation(supabase, {
sponsor_id: 's1',
department_id: 'd1',
allocated_amount: 1000,
});
expect(result.id).toBe('new1');
expect(result.allocated_amount).toBe(1000);
expect(result.used_amount).toBe(0);
});
it('should pass used_amount and notes when provided', async () => {
const created = { id: 'new2', sponsor_id: 's1', department_id: 'd1', allocated_amount: 500, used_amount: 100, notes: 'test' };
const supabase = mockSupabase({ data: created, error: null });
const result = await createSponsorAllocation(supabase, {
sponsor_id: 's1',
department_id: 'd1',
allocated_amount: 500,
used_amount: 100,
notes: 'test',
});
expect(result.used_amount).toBe(100);
expect(result.notes).toBe('test');
});
it('should throw on error', async () => {
const supabase = mockSupabase({ data: null, error: { message: 'create fail' } });
await expect(
createSponsorAllocation(supabase, { sponsor_id: 's1', department_id: 'd1', allocated_amount: 100 })
).rejects.toEqual({ message: 'create fail' });
});
});
describe('updateSponsorAllocation', () => {
it('should update and return the allocation', async () => {
const updated = { id: 'a1', sponsor_id: 's1', department_id: 'd1', allocated_amount: 750, used_amount: 200, notes: 'updated' };
const supabase = mockSupabase({ data: updated, error: null });
const result = await updateSponsorAllocation(supabase, 'a1', { allocated_amount: 750, used_amount: 200, notes: 'updated' });
expect(result.allocated_amount).toBe(750);
expect(result.notes).toBe('updated');
});
it('should throw on error', async () => {
const supabase = mockSupabase({ data: null, error: { message: 'update fail' } });
await expect(updateSponsorAllocation(supabase, 'a1', { allocated_amount: 100 })).rejects.toEqual({ message: 'update fail' });
});
});
describe('deleteSponsorAllocation', () => {
it('should delete without error', async () => {
// delete doesn't call .single(), so we need a different mock
const chain: any = {};
const methods = ['from', 'delete', 'eq'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.then = (resolve: any) => resolve({ data: null, error: null });
const supabase = { from: vi.fn(() => chain) } as any;
await expect(deleteSponsorAllocation(supabase, 'a1')).resolves.toBeUndefined();
});
it('should throw on error', async () => {
const chain: any = {};
const methods = ['from', 'delete', 'eq'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.then = (resolve: any) => resolve({ data: null, error: { message: 'delete fail' } });
const supabase = { from: vi.fn(() => chain) } as any;
await expect(deleteSponsorAllocation(supabase, 'a1')).rejects.toEqual({ message: 'delete fail' });
});
});
});

View File

@@ -0,0 +1,115 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { SponsorAllocation } from '$lib/supabase/types';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api.sponsor-allocations');
function db(supabase: SupabaseClient) {
return supabase as any;
}
// ============================================================
// Fetch allocations for an event (via departments)
// ============================================================
export async function fetchEventSponsorAllocations(
supabase: SupabaseClient,
eventId: string
): Promise<SponsorAllocation[]> {
const { data, error } = await db(supabase)
.from('sponsor_allocations')
.select('*, event_departments!inner(event_id)')
.eq('event_departments.event_id', eventId);
if (error) {
log.error('fetchEventSponsorAllocations failed', { error, data: { eventId } });
throw error;
}
return (data ?? []).map((d: any) => {
const { event_departments, ...alloc } = d;
return alloc;
}) as SponsorAllocation[];
}
export async function fetchDepartmentSponsorAllocations(
supabase: SupabaseClient,
departmentId: string
): Promise<SponsorAllocation[]> {
const { data, error } = await db(supabase)
.from('sponsor_allocations')
.select('*')
.eq('department_id', departmentId);
if (error) {
log.error('fetchDepartmentSponsorAllocations failed', { error, data: { departmentId } });
throw error;
}
return (data ?? []) as SponsorAllocation[];
}
// ============================================================
// CRUD
// ============================================================
export async function createSponsorAllocation(
supabase: SupabaseClient,
params: {
sponsor_id: string;
department_id: string;
allocated_amount: number;
used_amount?: number;
notes?: string;
}
): Promise<SponsorAllocation> {
const { data, error } = await db(supabase)
.from('sponsor_allocations')
.insert({
sponsor_id: params.sponsor_id,
department_id: params.department_id,
allocated_amount: params.allocated_amount,
used_amount: params.used_amount ?? 0,
notes: params.notes ?? null,
})
.select()
.single();
if (error) {
log.error('createSponsorAllocation failed', { error, data: params });
throw error;
}
return data as SponsorAllocation;
}
export async function updateSponsorAllocation(
supabase: SupabaseClient,
allocationId: string,
params: Partial<Pick<SponsorAllocation, 'allocated_amount' | 'used_amount' | 'notes'>>
): Promise<SponsorAllocation> {
const { data, error } = await db(supabase)
.from('sponsor_allocations')
.update({ ...params, updated_at: new Date().toISOString() })
.eq('id', allocationId)
.select()
.single();
if (error) {
log.error('updateSponsorAllocation failed', { error, data: { allocationId } });
throw error;
}
return data as SponsorAllocation;
}
export async function deleteSponsorAllocation(
supabase: SupabaseClient,
allocationId: string
): Promise<void> {
const { error } = await db(supabase)
.from('sponsor_allocations')
.delete()
.eq('id', allocationId);
if (error) {
log.error('deleteSponsorAllocation failed', { error, data: { allocationId } });
throw error;
}
}

View File

@@ -0,0 +1,116 @@
import { describe, it, expect, vi } from 'vitest';
import {
fetchEventSponsorTiers,
fetchEventSponsors,
fetchEventDeliverables,
} from './sponsors';
// ── Supabase mock builder ────────────────────────────────────────────────────
function mockChain(resolvedValue: { data: any; error: any }) {
const chain: any = {};
const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'order', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.single = vi.fn(() => Promise.resolve(resolvedValue));
chain.then = (resolve: any) => resolve(resolvedValue);
return chain;
}
function mockSupabase(resolvedValue: { data: any; error: any }) {
const chain = mockChain(resolvedValue);
return { from: vi.fn(() => chain), _chain: chain } as any;
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe('event-wide sponsor fetching', () => {
describe('fetchEventSponsorTiers', () => {
it('should return tiers stripped of join data', async () => {
const raw = [
{ id: 't1', name: 'Gold', sort_order: 0, event_departments: { event_id: 'e1' } },
{ id: 't2', name: 'Silver', sort_order: 1, event_departments: { event_id: 'e1' } },
];
const supabase = mockSupabase({ data: raw, error: null });
const result = await fetchEventSponsorTiers(supabase, 'e1');
expect(result).toHaveLength(2);
expect(result[0]).not.toHaveProperty('event_departments');
expect(result[0].name).toBe('Gold');
expect(result[1].name).toBe('Silver');
});
it('should return empty array when data is null', async () => {
const supabase = mockSupabase({ data: null, error: null });
const result = await fetchEventSponsorTiers(supabase, 'e1');
expect(result).toEqual([]);
});
it('should throw on error', async () => {
const supabase = mockSupabase({ data: null, error: { message: 'tier fail' } });
await expect(fetchEventSponsorTiers(supabase, 'e1')).rejects.toEqual({ message: 'tier fail' });
});
});
describe('fetchEventSponsors', () => {
it('should return sponsors stripped of join data', async () => {
const raw = [
{ id: 's1', name: 'Acme', amount: 5000, status: 'confirmed', event_departments: { event_id: 'e1' } },
{ id: 's2', name: 'Beta', amount: 2000, status: 'prospect', event_departments: { event_id: 'e1' } },
];
const supabase = mockSupabase({ data: raw, error: null });
const result = await fetchEventSponsors(supabase, 'e1');
expect(result).toHaveLength(2);
expect(result[0]).not.toHaveProperty('event_departments');
expect(result[0].name).toBe('Acme');
expect(result[0].amount).toBe(5000);
});
it('should return empty array when data is null', async () => {
const supabase = mockSupabase({ data: null, error: null });
const result = await fetchEventSponsors(supabase, 'e1');
expect(result).toEqual([]);
});
it('should throw on error', async () => {
const supabase = mockSupabase({ data: null, error: { message: 'sponsor fail' } });
await expect(fetchEventSponsors(supabase, 'e1')).rejects.toEqual({ message: 'sponsor fail' });
});
});
describe('fetchEventDeliverables', () => {
it('should return deliverables stripped of join data', async () => {
const raw = [
{ id: 'd1', description: 'Logo placement', is_completed: false, sponsors: { department_id: 'dep1', event_departments: { event_id: 'e1' } } },
];
const supabase = mockSupabase({ data: raw, error: null });
const result = await fetchEventDeliverables(supabase, 'e1');
expect(result).toHaveLength(1);
expect(result[0]).not.toHaveProperty('sponsors');
expect(result[0].description).toBe('Logo placement');
});
it('should return empty array when data is null', async () => {
const supabase = mockSupabase({ data: null, error: null });
const result = await fetchEventDeliverables(supabase, 'e1');
expect(result).toEqual([]);
});
it('should throw on error', async () => {
const supabase = mockSupabase({ data: null, error: { message: 'deliverable fail' } });
await expect(fetchEventDeliverables(supabase, 'e1')).rejects.toEqual({ message: 'deliverable fail' });
});
});
});

View File

@@ -0,0 +1,287 @@
import { describe, it, expect, vi } from 'vitest';
import {
SPONSOR_STATUSES,
STATUS_LABELS,
STATUS_COLORS,
fetchSponsorTiers,
createSponsorTier,
updateSponsorTier,
deleteSponsorTier,
fetchSponsors,
createSponsor,
updateSponsor,
deleteSponsor,
fetchDeliverables,
fetchAllDeliverables,
createDeliverable,
updateDeliverable,
deleteDeliverable,
} from './sponsors';
// ── Supabase mock builder ────────────────────────────────────────────────────
function mockChain(resolvedValue: { data: any; error: any }) {
const chain: any = {};
const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'in', 'order', 'single'];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
chain.single = vi.fn(() => Promise.resolve(resolvedValue));
chain.then = (resolve: any) => resolve(resolvedValue);
return chain;
}
function mockSupabase(resolvedValue: { data: any; error: any }) {
const chain = mockChain(resolvedValue);
return { from: vi.fn(() => chain), _chain: chain } as any;
}
// ── Constants ────────────────────────────────────────────────────────────────
describe('sponsor constants', () => {
it('SPONSOR_STATUSES has expected entries', () => {
expect(SPONSOR_STATUSES).toContain('prospect');
expect(SPONSOR_STATUSES).toContain('confirmed');
expect(SPONSOR_STATUSES).toContain('declined');
expect(SPONSOR_STATUSES).toContain('active');
expect(SPONSOR_STATUSES.length).toBe(5);
});
it('STATUS_LABELS has a label for every status', () => {
for (const s of SPONSOR_STATUSES) {
expect(STATUS_LABELS[s]).toBeDefined();
expect(typeof STATUS_LABELS[s]).toBe('string');
}
});
it('STATUS_COLORS has a color for every status', () => {
for (const s of SPONSOR_STATUSES) {
expect(STATUS_COLORS[s]).toBeDefined();
expect(STATUS_COLORS[s]).toMatch(/^#/);
}
});
});
// ── Sponsor Tiers ────────────────────────────────────────────────────────────
describe('fetchSponsorTiers', () => {
it('returns tiers for a department', async () => {
const tiers = [{ id: 't1', name: 'Gold', department_id: 'd1' }];
const sb = mockSupabase({ data: tiers, error: null });
expect(await fetchSponsorTiers(sb, 'd1')).toEqual(tiers);
});
it('returns empty array when null', async () => {
const sb = mockSupabase({ data: null, error: null });
expect(await fetchSponsorTiers(sb, 'd1')).toEqual([]);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchSponsorTiers(sb, 'd1')).rejects.toEqual({ message: 'fail' });
});
});
describe('createSponsorTier', () => {
it('creates with default color', async () => {
const tier = { id: 't1', name: 'Silver', amount: 0, color: '#F59E0B' };
const sb = mockSupabase({ data: tier, error: null });
expect(await createSponsorTier(sb, 'd1', 'Silver')).toEqual(tier);
});
it('creates with custom amount and color', async () => {
const tier = { id: 't2', name: 'Platinum', amount: 10000, color: '#00ff00' };
const sb = mockSupabase({ data: tier, error: null });
expect(await createSponsorTier(sb, 'd1', 'Platinum', 10000, '#00ff00')).toEqual(tier);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(createSponsorTier(sb, 'd1', 'X')).rejects.toEqual({ message: 'fail' });
});
});
describe('updateSponsorTier', () => {
it('updates and returns tier', async () => {
const tier = { id: 't1', name: 'Updated' };
const sb = mockSupabase({ data: tier, error: null });
expect(await updateSponsorTier(sb, 't1', { name: 'Updated' })).toEqual(tier);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateSponsorTier(sb, 't1', { name: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('deleteSponsorTier', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteSponsorTier(sb, 't1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteSponsorTier(sb, 't1')).rejects.toEqual({ message: 'fail' });
});
});
// ── Sponsors ─────────────────────────────────────────────────────────────────
describe('fetchSponsors', () => {
it('returns sponsors for a department', async () => {
const sponsors = [{ id: 's1', name: 'Acme', department_id: 'd1' }];
const sb = mockSupabase({ data: sponsors, error: null });
expect(await fetchSponsors(sb, 'd1')).toEqual(sponsors);
});
it('returns empty array when null', async () => {
const sb = mockSupabase({ data: null, error: null });
expect(await fetchSponsors(sb, 'd1')).toEqual([]);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchSponsors(sb, 'd1')).rejects.toEqual({ message: 'fail' });
});
});
describe('createSponsor', () => {
it('creates with minimal params', async () => {
const sponsor = { id: 's1', name: 'Acme', status: 'prospect', amount: 0 };
const sb = mockSupabase({ data: sponsor, error: null });
expect(await createSponsor(sb, 'd1', { name: 'Acme' })).toEqual(sponsor);
});
it('creates with all params', async () => {
const sponsor = { id: 's2', name: 'BigCo', status: 'confirmed', amount: 5000 };
const sb = mockSupabase({ data: sponsor, error: null });
expect(await createSponsor(sb, 'd1', {
name: 'BigCo',
tier_id: 't1',
contact_name: 'John',
contact_email: 'john@bigco.com',
contact_phone: '+1234',
website: 'https://bigco.com',
logo_url: 'https://bigco.com/logo.png',
status: 'confirmed',
amount: 5000,
notes: 'VIP sponsor',
})).toEqual(sponsor);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(createSponsor(sb, 'd1', { name: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('updateSponsor', () => {
it('updates and returns sponsor', async () => {
const sponsor = { id: 's1', name: 'Updated' };
const sb = mockSupabase({ data: sponsor, error: null });
expect(await updateSponsor(sb, 's1', { name: 'Updated' })).toEqual(sponsor);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateSponsor(sb, 's1', { name: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('deleteSponsor', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteSponsor(sb, 's1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteSponsor(sb, 's1')).rejects.toEqual({ message: 'fail' });
});
});
// ── Deliverables ─────────────────────────────────────────────────────────────
describe('fetchDeliverables', () => {
it('returns deliverables for a sponsor', async () => {
const delivs = [{ id: 'dl1', description: 'Logo placement', sponsor_id: 's1' }];
const sb = mockSupabase({ data: delivs, error: null });
expect(await fetchDeliverables(sb, 's1')).toEqual(delivs);
});
it('returns empty array when null', async () => {
const sb = mockSupabase({ data: null, error: null });
expect(await fetchDeliverables(sb, 's1')).toEqual([]);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchDeliverables(sb, 's1')).rejects.toEqual({ message: 'fail' });
});
});
describe('fetchAllDeliverables', () => {
it('returns empty array for empty sponsor list', async () => {
const sb = mockSupabase({ data: [], error: null });
expect(await fetchAllDeliverables(sb, [])).toEqual([]);
});
it('returns deliverables for multiple sponsors', async () => {
const delivs = [
{ id: 'dl1', sponsor_id: 's1' },
{ id: 'dl2', sponsor_id: 's2' },
];
const sb = mockSupabase({ data: delivs, error: null });
expect(await fetchAllDeliverables(sb, ['s1', 's2'])).toEqual(delivs);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(fetchAllDeliverables(sb, ['s1'])).rejects.toEqual({ message: 'fail' });
});
});
describe('createDeliverable', () => {
it('creates with description only', async () => {
const deliv = { id: 'dl1', description: 'Banner', sponsor_id: 's1' };
const sb = mockSupabase({ data: deliv, error: null });
expect(await createDeliverable(sb, 's1', 'Banner')).toEqual(deliv);
});
it('creates with due date', async () => {
const deliv = { id: 'dl2', description: 'Video', sponsor_id: 's1', due_date: '2025-06-01' };
const sb = mockSupabase({ data: deliv, error: null });
expect(await createDeliverable(sb, 's1', 'Video', '2025-06-01')).toEqual(deliv);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(createDeliverable(sb, 's1', 'X')).rejects.toEqual({ message: 'fail' });
});
});
describe('updateDeliverable', () => {
it('updates and returns deliverable', async () => {
const deliv = { id: 'dl1', description: 'Updated' };
const sb = mockSupabase({ data: deliv, error: null });
expect(await updateDeliverable(sb, 'dl1', { description: 'Updated' })).toEqual(deliv);
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(updateDeliverable(sb, 'dl1', { description: 'X' })).rejects.toEqual({ message: 'fail' });
});
});
describe('deleteDeliverable', () => {
it('deletes without error', async () => {
const sb = mockSupabase({ data: null, error: null });
await expect(deleteDeliverable(sb, 'dl1')).resolves.toBeUndefined();
});
it('throws on error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'fail' } });
await expect(deleteDeliverable(sb, 'dl1')).rejects.toEqual({ message: 'fail' });
});
});

365
src/lib/api/sponsors.ts Normal file
View File

@@ -0,0 +1,365 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { SponsorTier, Sponsor, SponsorDeliverable } from '$lib/supabase/types';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api.sponsors');
// Helper to cast supabase for tables not yet in generated types
function db(supabase: SupabaseClient) {
return supabase as any;
}
export const SPONSOR_STATUSES = ['prospect', 'contacted', 'confirmed', 'declined', 'active'] as const;
export type SponsorStatus = (typeof SPONSOR_STATUSES)[number];
export const STATUS_LABELS: Record<string, string> = {
prospect: 'Prospect',
contacted: 'Contacted',
confirmed: 'Confirmed',
declined: 'Declined',
active: 'Active',
};
export const STATUS_COLORS: Record<string, string> = {
prospect: '#94a3b8',
contacted: '#F59E0B',
confirmed: '#10B981',
declined: '#EF4444',
active: '#6366f1',
};
// ============================================================
// Sponsor Tiers
// ============================================================
export async function fetchSponsorTiers(
supabase: SupabaseClient,
departmentId: string
): Promise<SponsorTier[]> {
const { data, error } = await db(supabase)
.from('sponsor_tiers')
.select('*')
.eq('department_id', departmentId)
.order('sort_order');
if (error) {
log.error('fetchSponsorTiers failed', { error, data: { departmentId } });
throw error;
}
return (data ?? []) as SponsorTier[];
}
export async function createSponsorTier(
supabase: SupabaseClient,
departmentId: string,
name: string,
amount?: number,
color?: string
): Promise<SponsorTier> {
const { data, error } = await db(supabase)
.from('sponsor_tiers')
.insert({
department_id: departmentId,
name,
amount: amount ?? 0,
color: color ?? '#F59E0B',
})
.select()
.single();
if (error) {
log.error('createSponsorTier failed', { error, data: { departmentId, name } });
throw error;
}
return data as SponsorTier;
}
export async function updateSponsorTier(
supabase: SupabaseClient,
tierId: string,
params: Partial<Pick<SponsorTier, 'name' | 'amount' | 'color' | 'sort_order'>>
): Promise<SponsorTier> {
const { data, error } = await db(supabase)
.from('sponsor_tiers')
.update(params)
.eq('id', tierId)
.select()
.single();
if (error) {
log.error('updateSponsorTier failed', { error, data: { tierId } });
throw error;
}
return data as SponsorTier;
}
export async function deleteSponsorTier(
supabase: SupabaseClient,
tierId: string
): Promise<void> {
const { error } = await db(supabase)
.from('sponsor_tiers')
.delete()
.eq('id', tierId);
if (error) {
log.error('deleteSponsorTier failed', { error, data: { tierId } });
throw error;
}
}
// ============================================================
// Event-wide Tier + Sponsor fetching
// ============================================================
export async function fetchEventSponsorTiers(
supabase: SupabaseClient,
eventId: string
): Promise<SponsorTier[]> {
const { data, error } = await db(supabase)
.from('sponsor_tiers')
.select('*, event_departments!inner(event_id)')
.eq('event_departments.event_id', eventId)
.order('sort_order');
if (error) {
log.error('fetchEventSponsorTiers failed', { error, data: { eventId } });
throw error;
}
return (data ?? []).map((d: any) => {
const { event_departments, ...tier } = d;
return tier;
}) as SponsorTier[];
}
export async function fetchEventSponsors(
supabase: SupabaseClient,
eventId: string
): Promise<Sponsor[]> {
const { data, error } = await db(supabase)
.from('sponsors')
.select('*, event_departments!inner(event_id)')
.eq('event_departments.event_id', eventId)
.order('name');
if (error) {
log.error('fetchEventSponsors failed', { error, data: { eventId } });
throw error;
}
return (data ?? []).map((d: any) => {
const { event_departments, ...sponsor } = d;
return sponsor;
}) as Sponsor[];
}
export async function fetchEventDeliverables(
supabase: SupabaseClient,
eventId: string
): Promise<SponsorDeliverable[]> {
const { data, error } = await db(supabase)
.from('sponsor_deliverables')
.select('*, sponsors!inner(department_id, event_departments!inner(event_id))')
.eq('sponsors.event_departments.event_id', eventId)
.order('sort_order');
if (error) {
log.error('fetchEventDeliverables failed', { error, data: { eventId } });
throw error;
}
return (data ?? []).map((d: any) => {
const { sponsors, ...del } = d;
return del;
}) as SponsorDeliverable[];
}
// ============================================================
// Sponsors
// ============================================================
export async function fetchSponsors(
supabase: SupabaseClient,
departmentId: string
): Promise<Sponsor[]> {
const { data, error } = await db(supabase)
.from('sponsors')
.select('*')
.eq('department_id', departmentId)
.order('name');
if (error) {
log.error('fetchSponsors failed', { error, data: { departmentId } });
throw error;
}
return (data ?? []) as Sponsor[];
}
export async function createSponsor(
supabase: SupabaseClient,
departmentId: string,
params: {
name: string;
tier_id?: string | null;
contact_name?: string;
contact_email?: string;
contact_phone?: string;
website?: string;
logo_url?: string;
status?: SponsorStatus;
amount?: number;
notes?: string;
}
): Promise<Sponsor> {
const { data, error } = await db(supabase)
.from('sponsors')
.insert({
department_id: departmentId,
name: params.name,
tier_id: params.tier_id ?? null,
contact_name: params.contact_name ?? null,
contact_email: params.contact_email ?? null,
contact_phone: params.contact_phone ?? null,
website: params.website ?? null,
logo_url: params.logo_url ?? null,
status: params.status ?? 'prospect',
amount: params.amount ?? 0,
notes: params.notes ?? null,
})
.select()
.single();
if (error) {
log.error('createSponsor failed', { error, data: { departmentId, name: params.name } });
throw error;
}
return data as Sponsor;
}
export async function updateSponsor(
supabase: SupabaseClient,
sponsorId: string,
params: Partial<Pick<Sponsor, 'name' | 'tier_id' | 'contact_name' | 'contact_email' | 'contact_phone' | 'website' | 'logo_url' | 'status' | 'amount' | 'notes'>>
): Promise<Sponsor> {
const { data, error } = await db(supabase)
.from('sponsors')
.update({ ...params, updated_at: new Date().toISOString() })
.eq('id', sponsorId)
.select()
.single();
if (error) {
log.error('updateSponsor failed', { error, data: { sponsorId } });
throw error;
}
return data as Sponsor;
}
export async function deleteSponsor(
supabase: SupabaseClient,
sponsorId: string
): Promise<void> {
const { error } = await db(supabase)
.from('sponsors')
.delete()
.eq('id', sponsorId);
if (error) {
log.error('deleteSponsor failed', { error, data: { sponsorId } });
throw error;
}
}
// ============================================================
// Sponsor Deliverables
// ============================================================
export async function fetchDeliverables(
supabase: SupabaseClient,
sponsorId: string
): Promise<SponsorDeliverable[]> {
const { data, error } = await db(supabase)
.from('sponsor_deliverables')
.select('*')
.eq('sponsor_id', sponsorId)
.order('sort_order');
if (error) {
log.error('fetchDeliverables failed', { error, data: { sponsorId } });
throw error;
}
return (data ?? []) as SponsorDeliverable[];
}
export async function fetchAllDeliverables(
supabase: SupabaseClient,
sponsorIds: string[]
): Promise<SponsorDeliverable[]> {
if (sponsorIds.length === 0) return [];
const { data, error } = await db(supabase)
.from('sponsor_deliverables')
.select('*')
.in('sponsor_id', sponsorIds)
.order('sort_order');
if (error) {
log.error('fetchAllDeliverables failed', { error, data: { sponsorIds } });
throw error;
}
return (data ?? []) as SponsorDeliverable[];
}
export async function createDeliverable(
supabase: SupabaseClient,
sponsorId: string,
description: string,
dueDate?: string
): Promise<SponsorDeliverable> {
const { data, error } = await db(supabase)
.from('sponsor_deliverables')
.insert({
sponsor_id: sponsorId,
description,
due_date: dueDate ?? null,
})
.select()
.single();
if (error) {
log.error('createDeliverable failed', { error, data: { sponsorId, description } });
throw error;
}
return data as SponsorDeliverable;
}
export async function updateDeliverable(
supabase: SupabaseClient,
deliverableId: string,
params: Partial<Pick<SponsorDeliverable, 'description' | 'is_completed' | 'due_date' | 'sort_order'>>
): Promise<SponsorDeliverable> {
const { data, error } = await db(supabase)
.from('sponsor_deliverables')
.update({ ...params, updated_at: new Date().toISOString() })
.eq('id', deliverableId)
.select()
.single();
if (error) {
log.error('updateDeliverable failed', { error, data: { deliverableId } });
throw error;
}
return data as SponsorDeliverable;
}
export async function deleteDeliverable(
supabase: SupabaseClient,
deliverableId: string
): Promise<void> {
const { error } = await db(supabase)
.from('sponsor_deliverables')
.delete()
.eq('id', deliverableId);
if (error) {
log.error('deleteDeliverable failed', { error, data: { deliverableId } });
throw error;
}
}

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import type { CalendarEvent } from "$lib/supabase/types";
import { getMonthDays, isSameDay } from "$lib/api/calendar";
import * as m from "$lib/paraglide/messages";
type ViewType = "month" | "week" | "day";
@@ -19,16 +20,24 @@
}: Props = $props();
let currentDate = $state(new Date());
// svelte-ignore state_referenced_locally
let currentView = $state<ViewType>(initialView);
const today = new Date();
const weekDayHeaders = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const weekDayHeaders = $derived([
m.calendar_day_mon(),
m.calendar_day_tue(),
m.calendar_day_wed(),
m.calendar_day_thu(),
m.calendar_day_fri(),
m.calendar_day_sat(),
m.calendar_day_sun(),
]);
const days = $derived(
getMonthDays(currentDate.getFullYear(), currentDate.getMonth()),
);
// Group days into weeks (rows of 7)
const weeks = $derived.by(() => {
const result: Date[][] = [];
for (let i = 0; i < days.length; i += 7) {
@@ -48,14 +57,13 @@
return date.getMonth() === currentDate.getMonth();
}
const monthYear = $derived(
currentDate.toLocaleDateString("en-US", {
const headerTitle = $derived(
currentDate.toLocaleDateString(undefined, {
month: "long",
year: "numeric",
}),
);
// Get week days for week view (Mon-Sun)
function getWeekDays(date: Date): Date[] {
const startOfWeek = new Date(date);
const dayOfWeek = startOfWeek.getDay();
@@ -106,212 +114,332 @@
currentDate = new Date();
}
const headerTitle = $derived.by(() => {
if (currentView === "day") {
return currentDate.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
} else if (currentView === "week") {
const start = weekDates[0];
const end = weekDates[6];
return `${start.toLocaleDateString("en-US", { month: "short", day: "numeric" })} - ${end.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}`;
}
return monthYear;
});
const iconStyle =
"font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;";
</script>
<div class="flex flex-col h-full">
<!-- Navigation bar -->
<div class="flex items-center justify-between px-4 py-2 shrink-0">
<div class="flex items-center gap-1">
<div class="flex flex-col h-full gap-4">
<!-- Toolbar / PageBar -->
<div class="flex items-center justify-between shrink-0">
<!-- Left: Today + prev/next + month-year -->
<div class="flex items-center">
<button
class="p-1.5 text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
type="button"
class="bg-surface rounded-[32px] px-3 py-2 font-body font-bold text-btn-sm text-white hover:opacity-80 transition-opacity shrink-0 mr-1"
onclick={goToToday}
>
{m.calendar_today()}
</button>
<button
type="button"
class="flex items-center justify-center size-8 p-1 rounded-full hover:bg-surface transition-colors"
onclick={prev}
aria-label="Previous"
aria-label={m.calendar_previous()}
>
<span
class="material-symbols-rounded"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>chevron_left</span
class="material-symbols-rounded text-white"
style={iconStyle}>chevron_backward</span
>
</button>
<span
class="font-heading text-body-sm text-white min-w-[180px] text-center"
>{headerTitle}</span
class="font-heading text-h3 text-white text-center whitespace-nowrap px-2 min-w-[180px]"
>
{headerTitle}
</span>
<button
class="p-1.5 text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
type="button"
class="flex items-center justify-center size-8 p-1 rounded-full hover:bg-surface transition-colors"
onclick={next}
aria-label="Next"
aria-label={m.calendar_next()}
>
<span
class="material-symbols-rounded"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>chevron_right</span
class="material-symbols-rounded text-white"
style={iconStyle}>chevron_forward</span
>
</button>
<button
class="px-2.5 py-1 text-body-sm font-body text-light/50 hover:text-white hover:bg-dark/50 rounded-lg transition-colors ml-1"
onclick={goToToday}
>
Today
</button>
</div>
<div class="flex gap-0.5 bg-dark/30 rounded-lg p-0.5">
<!-- Right: Day/Week/Month + New -->
<div class="flex items-center gap-1">
<button
class="px-2.5 py-1 text-[12px] font-body rounded-md transition-colors {currentView ===
type="button"
class="px-3 py-2 rounded-[32px] font-body font-bold text-btn-sm transition-colors {currentView ===
'day'
? 'bg-primary text-background'
: 'text-light/50 hover:text-white'}"
onclick={() => (currentView = "day")}>Day</button
: 'bg-surface text-white hover:opacity-80'}"
onclick={() => (currentView = "day")}
>{m.calendar_view_day()}</button
>
<button
class="px-2.5 py-1 text-[12px] font-body rounded-md transition-colors {currentView ===
type="button"
class="px-3 py-2 rounded-[32px] font-body font-bold text-btn-sm transition-colors {currentView ===
'week'
? 'bg-primary text-background'
: 'text-light/50 hover:text-white'}"
onclick={() => (currentView = "week")}>Week</button
: 'bg-surface text-white hover:opacity-80'}"
onclick={() => (currentView = "week")}
>{m.calendar_view_week()}</button
>
<button
class="px-2.5 py-1 text-[12px] font-body rounded-md transition-colors {currentView ===
type="button"
class="px-3 py-2 rounded-[32px] font-body font-bold text-btn-sm transition-colors {currentView ===
'month'
? 'bg-primary text-background'
: 'text-light/50 hover:text-white'}"
onclick={() => (currentView = "month")}>Month</button
: 'bg-surface text-white hover:opacity-80'}"
onclick={() => (currentView = "month")}
>{m.calendar_view_month()}</button
>
</div>
</div>
<!-- Month View -->
{#if currentView === "month"}
<div class="flex flex-col flex-1 min-h-0">
<!-- Day Headers -->
<div class="grid grid-cols-7 border-b border-light/5">
<!-- Calendar content card -->
<div
class="flex-1 flex flex-col min-h-0 bg-background rounded-[32px] overflow-hidden px-4 py-5 gap-2"
>
<!-- ── MONTH VIEW ── -->
{#if currentView === "month"}
<!-- Day name headers -->
<div class="flex shrink-0 h-[51px] items-center">
{#each weekDayHeaders as day}
<div class="flex items-center justify-center py-2">
<span class="font-body text-[11px] text-light/40 uppercase tracking-wider">{day}</span>
<div class="flex-1 flex items-center justify-center">
<span class="font-heading text-h4 text-white"
>{day}</span
>
</div>
{/each}
</div>
<!-- Calendar Grid -->
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
<!-- Month grid: rows of weeks -->
<div
class="flex-1 flex flex-col gap-2 min-h-0 overflow-hidden rounded-[32px]"
>
{#each weeks as week}
<div class="grid grid-cols-7 flex-1 border-b border-light/5 last:border-b-0">
<div class="flex flex-1 min-h-0 gap-2">
{#each week as day}
{@const dayEvents = getEventsForDay(day)}
{@const isToday = isSameDay(day, today)}
{@const inMonth = isCurrentMonth(day)}
<button
type="button"
class="flex flex-col items-start px-1.5 py-1 overflow-hidden transition-colors hover:bg-dark/30 min-h-0 cursor-pointer border-r border-light/5 last:border-r-0
{!inMonth ? 'opacity-40' : ''}"
onclick={() => onDateClick?.(day)}
class="flex-1 flex flex-col items-start bg-night p-4 gap-2 overflow-hidden hover:opacity-90 transition-opacity {!inMonth
? 'opacity-50'
: ''} {isToday
? 'ring-4 ring-inset ring-primary'
: ''}"
onclick={() => {
currentDate = day;
currentView = "day";
}}
>
<span
class="text-[12px] font-body w-6 h-6 flex items-center justify-center rounded-full shrink-0
{isToday ? 'bg-primary text-background font-bold' : 'text-light/60'}"
<div
class="flex items-center justify-center shrink-0 size-6 {isToday
? 'bg-primary rounded-full'
: ''}"
>
{day.getDate()}
</span>
<span
class="font-body text-body leading-none {isToday
? 'font-bold text-background'
: 'text-white'}"
>
{day.getDate()}
</span>
</div>
{#each dayEvents.slice(0, 2) as event}
<button
class="w-full mt-0.5 px-1.5 py-0.5 rounded text-[11px] font-body text-night truncate text-left font-medium"
style="background-color: {event.color ?? '#00A3E0'}"
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="w-full px-1 py-0.5 rounded-[4px] font-body font-bold text-btn-sm text-night truncate text-left shrink-0"
style="background-color: {event.color ??
'#00A3E0'}"
role="button"
tabindex="0"
onclick={(e) => {
e.stopPropagation();
onEventClick?.(event);
}}
>
{event.title}
</button>
</div>
{/each}
{#if dayEvents.length > 2}
<span class="text-[10px] text-light/30 mt-0.5 px-1">+{dayEvents.length - 2}</span>
<span class="text-body-sm text-white/50"
>+{dayEvents.length - 2}</span
>
{/if}
</button>
{/each}
</div>
{/each}
</div>
</div>
{/if}
<!-- Week View -->
{#if currentView === "week"}
<div class="flex flex-col flex-1 min-h-0">
<div class="grid grid-cols-7 flex-1 overflow-hidden">
<!-- ── WEEK VIEW ── -->
{:else if currentView === "week"}
<!-- Day name headers -->
<div class="flex shrink-0 h-[51px] items-center">
{#each weekDates as day}
{@const dayEvents = getEventsForDay(day)}
{@const isToday = isSameDay(day, today)}
<div class="flex flex-col overflow-hidden border-r border-light/5 last:border-r-0">
<div class="px-2 py-2 text-center border-b border-light/5">
<div class="text-[11px] font-body uppercase tracking-wider {isToday ? 'text-primary' : 'text-light/40'}">
{weekDayHeaders[(day.getDay() + 6) % 7]}
</div>
<div class="text-body-sm font-heading mt-0.5 {isToday ? 'text-primary' : 'text-white'}">
<div
class="flex-1 flex flex-col items-center justify-center gap-0.5"
>
<span
class="font-heading text-h4 {isToday
? 'text-primary'
: 'text-white'}"
>
{weekDayHeaders[(day.getDay() + 6) % 7]}
</span>
<div
class="flex items-center justify-center size-6 {isToday
? 'bg-primary rounded-full'
: ''}"
>
<span
class="font-body text-body-md leading-none {isToday
? 'font-bold text-background'
: 'text-white/50'}"
>
{day.getDate()}
</div>
</div>
<div class="flex-1 px-1.5 py-1.5 space-y-1 overflow-y-auto">
{#each dayEvents as event}
<button
class="w-full px-2 py-1.5 rounded text-[11px] font-body text-night truncate text-left font-medium"
style="background-color: {event.color ?? '#00A3E0'}"
onclick={() => onEventClick?.(event)}
>
{event.title}
</button>
{/each}
</span>
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Day View -->
{#if currentView === "day"}
{@const dayEvents = getEventsForDay(currentDate)}
<div class="flex-1 px-4 py-4 min-h-0 overflow-auto">
{#if dayEvents.length === 0}
<div class="flex flex-col items-center justify-center h-full text-light/40">
<span class="material-symbols-rounded mb-3" style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;">event_busy</span>
<p class="text-body-sm">No events for this day</p>
</div>
{:else}
<div class="space-y-2">
{#each dayEvents as event}
<button
class="w-full text-left p-3 rounded-xl border border-light/5 hover:border-light/10 transition-all"
style="border-left: 3px solid {event.color ?? '#00A3E0'}"
onclick={() => onEventClick?.(event)}
>
<div class="font-heading text-body-sm text-white">
<!-- Single week row -->
<div
class="flex flex-1 min-h-0 gap-2 overflow-hidden rounded-[32px]"
>
{#each weekDates as day}
{@const dayEvents = getEventsForDay(day)}
{@const isToday = isSameDay(day, today)}
<button
type="button"
class="flex-1 flex flex-col items-start bg-night p-4 gap-2 overflow-y-auto hover:opacity-95 transition-opacity {isToday
? 'ring-4 ring-inset ring-primary'
: ''}"
onclick={() => {
currentDate = day;
currentView = "day";
}}
>
{#each dayEvents as event}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="w-full px-1 py-0.5 rounded-[4px] font-body font-bold text-btn-sm text-night truncate text-left shrink-0"
style="background-color: {event.color ??
'#00A3E0'}"
role="button"
tabindex="0"
onclick={(e) => {
e.stopPropagation();
onEventClick?.(event);
}}
>
{event.title}
</div>
<div class="text-[12px] text-light/40 mt-1">
{new Date(event.start_time).toLocaleTimeString(
"en-US",
{ hour: "numeric", minute: "2-digit" },
)}
- {new Date(event.end_time).toLocaleTimeString(
"en-US",
{ hour: "numeric", minute: "2-digit" },
)}
</div>
{#if event.description}
<div class="text-[12px] text-light/30 mt-1.5 line-clamp-2">
{event.description}
</div>
{/if}
</button>
{/each}
{/each}
{#if dayEvents.length === 0}
<span class="text-white/20 text-body-sm"
>{m.calendar_no_events()}</span
>
{/if}
</button>
{/each}
</div>
<!-- ── DAY VIEW ── -->
{:else}
{@const dayEvents = getEventsForDay(currentDate)}
{@const isToday = isSameDay(currentDate, today)}
<!-- Day header -->
<div class="flex shrink-0 h-[51px] items-center">
<div
class="flex-1 flex flex-col items-center justify-center gap-0.5"
>
<span
class="font-heading text-h4 {isToday
? 'text-primary'
: 'text-white'}"
>
{currentDate.toLocaleDateString(undefined, {
weekday: "long",
})}
</span>
<div
class="flex items-center justify-center size-6 {isToday
? 'bg-primary rounded-full'
: ''}"
>
<span
class="font-body text-body-md leading-none {isToday
? 'font-bold text-background'
: 'text-white/50'}"
>
{currentDate.getDate()}
</span>
</div>
</div>
{/if}
</div>
{/if}
</div>
<!-- Day content -->
<div class="flex-1 min-h-0 rounded-[32px] overflow-hidden">
<div
class="h-full bg-night p-4 overflow-y-auto flex flex-col gap-2"
>
{#if dayEvents.length === 0}
<div
class="flex flex-col items-center justify-center h-full text-white/30"
>
<span
class="material-symbols-rounded mb-3"
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
>event_busy</span
>
<p class="font-body text-body">
{m.calendar_no_events()}
</p>
</div>
{:else}
{#each dayEvents as event}
<button
type="button"
class="w-full text-left p-3 rounded-[4px] font-body font-bold text-btn-sm text-night hover:opacity-90 transition-opacity"
style="background-color: {event.color ??
'#00A3E0'}"
onclick={() => onEventClick?.(event)}
>
<div class="text-night truncate">
{event.title}
</div>
<div
class="text-night/70 font-normal text-body-sm mt-0.5"
>
{new Date(
event.start_time,
).toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
})}
{new Date(
event.end_time,
).toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
})}
</div>
{#if event.description}
<div
class="text-night/60 font-normal text-body-sm mt-1 line-clamp-2"
>
{event.description}
</div>
{/if}
</button>
{/each}
{/if}
</div>
</div>
{/if}
</div>
</div>

View File

@@ -268,8 +268,6 @@
{onEdit}
{onDelete}
{onReply}
{onLoadMore}
isLoading={isLoadingMore}
/>
<TypingIndicator userNames={typingUsers} />
<MessageInput

View File

@@ -231,7 +231,7 @@
<button
class="size-10 rounded-full cursor-pointer border-2 transition-all hover:scale-110 flex items-center justify-center {currentPrimary ===
color.primary
? 'border-white ring-2 ring-white/30'
? 'border-white ring-4 ring-white/30'
: 'border-transparent'}"
style="background-color: {color.primary}"
title={color.name}

View File

@@ -1,11 +1,7 @@
<script lang="ts">
import { getContext } from "svelte";
import { goto } from "$app/navigation";
import {
Button,
Modal,
Input,
} from "$lib/components/ui";
import { Button, Modal, Input } from "$lib/components/ui";
import { DocumentViewer } from "$lib/components/documents";
import { createLogger } from "$lib/utils/logger";
import { toasts } from "$lib/stores/toast.svelte";
@@ -20,6 +16,9 @@
deleteDocument,
createDocument,
copyDocument,
uploadFile,
getFileMetadata,
formatFileSize,
} from "$lib/api/documents";
const log = createLogger("component.file-browser");
@@ -31,6 +30,7 @@
user: { id: string } | null;
/** Page title shown in the header */
title?: string;
showCreateModal?: boolean;
}
let {
@@ -39,16 +39,20 @@
currentFolderId,
user,
title = m.files_title(),
showCreateModal = $bindable(false),
}: 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 fileUploadInput: HTMLInputElement | undefined = $state();
let fileUploading = $state(false);
let fileUploadProgress = $state("");
let fileDraggingOver = $state(false);
let viewMode = $state<"list" | "grid">(
typeof localStorage !== "undefined" &&
localStorage.getItem("root:viewMode") === "list"
@@ -74,7 +78,8 @@
if (type === "folder") return 0;
if (type === "document") return 1;
if (type === "kanban") return 2;
return 3;
if (type === "file") return 3;
return 4;
}
const currentFolderItems = $derived(
@@ -129,9 +134,17 @@
function getDocIcon(doc: Document): string {
if (doc.type === "folder") return "folder";
if (doc.type === "kanban") return "view_kanban";
if (doc.type === "file") {
const meta = getFileMetadata(doc);
return meta ? getFileIcon(meta.mime_type) : "attach_file";
}
return "description";
}
function getDocColor(doc: Document): string {
return "text-white";
}
function handleItemClick(doc: Document) {
if (isDragging) {
isDragging = false;
@@ -141,6 +154,11 @@
goto(getFolderUrl(doc.id));
} else if (doc.type === "kanban") {
goto(getFileUrl(doc));
} else if (doc.type === "file") {
const meta = getFileMetadata(doc);
if (meta?.public_url) {
window.open(meta.public_url, "_blank");
}
} else {
selectedDoc = doc;
}
@@ -149,6 +167,9 @@
function handleDoubleClick(doc: Document) {
if (doc.type === "folder") {
window.open(getFolderUrl(doc.id), "_blank");
} else if (doc.type === "file") {
const meta = getFileMetadata(doc);
if (meta?.public_url) window.open(meta.public_url, "_blank");
} else {
window.open(getFileUrl(doc), "_blank");
}
@@ -194,7 +215,7 @@
documents = [...documents, newDoc];
toasts.success(`Copied "${doc.name}"`);
} catch {
toasts.error("Failed to copy document");
toasts.error(m.toast_error_create_document());
}
}
@@ -229,6 +250,95 @@
showCreateModal = true;
}
async function handleFileUpload(files: FileList | File[]) {
if (!user || files.length === 0) return;
fileUploading = true;
try {
const fileArr = Array.from(files);
for (let i = 0; i < fileArr.length; i++) {
fileUploadProgress = `Uploading ${i + 1}/${fileArr.length}: ${fileArr[i].name}`;
const newDoc = await uploadFile(
supabase,
org.id,
currentFolderId,
user.id,
fileArr[i],
);
documents = [...documents, newDoc];
logActivity(supabase, {
orgId: org.id,
userId: user.id,
action: "create",
entityType: "document",
entityId: newDoc.id,
entityName: fileArr[i].name,
});
}
toasts.success(
fileArr.length === 1
? `Uploaded "${fileArr[0].name}"`
: `Uploaded ${fileArr.length} files`,
);
} catch {
toasts.error(m.toast_error_upload_file());
} finally {
fileUploading = false;
fileUploadProgress = "";
fileDraggingOver = false;
}
}
function handleFileInputChange(e: globalThis.Event) {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
handleFileUpload(input.files);
input.value = "";
}
}
function handleFileDrop(e: DragEvent) {
// Only handle file drops from OS, not internal document drags
if (draggedItem) return;
e.preventDefault();
fileDraggingOver = false;
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
handleFileUpload(e.dataTransfer.files);
}
}
function handleFileDragOver(e: DragEvent) {
if (draggedItem) return;
if (e.dataTransfer?.types?.includes("Files")) {
e.preventDefault();
fileDraggingOver = true;
}
}
function handleFileDragLeave() {
fileDraggingOver = false;
}
function getFileIcon(mimeType: string): string {
if (mimeType.startsWith("image/")) return "image";
if (mimeType.startsWith("video/")) return "movie";
if (mimeType.startsWith("audio/")) return "audio_file";
if (mimeType === "application/pdf") return "picture_as_pdf";
if (mimeType.includes("spreadsheet") || mimeType.includes("excel"))
return "table_chart";
if (
mimeType.includes("presentation") ||
mimeType.includes("powerpoint")
)
return "slideshow";
if (
mimeType.includes("zip") ||
mimeType.includes("archive") ||
mimeType.includes("compressed")
)
return "folder_zip";
return "attach_file";
}
// Drag handlers
function handleDragStart(e: DragEvent, doc: Document) {
isDragging = true;
@@ -303,7 +413,7 @@
try {
await moveDocument(supabase, docId, newParentId);
} catch {
toasts.error("Failed to move file");
toasts.error(m.toast_error_move_document());
const { data: freshDocs } = await supabase
.from("documents")
.select(
@@ -327,7 +437,7 @@
.select()
.single();
if (boardError || !newBoard) {
toasts.error("Failed to create kanban board");
toasts.error(m.toast_error_create_document());
return;
}
await supabase.from("kanban_columns").insert([
@@ -382,7 +492,7 @@
}
}
} catch {
toasts.error("Failed to create document");
toasts.error(m.toast_error_create_document());
}
showCreateModal = false;
@@ -398,7 +508,7 @@
d.id === selectedDoc!.id ? { ...d, content } : d,
);
} catch {
toasts.error("Failed to save document");
toasts.error(m.toast_error_create_document());
}
}
@@ -424,7 +534,7 @@
selectedDoc = { ...selectedDoc, name: newDocName };
}
} catch {
toasts.error("Failed to rename document");
toasts.error(m.toast_error_rename_document());
}
showEditModal = false;
editingDoc = null;
@@ -482,40 +592,77 @@
selectedDoc = null;
}
} catch {
toasts.error("Failed to delete document");
toasts.error(m.toast_error_delete_document());
}
}
</script>
<!-- Hidden file input -->
<input
bind:this={fileUploadInput}
type="file"
multiple
class="hidden"
onchange={handleFileInputChange}
/>
<div class="flex h-full gap-0">
<!-- Files Panel -->
<div class="flex flex-col flex-1 min-w-0 h-full overflow-hidden">
<!-- Toolbar: Breadcrumbs + Actions -->
<div class="flex items-center gap-2 px-6 py-3 border-b border-light/5 shrink-0">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex flex-col flex-1 min-w-0 h-full overflow-hidden relative {fileDraggingOver
? 'ring-4 ring-primary ring-inset'
: ''}"
role="region"
ondragover={handleFileDragOver}
ondragleave={handleFileDragLeave}
ondrop={handleFileDrop}
>
<!-- File drag overlay -->
{#if fileDraggingOver}
<div
class="absolute inset-0 bg-primary/5 z-20 flex items-center justify-center pointer-events-none"
>
<div class="flex flex-col items-center gap-2">
<span
class="material-symbols-rounded text-primary"
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
>cloud_upload</span
>
<p class="text-body text-primary font-heading">
Drop files to upload
</p>
</div>
</div>
{/if}
<!-- Breadcrumb Bar -->
<div
class="flex flex-wrap items-center justify-between gap-y-2 w-full shrink-0"
>
<!-- Breadcrumb Path -->
<nav class="flex items-center gap-1 flex-1 min-w-0 overflow-x-auto">
<nav class="flex items-center flex-1 min-w-0 overflow-x-auto">
{#each breadcrumbPath as crumb, i}
{#if i > 0}
<span
class="material-symbols-rounded text-light/20 shrink-0"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>
chevron_right
<span class="flex items-center p-1 shrink-0">
<span
class="material-symbols-rounded text-white"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>chevron_forward</span
>
</span>
{/if}
<a
href={getFolderUrl(crumb.id)}
class="px-2 py-1 rounded-lg text-body-sm font-body whitespace-nowrap transition-colors
{crumb.id === currentFolderId
? 'text-white bg-dark/30'
: 'text-light/50 hover:text-white hover:bg-dark/30'}
class="flex items-center gap-2 shrink-0 hover:opacity-80 transition-opacity
{dragOverBreadcrumb === (crumb.id ?? '__root__')
? 'ring-2 ring-primary bg-primary/10'
? 'ring-4 ring-primary bg-primary/10 rounded-lg'
: ''}"
ondragover={(e) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
if (e.dataTransfer)
e.dataTransfer.dropEffect = "move";
dragOverBreadcrumb = crumb.id ?? "__root__";
}}
ondragleave={() => {
@@ -538,52 +685,82 @@
resetDragState();
}}
>
{#if i === 0}
<span class="material-symbols-rounded" style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;">home</span>
{:else}
{crumb.name}
{/if}
<span class="flex items-center justify-center p-1">
<span
class="material-symbols-rounded text-white"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>{i === 0 ? "cloud" : "folder"}</span
>
</span>
<span
class="font-heading text-h3 text-white whitespace-nowrap"
>{crumb.name}</span
>
</a>
{/each}
</nav>
<Button size="sm" icon="add" onclick={handleAdd}>{m.btn_new()}</Button>
<button
type="button"
class="p-1.5 rounded-lg text-light/40 hover:text-white hover:bg-dark/50 transition-colors"
title={m.files_toggle_view()}
onclick={toggleViewMode}
>
<span
class="material-symbols-rounded"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>{viewMode === "list" ? "grid_view" : "view_list"}</span
<div class="flex items-center gap-2 shrink-0">
{#if fileUploading}
<div
class="flex items-center gap-2 px-3 py-1.5 bg-primary/10 rounded-[32px]"
>
<span
class="material-symbols-rounded text-primary animate-spin"
style="font-size: 14px;">progress_activity</span
>
<span
class="text-body-sm text-primary truncate max-w-[200px]"
>{fileUploadProgress}</span
>
</div>
{/if}
<button
type="button"
class="flex items-center justify-center p-1 shrink-0 hover:bg-white/5 rounded-lg transition-colors"
title={m.files_toggle_view()}
onclick={toggleViewMode}
>
</button>
<span
class="material-symbols-rounded text-white"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>{viewMode === "list" ? "grid_view" : "list"}</span
>
</button>
</div>
</div>
<!-- File List/Grid -->
<div class="flex-1 overflow-auto min-h-0 p-4">
<div class="flex-1 overflow-auto min-h-0 py-4">
{#if viewMode === "list"}
<!-- List View -->
<div
class="flex flex-col gap-0.5"
class="flex flex-col"
ondragover={handleContainerDragOver}
ondrop={handleDropOnEmpty}
role="list"
>
{#if currentFolderItems.length === 0}
<div class="flex flex-col items-center justify-center text-light/40 py-16">
<span class="material-symbols-rounded mb-3" style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;">folder_open</span>
<p class="text-body-sm">{m.files_empty()}</p>
<div
class="flex flex-col items-center justify-center text-white/30 py-16"
>
<span
class="material-symbols-rounded mb-3"
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
>folder_open</span
>
<p class="font-body text-body">{m.files_empty()}</p>
</div>
{:else}
{#each currentFolderItems as item}
<button
type="button"
class="flex items-center gap-3 px-3 py-2 rounded-xl w-full text-left transition-colors hover:bg-dark/50
{selectedDoc?.id === item.id ? 'bg-dark/50 ring-1 ring-primary/20' : ''}
class="flex items-center justify-between p-2 rounded-[32px] w-full text-left transition-all overflow-clip
{selectedDoc?.id === item.id
? 'bg-surface ring-4 ring-primary'
: 'hover:bg-surface'}
{draggedItem?.id === item.id ? 'opacity-50' : ''}
{dragOverFolder === item.id ? 'ring-2 ring-primary bg-primary/10' : ''}"
{dragOverFolder === item.id ? 'ring-4 ring-primary bg-primary/10' : ''}"
draggable="true"
ondragstart={(e) => handleDragStart(e, item)}
ondragend={handleDragEnd}
@@ -596,24 +773,32 @@
oncontextmenu={(e) =>
handleContextMenu(e, item)}
>
<span
class="material-symbols-rounded shrink-0 {item.type === 'folder' ? 'text-amber-400' : item.type === 'kanban' ? 'text-purple-400' : 'text-light/50'}"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
<div
class="flex items-center gap-[10px] min-w-0"
>
{getDocIcon(item)}
</span>
<span
class="font-body text-body-sm text-white truncate flex-1"
>{item.name}</span
>
{#if item.type === "folder"}
<span
class="material-symbols-rounded text-light/20"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
class="flex items-center justify-center p-1 shrink-0"
>
chevron_right
<span
class="material-symbols-rounded text-white"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>{getDocIcon(item)}</span
>
</span>
{/if}
<span
class="font-body text-body text-white truncate"
>{item.name}</span
>
</div>
<span
class="flex items-center justify-center p-1 shrink-0"
>
<span
class="material-symbols-rounded text-white"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>chevron_forward</span
>
</span>
</button>
{/each}
{/if}
@@ -621,24 +806,34 @@
{:else}
<!-- Grid View -->
<div
class="grid grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-2"
class="flex flex-wrap gap-8 items-start content-start"
ondragover={handleContainerDragOver}
ondrop={handleDropOnEmpty}
role="list"
>
{#if currentFolderItems.length === 0}
<div class="col-span-full flex flex-col items-center justify-center text-light/40 py-16">
<span class="material-symbols-rounded mb-3" style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;">folder_open</span>
<p class="text-body-sm">{m.files_empty()}</p>
<div
class="w-full flex flex-col items-center justify-center text-white/30 py-16"
>
<span
class="material-symbols-rounded mb-3"
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
>folder_open</span
>
<p class="font-body text-body-sm">
{m.files_empty()}
</p>
</div>
{:else}
{#each currentFolderItems as item}
<button
type="button"
class="flex flex-col items-center gap-2 p-3 rounded-xl border border-transparent transition-all hover:bg-dark/50 hover:border-light/5
{selectedDoc?.id === item.id ? 'bg-dark/50 border-primary/20' : ''}
class="flex flex-col gap-2 items-center justify-center w-[240px] min-w-[240px] h-[155px] min-h-[155px] p-4 rounded-[32px] overflow-clip transition-all cursor-pointer
{selectedDoc?.id === item.id
? 'bg-surface ring-4 ring-primary'
: 'hover:bg-surface/50'}
{draggedItem?.id === item.id ? 'opacity-50' : ''}
{dragOverFolder === item.id ? 'ring-2 ring-primary bg-primary/10' : ''}"
{dragOverFolder === item.id ? 'ring-4 ring-primary bg-primary/10' : ''}"
draggable="true"
ondragstart={(e) => handleDragStart(e, item)}
ondragend={handleDragEnd}
@@ -652,15 +847,19 @@
handleContextMenu(e, item)}
>
<span
class="material-symbols-rounded {item.type === 'folder' ? 'text-amber-400' : item.type === 'kanban' ? 'text-purple-400' : 'text-light/40'}"
style="font-size: 40px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 40;"
class="flex items-center justify-center p-3"
>
{getDocIcon(item)}
<span
class="material-symbols-rounded text-white"
style="font-size: 72px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
>{getDocIcon(item)}</span
>
</span>
<span
class="font-body text-[12px] text-white text-center truncate w-full"
>{item.name}</span
<p
class="font-body text-body text-white text-center truncate w-full min-w-full"
>
{item.name}
</p>
</button>
{/each}
{/if}
@@ -745,7 +944,7 @@
: m.files_doc_placeholder()}
/>
<div class="flex justify-end gap-2 pt-2">
<Button variant="tertiary" onclick={() => (showCreateModal = false)}
<Button variant="ghost" onclick={() => (showCreateModal = false)}
>{m.btn_cancel()}</Button
>
<Button onclick={handleCreate} disabled={!newDocName.trim()}
@@ -876,7 +1075,7 @@
/>
<div class="flex justify-end gap-2 pt-2">
<Button
variant="tertiary"
variant="ghost"
onclick={() => {
showEditModal = false;
editingDoc = null;

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { getContext, onDestroy } from "svelte";
import { Button, Input, Icon } from "$lib/components/ui";
import * as m from "$lib/paraglide/messages";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
@@ -155,7 +156,7 @@
}}
>
<Input
placeholder="Add checklist item..."
placeholder={m.card_checklist_add_item_placeholder()}
bind:value={newItemTitle}
disabled={isAdding}
/>
@@ -164,7 +165,7 @@
size="md"
disabled={!newItemTitle.trim() || isAdding}
>
Add
{m.btn_add()}
</Button>
</form>
</div>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { getContext, onDestroy } from "svelte";
import { Button, Input, Icon, Avatar } from "$lib/components/ui";
import * as m from "$lib/paraglide/messages";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
@@ -132,7 +133,9 @@
{/each}
</div>
{:else}
<p class="text-sm text-light/40 text-center py-2">No comments yet</p>
<p class="text-sm text-light/40 text-center py-2">
{m.card_comments_none()}
</p>
{/if}
<!-- Add comment form -->
@@ -144,7 +147,7 @@
}}
>
<Input
placeholder="Add a comment..."
placeholder={m.card_comments_add_placeholder()}
bind:value={newComment}
disabled={isAdding}
/>
@@ -153,7 +156,7 @@
size="md"
disabled={!newComment.trim() || isAdding}
>
Send
{m.btn_send()}
</Button>
</form>
</div>

View File

@@ -12,6 +12,8 @@
import type { KanbanCard } from "$lib/supabase/types";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
import { toasts } from "$lib/stores/toast.svelte";
import * as m from "$lib/paraglide/messages";
let isMounted = $state(true);
onDestroy(() => {
@@ -265,7 +267,7 @@
}
async function deleteTag(tagId: string) {
if (!confirm("Delete this tag from the organization?")) return;
if (!confirm(m.kanban_confirm_delete_tag())) return;
const { error } = await supabase.from("tags").delete().eq("id", tagId);
if (!error) {
orgTags = orgTags.filter((t) => t.id !== tagId);
@@ -304,6 +306,9 @@
due_date: dueDate || null,
priority,
} as KanbanCard);
toasts.success(m.kanban_toast_changes_saved());
} else {
toasts.error(m.kanban_toast_save_failed());
}
isSaving = false;
}
@@ -338,7 +343,12 @@
.select()
.single();
if (!error && newCard) {
if (error) {
toasts.error(m.kanban_toast_create_failed());
isSaving = false;
return;
}
if (newCard) {
// Persist checklist items added during creation
if (checklist.length > 0) {
await supabase.from("kanban_checklist_items").insert(
@@ -359,6 +369,7 @@
})),
);
}
toasts.success(m.kanban_toast_card_created());
onCreate?.(newCard as KanbanCard);
onClose();
}
@@ -471,7 +482,7 @@
}
async function handleDelete() {
if (!card || !confirm("Delete this card?")) return;
if (!card || !confirm(m.kanban_confirm_delete_card())) return;
await supabase.from("kanban_cards").delete().eq("id", card.id);
@@ -490,17 +501,21 @@
<Modal
{isOpen}
{onClose}
title={mode === "create" ? "Add Card" : "Card Details"}
title={mode === "create" ? m.card_add_title() : m.card_details_title()}
size="lg"
>
{#if mode === "create" || card}
<div class="space-y-5">
<Input label="Title" bind:value={title} placeholder="Card title" />
<Input
label={m.card_title_label()}
bind:value={title}
placeholder={m.card_title_placeholder()}
/>
<Textarea
label="Description"
label={m.card_description_label()}
bind:value={description}
placeholder="Add a more detailed description..."
placeholder={m.card_description_placeholder()}
rows={3}
/>
@@ -508,14 +523,16 @@
<div>
<div class="flex items-center justify-between mb-2">
<span class="px-3 font-bold font-body text-body text-white"
>Tags</span
>{m.card_tags()}</span
>
<button
type="button"
class="text-xs text-light/40 hover:text-light transition-colors"
onclick={() => (showTagManager = !showTagManager)}
>
{showTagManager ? "Done" : "Manage"}
{showTagManager
? m.card_tags_done()
: m.card_tags_manage()}
</button>
</div>
@@ -546,14 +563,15 @@
<button
type="button"
class="text-primary text-xs font-bold"
onclick={saveEditTag}>Save</button
onclick={saveEditTag}
>{m.btn_save()}</button
>
<button
type="button"
class="text-light/40 text-xs"
onclick={() =>
(editingTagId = null)}
>Cancel</button
>{m.btn_cancel()}</button
>
</div>
{:else}
@@ -600,7 +618,7 @@
<input
type="text"
class="bg-dark border border-light/20 rounded-lg px-2 py-1 text-sm text-white flex-1 focus:outline-none focus:border-primary"
placeholder="New tag name..."
placeholder={m.card_tags_new_placeholder()}
bind:value={newTagName}
onkeydown={(e) =>
e.key === "Enter" && createTag()}
@@ -611,92 +629,137 @@
onclick={createTag}
disabled={!newTagName.trim()}
>
+ Add
{m.btn_add()}
</button>
</div>
</div>
{/if}
<!-- Tag toggle chips -->
<!-- Active tags with remove button -->
<div class="flex flex-wrap gap-2 items-center">
{#each orgTags as tag}
<button
type="button"
class="rounded-[4px] px-2 py-1 font-body font-bold text-[13px] leading-none transition-all border-2"
style="background-color: {cardTagIds.has(tag.id)
? tag.color || '#00A3E0'
: 'transparent'}; color: {cardTagIds.has(tag.id)
? '#0A121F'
: tag.color ||
'#00A3E0'}; border-color: {tag.color ||
'#00A3E0'};"
onclick={() => toggleTag(tag.id)}
{#each orgTags.filter((t) => cardTagIds.has(t.id)) as tag}
<span
class="rounded-md px-2 py-1 font-body font-bold text-[13px] leading-none flex items-center gap-1"
style="background-color: {tag.color ||
'#00A3E0'}; color: #0A121F;"
>
{tag.name}
</button>
{/each}
{#if !showTagManager}
{#if showTagInput}
<div class="flex gap-1 items-center">
<input
type="text"
class="bg-dark border border-light/20 rounded-lg px-2 py-1 text-sm text-white w-24 focus:outline-none focus:border-primary"
placeholder="Tag name"
bind:value={newTagName}
onkeydown={(e) =>
e.key === "Enter" && createTag()}
/>
<button
type="button"
class="text-primary text-sm font-bold hover:text-primary/80"
onclick={createTag}
<button
type="button"
class="flex items-center justify-center hover:opacity-70 transition-opacity"
onclick={() => toggleTag(tag.id)}
aria-label="Remove tag {tag.name}"
>
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 500, 'GRAD' 0, 'opsz' 14;"
>close</span
>
Add
</button>
<button
type="button"
class="text-light/40 text-sm hover:text-light"
</button>
</span>
{/each}
<!-- Add tag dropdown -->
{#if !showTagManager}
<div class="relative">
<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 = !showTagInput)}
>
{m.card_tags_add()}
</button>
{#if showTagInput}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="fixed inset-0 z-[60]"
onclick={() => {
showTagInput = false;
newTagName = "";
}}
></div>
<div
class="absolute top-full left-0 mt-2 w-56 bg-night border border-light/10 rounded-xl shadow-xl z-[70] p-2 space-y-1"
>
Cancel
</button>
</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}
{#each orgTags.filter((t) => !cardTagIds.has(t.id)) as tag}
<button
type="button"
class="w-full text-left px-3 py-1.5 rounded-lg text-sm hover:bg-dark transition-colors flex items-center gap-2 text-light"
onclick={() => {
toggleTag(tag.id);
}}
>
<span
class="w-3 h-3 rounded-sm shrink-0"
style="background-color: {tag.color ||
'#00A3E0'}"
></span>
{tag.name}
</button>
{/each}
{#if orgTags.filter((t) => !cardTagIds.has(t.id)).length === 0}
<p
class="text-xs text-light/40 px-3 py-1"
>
{m.card_tags_all_added()}
</p>
{/if}
<div
class="border-t border-light/10 pt-1 mt-1"
>
<div class="flex items-center gap-1">
<input
type="text"
class="bg-dark border border-light/20 rounded-lg px-2 py-1 text-sm text-white flex-1 focus:outline-none focus:border-primary"
placeholder={m.card_tags_new_short()}
bind:value={newTagName}
onkeydown={(e) =>
e.key === "Enter" &&
createTag()}
/>
<button
type="button"
class="text-primary text-xs font-bold hover:text-primary/80 px-2 py-1 whitespace-nowrap"
onclick={createTag}
disabled={!newTagName.trim()}
>
{m.btn_add()}
</button>
</div>
</div>
</div>
{/if}
</div>
{/if}
</div>
</div>
<!-- Assignee, Due Date, Priority Row -->
<div class="grid grid-cols-3 gap-4">
<AssigneePicker
label="Assignee"
value={assigneeId}
{members}
onchange={(id) => (assigneeId = id)}
<!-- Assignee -->
<AssigneePicker
label={m.card_assignee()}
value={assigneeId}
{members}
onchange={(id) => (assigneeId = id)}
/>
<!-- Due Date & Priority -->
<div class="grid grid-cols-2 gap-4">
<Input
type="date"
label={m.card_due_date()}
bind:value={dueDate}
/>
<Input type="date" label="Due Date" bind:value={dueDate} />
<Select
label="Priority"
label={m.card_priority()}
bind:value={priority}
placeholder=""
options={[
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "urgent", label: "Urgent" },
{ value: "low", label: m.card_priority_low() },
{ value: "medium", label: m.card_priority_medium() },
{ value: "high", label: m.card_priority_high() },
{ value: "urgent", label: m.card_priority_urgent() },
]}
/>
</div>
@@ -704,7 +767,7 @@
<div>
<div class="flex items-center justify-between mb-3">
<span class="px-3 font-bold font-body text-body text-white"
>Checklist</span
>{m.card_checklist()}</span
>
{#if checklist.length > 0}
<span class="text-xs text-light/50"
@@ -725,7 +788,9 @@
{/if}
{#if isLoading}
<div class="text-light/50 text-sm py-2">Loading...</div>
<div class="text-light/50 text-sm py-2">
{m.card_loading()}
</div>
{:else}
<div class="space-y-2 mb-3">
{#each checklist as item}
@@ -770,7 +835,7 @@
<div class="flex gap-2 items-end">
<Input
placeholder="Add an item..."
placeholder={m.card_checklist_add_placeholder()}
bind:value={newItemTitle}
onkeydown={(e) =>
e.key === "Enter" && handleAddItem()}
@@ -780,7 +845,7 @@
onclick={handleAddItem}
disabled={!newItemTitle.trim()}
>
Add
{m.btn_add()}
</Button>
</div>
{/if}
@@ -791,7 +856,7 @@
<div>
<span
class="px-3 font-bold font-body text-body text-white mb-3 block"
>Comments</span
>{m.card_comments()}</span
>
<div class="space-y-3 mb-3 max-h-48 overflow-y-auto">
{#each comments as comment}
@@ -810,7 +875,7 @@
>
{comment.profiles?.full_name ||
comment.profiles?.email ||
"Unknown"}
m.card_comments_unknown()}
</span>
<span class="text-xs text-light/40"
>{formatDate(
@@ -826,13 +891,13 @@
{/each}
{#if comments.length === 0}
<p class="text-sm text-light/40 text-center py-2">
No comments yet
{m.card_comments_none()}
</p>
{/if}
</div>
<div class="flex gap-2 items-end">
<Input
placeholder="Add a comment..."
placeholder={m.card_comments_add_placeholder()}
bind:value={newComment}
onkeydown={(e) =>
e.key === "Enter" && handleAddComment()}
@@ -842,30 +907,34 @@
onclick={handleAddComment}
disabled={!newComment.trim()}
>
Send
{m.btn_send()}
</Button>
</div>
</div>
{/if}
<div
class="flex items-center justify-between pt-3 border-t border-light/10"
class="flex items-center justify-between pt-3 border-t border-light/10 sticky bottom-0 bg-dark z-10 pb-1"
>
{#if mode === "edit"}
<Button variant="danger" onclick={handleDelete}>
Delete Card
{m.card_delete()}
</Button>
{:else}
<div></div>
{/if}
<div class="flex gap-2">
<Button variant="tertiary" onclick={onClose}>Cancel</Button>
<Button variant="ghost" onclick={onClose}
>{m.btn_cancel()}</Button
>
<Button
onclick={handleSave}
loading={isSaving}
disabled={!title.trim()}
>
{mode === "create" ? "Add Card" : "Save Changes"}
{mode === "create"
? m.card_add_title()
: m.card_save_changes()}
</Button>
</div>
</div>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { Input, Select, AssigneePicker, Badge } from "$lib/components/ui";
import * as m from "$lib/paraglide/messages";
interface Member {
id: string;
@@ -48,7 +49,7 @@
<div class="grid grid-cols-3 gap-3">
<AssigneePicker
label="Assignee"
label={m.card_assignee()}
value={assigneeId}
{members}
onchange={onAssigneeChange}
@@ -56,20 +57,20 @@
<Input
type="date"
label="Due Date"
label={m.card_due_date()}
bind:value={dueDateLocal}
onchange={() => onDueDateChange(dueDateLocal)}
/>
<Select
label="Priority"
label={m.card_priority()}
value={priority}
placeholder=""
options={[
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "urgent", label: "Urgent" },
{ value: "low", label: m.card_priority_low() },
{ value: "medium", label: m.card_priority_medium() },
{ value: "high", label: m.card_priority_high() },
{ value: "urgent", label: m.card_priority_urgent() },
]}
onchange={(e) =>
onPriorityChange((e.target as HTMLSelectElement).value)}

View File

@@ -3,6 +3,7 @@
import type { KanbanCard } from "$lib/supabase/types";
import KanbanCardComponent from "./KanbanCard.svelte";
import { Button } from "$lib/components/ui";
import * as m from "$lib/paraglide/messages";
interface Props {
columns: ColumnWithCards[];
@@ -18,6 +19,7 @@
onDeleteColumn?: (columnId: string) => void;
onRenameColumn?: (columnId: string, newName: string) => void;
canEdit?: boolean;
dateFormat?: string;
}
let {
@@ -30,6 +32,7 @@
onDeleteColumn,
onRenameColumn,
canEdit = true,
dateFormat = "DD/MM/YYYY",
}: Props = $props();
let columnMenuId = $state<string | null>(null);
@@ -83,11 +86,26 @@
dragOverCardIndex = null;
}
function dropIndicatorClass(card: KanbanCard, cardIndex: number, columnId: string, totalCards: number): string {
if (!draggedCard || draggedCard.id === card.id || dragOverCardIndex?.columnId !== columnId) return '';
if (dragOverCardIndex.index === cardIndex) return 'shadow-[0_-3px_0_0_var(--color-primary)]';
if (dragOverCardIndex.index === cardIndex + 1 && cardIndex === totalCards - 1) return 'shadow-[0_3px_0_0_var(--color-primary)]';
return '';
function dropIndicatorClass(
card: KanbanCard,
cardIndex: number,
columnId: string,
totalCards: number,
): string {
if (
!draggedCard ||
draggedCard.id === card.id ||
dragOverCardIndex?.columnId !== columnId
)
return "";
if (dragOverCardIndex.index === cardIndex)
return "shadow-[0_-3px_0_0_var(--color-primary)]";
if (
dragOverCardIndex.index === cardIndex + 1 &&
cardIndex === totalCards - 1
)
return "shadow-[0_3px_0_0_var(--color-primary)]";
return "";
}
function handleCardDragOver(e: DragEvent, columnId: string, index: number) {
@@ -103,7 +121,8 @@
if (
dragOverCardIndex?.columnId === columnId &&
dragOverCardIndex?.index === dropIndex
) return;
)
return;
dragOverColumn = columnId;
dragOverCardIndex = { columnId, index: dropIndex };
@@ -148,14 +167,14 @@
</script>
<div
class="flex gap-2 overflow-x-auto pb-4 h-full kanban-scroll"
class="flex gap-4 overflow-x-auto pb-4 h-full kanban-scroll"
role="presentation"
>
{#each columns as column, colIndex (column.id)}
<div
class="flex-shrink-0 w-[256px] bg-background rounded-[32px] px-4 py-5 flex flex-col gap-4 max-h-full transition-opacity {dragOverColumn ===
column.id
? 'ring-2 ring-primary'
? 'ring-4 ring-primary'
: ''}"
data-column-id={column.id}
ondragover={(e) => handleColumnDragOver(e, column.id)}
@@ -167,6 +186,7 @@
<div class="flex items-center gap-1 p-1 rounded-[32px]">
<div class="flex items-center gap-2 flex-1 min-w-0">
{#if renamingColumnId === column.id}
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
class="bg-dark border border-primary rounded-lg px-2 py-1 text-white font-heading text-h4 w-full focus:outline-none"
@@ -252,8 +272,14 @@
<!-- Cards -->
<div class="flex-1 overflow-y-auto flex flex-col gap-0">
{#each column.cards as card, cardIndex}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="mb-2 relative {dropIndicatorClass(card, cardIndex, column.id, column.cards.length)}"
class="mb-2 relative {dropIndicatorClass(
card,
cardIndex,
column.id,
column.cards.length,
)}"
ondragover={(e) =>
handleCardDragOver(e, column.id, cardIndex)}
>
@@ -266,6 +292,7 @@
ondelete={canEdit
? (id) => onDeleteCard?.(id)
: undefined}
{dateFormat}
/>
</div>
{/each}
@@ -274,12 +301,12 @@
<!-- Add Card Button -->
{#if canEdit}
<Button
variant="secondary"
variant="primary"
fullWidth
icon="add"
onclick={() => onAddCard?.(column.id)}
>
Add card
{m.kanban_add_card()}
</Button>
{/if}
</div>
@@ -288,12 +315,12 @@
{#if canEdit}
<div class="flex-shrink-0 w-[256px]">
<Button
variant="secondary"
variant="outline"
fullWidth
icon="add"
onclick={() => onAddColumn?.()}
>
Add column
{m.kanban_add_col()}
</Button>
</div>
{/if}

View File

@@ -1,6 +1,8 @@
<script lang="ts">
import type { KanbanCard as KanbanCardType } from "$lib/supabase/types";
import { Avatar } from "$lib/components/ui";
import { formatDate } from "$lib/utils/currency";
import * as m from "$lib/paraglide/messages";
interface Tag {
id: string;
@@ -22,6 +24,7 @@
draggable?: boolean;
ondragstart?: (e: DragEvent) => void;
ondragend?: (e: DragEvent) => void;
dateFormat?: string;
}
let {
@@ -32,22 +35,18 @@
draggable = true,
ondragstart,
ondragend,
dateFormat = "DD/MM/YYYY",
}: Props = $props();
function handleDelete(e: MouseEvent) {
e.stopPropagation();
if (confirm("Are you sure you want to delete this card?")) {
if (confirm(m.kanban_confirm_delete_card())) {
ondelete?.(card.id);
}
}
function formatDueDate(dateStr: string | null): string {
if (!dateStr) return "";
const date = new Date(dateStr);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
return formatDate(dateStr, dateFormat, true);
}
const hasFooter = $derived(
@@ -59,7 +58,7 @@
<button
type="button"
class="bg-night/80 border border-light/5 hover:border-light/10 rounded-xl px-3 py-2.5 cursor-pointer transition-all group w-full text-left flex flex-col gap-1.5 relative"
class="bg-surface rounded-[16px] p-4 cursor-pointer transition-all group w-full text-left flex flex-col gap-2 relative hover:ring-1 hover:ring-white/10"
class:opacity-50={isDragging}
data-card-id={card.id}
{draggable}
@@ -69,15 +68,16 @@
>
<!-- Delete button (top-right, visible on hover) -->
{#if ondelete}
<!-- svelte-ignore node_invalid_placement_ssr -->
<button
type="button"
class="absolute top-1.5 right-1.5 p-0.5 rounded-lg opacity-0 group-hover:opacity-100 hover:bg-error/10 transition-all z-10"
class="absolute top-2 right-2 p-0.5 rounded-lg opacity-0 group-hover:opacity-100 hover:bg-error/10 transition-all z-10"
onclick={handleDelete}
aria-label="Delete card"
>
<span
class="material-symbols-rounded text-light/30 hover:text-error"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
class="material-symbols-rounded text-white/30 hover:text-error"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>
close
</span>
@@ -86,10 +86,10 @@
<!-- Tags / Chips -->
{#if card.tags && card.tags.length > 0}
<div class="flex gap-1 items-start flex-wrap">
<div class="flex gap-1.5 items-start flex-wrap">
{#each card.tags as tag}
<span
class="rounded-[4px] px-1.5 py-0.5 font-body font-bold text-[11px] text-night leading-none"
class="rounded-[32px] px-2.5 py-1 font-body font-bold text-body-sm text-night leading-none"
style="background-color: {tag.color || '#00A3E0'}"
>
{tag.name}
@@ -99,30 +99,32 @@
{/if}
<!-- Title -->
<p class="font-body text-body-sm text-white w-full leading-snug">
<p class="font-body text-body text-white w-full leading-snug">
{card.title}
</p>
<!-- Bottom row: details + avatar -->
{#if hasFooter}
<div class="flex items-center justify-between w-full mt-0.5">
<div class="flex gap-2 items-center text-[11px] text-light/40">
<div class="flex items-center justify-between w-full mt-1">
<div class="flex gap-3 items-center text-body-sm text-white/40">
{#if card.due_date}
<span class="flex items-center gap-0.5">
<span class="flex items-center gap-1">
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>calendar_today</span>
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>calendar_today</span
>
{formatDueDate(card.due_date)}
</span>
{/if}
{#if (card.checklist_total ?? 0) > 0}
<span class="flex items-center gap-0.5">
<span class="flex items-center gap-1">
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>check_box</span>
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>check_box</span
>
{card.checklist_done ?? 0}/{card.checklist_total}
</span>
{/if}
@@ -132,7 +134,7 @@
<Avatar
name={card.assignee_name || "?"}
src={card.assignee_avatar}
size="xs"
size="sm"
/>
{/if}
</div>

View File

@@ -2,6 +2,10 @@
import { Button, Input } from "$lib/components/ui";
import { createRoom } from "$lib/matrix";
import { toasts } from "$lib/stores/ui";
import * as m from "$lib/paraglide/messages";
import { createLogger, getErrorMessage } from "$lib/utils/logger";
const log = createLogger("matrix:room");
import { syncRoomsFromEvent, selectRoom } from "$lib/stores/matrix";
interface Props {
@@ -17,7 +21,7 @@
async function handleCreate() {
if (!roomName.trim()) {
toasts.error("Please enter a room name");
toasts.error(m.toast_error_enter_room_name());
return;
}
@@ -25,7 +29,7 @@
try {
const result = await createRoom(roomName.trim(), isDirect);
toasts.success("Room created!");
toasts.success(m.toast_success_room_created());
// Add new room to list and select it
syncRoomsFromEvent("join", result.room_id);
@@ -35,9 +39,9 @@
roomName = "";
isDirect = false;
onClose();
} catch (e: any) {
console.error("Failed to create room:", e);
toasts.error(e.message || "Failed to create room");
} catch (e: unknown) {
log.error("Failed to create room", { error: e });
toasts.error(getErrorMessage(e, "Failed to create room"));
} finally {
isCreating = false;
}
@@ -59,6 +63,8 @@
aria-modal="true"
tabindex="-1"
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="bg-dark rounded-2xl p-6 w-full max-w-md mx-4"
onclick={(e) => e.stopPropagation()}

View File

@@ -2,6 +2,10 @@
import { Button, Input } from "$lib/components/ui";
import { createSpace, getSpaces } from "$lib/matrix";
import { toasts } from "$lib/stores/ui";
import * as m from "$lib/paraglide/messages";
import { createLogger, getErrorMessage } from "$lib/utils/logger";
const log = createLogger("matrix:space");
import { syncRoomsFromEvent } from "$lib/stores/matrix";
interface Props {
@@ -22,7 +26,7 @@
async function handleCreate() {
if (!spaceName.trim()) {
toasts.error("Please enter a space name");
toasts.error(m.toast_error_enter_space_name());
return;
}
@@ -34,8 +38,8 @@
isPublic,
parentSpaceId: parentSpaceId || undefined,
});
toasts.success("Space created!");
toasts.success(m.toast_success_space_created());
// Sync the new space
syncRoomsFromEvent("join", result.room_id);
@@ -45,9 +49,9 @@
spaceTopic = "";
isPublic = false;
onClose();
} catch (e: any) {
console.error("Failed to create space:", e);
toasts.error(e.message || "Failed to create space");
} catch (e: unknown) {
log.error("Failed to create space", { error: e });
toasts.error(getErrorMessage(e, "Failed to create space"));
} finally {
isCreating = false;
}
@@ -78,14 +82,24 @@
role="document"
>
<div class="flex items-center gap-3 mb-6">
<div class="w-12 h-12 rounded-xl bg-primary/20 flex items-center justify-center">
<svg class="w-6 h-6 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<div
class="w-12 h-12 rounded-xl bg-primary/20 flex items-center justify-center"
>
<svg
class="w-6 h-6 text-primary"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 9l9-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>
</div>
<div>
<h2 id="create-space-title" class="text-xl font-semibold text-light">Create Space</h2>
<h2 id="create-space-title" class="text-xl font-semibold text-light">
Create Space
</h2>
<p class="text-sm text-light/60">Organize your rooms and team</p>
</div>
</div>
@@ -105,7 +119,10 @@
/>
<div>
<label for="space-topic" class="block text-sm font-medium text-light/80 mb-1.5">
<label
for="space-topic"
class="block text-sm font-medium text-light/80 mb-1.5"
>
Description (optional)
</label>
<textarea
@@ -113,21 +130,25 @@
bind:value={spaceTopic}
placeholder="What is this space for?"
rows="2"
class="w-full px-4 py-2.5 bg-night text-light rounded-xl border border-light/20
placeholder:text-light/40 focus:outline-none focus:border-primary
class="w-full px-4 py-2.5 bg-night text-light rounded-xl border border-light/20
placeholder:text-light/40 focus:outline-none focus:border-primary
focus:ring-1 focus:ring-primary resize-none"
></textarea>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-light/80">Visibility</span>
<label class="flex items-start gap-3 p-3 rounded-lg border border-light/10 hover:border-light/20 cursor-pointer transition-colors {!isPublic ? 'border-primary bg-primary/5' : ''}">
<label
class="flex items-start gap-3 p-3 rounded-lg border border-light/10 hover:border-light/20 cursor-pointer transition-colors {!isPublic
? 'border-primary bg-primary/5'
: ''}"
>
<input
type="radio"
name="visibility"
checked={!isPublic}
onchange={() => isPublic = false}
onchange={() => (isPublic = false)}
class="mt-0.5 w-4 h-4 text-primary focus:ring-primary"
/>
<div>
@@ -136,17 +157,23 @@
</div>
</label>
<label class="flex items-start gap-3 p-3 rounded-lg border border-light/10 hover:border-light/20 cursor-pointer transition-colors {isPublic ? 'border-primary bg-primary/5' : ''}">
<label
class="flex items-start gap-3 p-3 rounded-lg border border-light/10 hover:border-light/20 cursor-pointer transition-colors {isPublic
? 'border-primary bg-primary/5'
: ''}"
>
<input
type="radio"
name="visibility"
checked={isPublic}
onchange={() => isPublic = true}
onchange={() => (isPublic = true)}
class="mt-0.5 w-4 h-4 text-primary focus:ring-primary"
/>
<div>
<span class="text-light font-medium">Public</span>
<p class="text-sm text-light/60">Anyone can find and join this space</p>
<p class="text-sm text-light/60">
Anyone can find and join this space
</p>
</div>
</label>
</div>
@@ -155,7 +182,8 @@
<div class="px-3 py-2 bg-light/5 rounded-lg text-sm text-light/60">
<span class="text-light/40">Creating inside:</span>
<span class="text-light ml-1">
{existingSpaces.find(s => s.roomId === parentSpaceId)?.name || 'Parent Space'}
{existingSpaces.find((s) => s.roomId === parentSpaceId)?.name ||
"Parent Space"}
</span>
</div>
{/if}

View File

@@ -12,20 +12,18 @@
let { client, children }: Props = $props();
// Store client reference for cleanup
let clientRef = client;
// Set the context during component initialization
setMatrixContext(clientRef);
// svelte-ignore state_referenced_locally
setMatrixContext(client);
// Setup sync handlers when provider mounts
onMount(() => {
setupSyncHandlers(clientRef);
setupSyncHandlers(client);
});
// Cleanup when provider unmounts
onDestroy(() => {
removeSyncHandlers(clientRef);
removeSyncHandlers(client);
});
</script>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { Avatar } from "$lib/components/ui";
import { MatrixAvatar } from "$lib/components/ui";
import UserProfileModal from "./UserProfileModal.svelte";
import type { RoomMember } from "$lib/matrix/types";
import { userPresence } from "$lib/stores/matrix";
@@ -66,8 +66,8 @@
class="w-full flex items-center gap-3 px-4 py-2 hover:bg-light/5 transition-colors text-left"
onclick={() => handleMemberClick(member)}
>
<Avatar
src={member.avatarUrl}
<MatrixAvatar
mxcUrl={member.avatarUrl}
name={member.name}
size="sm"
status={getPresenceStatus(member.userId)}

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Avatar } from '$lib/components/ui';
import type { RoomMember } from '$lib/matrix/types';
import { MatrixAvatar } from "$lib/components/ui";
import type { RoomMember } from "$lib/matrix/types";
interface Props {
members: RoomMember[];
@@ -23,11 +23,12 @@
// Filter members based on query
const filteredMembers = $derived(
members
.filter(m =>
m.name.toLowerCase().includes(query.toLowerCase()) ||
m.userId.toLowerCase().includes(query.toLowerCase())
.filter(
(m) =>
m.name.toLowerCase().includes(query.toLowerCase()) ||
m.userId.toLowerCase().includes(query.toLowerCase()),
)
.slice(0, 8)
.slice(0, 8),
);
// Reset selection when query changes
@@ -40,22 +41,23 @@
if (filteredMembers.length === 0) return;
switch (e.key) {
case 'ArrowDown':
case "ArrowDown":
e.preventDefault();
selectedIndex = (selectedIndex + 1) % filteredMembers.length;
break;
case 'ArrowUp':
case "ArrowUp":
e.preventDefault();
selectedIndex = (selectedIndex - 1 + filteredMembers.length) % filteredMembers.length;
selectedIndex =
(selectedIndex - 1 + filteredMembers.length) % filteredMembers.length;
break;
case 'Enter':
case 'Tab':
case "Enter":
case "Tab":
e.preventDefault();
if (filteredMembers[selectedIndex]) {
onSelect(filteredMembers[selectedIndex]);
}
break;
case 'Escape':
case "Escape":
e.preventDefault();
onClose();
break;
@@ -76,11 +78,14 @@
</div>
{#each filteredMembers as member, i}
<button
class="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors {i === selectedIndex ? 'bg-primary/20' : 'hover:bg-light/5'}"
class="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors {i ===
selectedIndex
? 'bg-primary/20'
: 'hover:bg-light/5'}"
onclick={() => onSelect(member)}
onmouseenter={() => selectedIndex = i}
onmouseenter={() => (selectedIndex = i)}
>
<Avatar src={member.avatarUrl} name={member.name} size="sm" />
<MatrixAvatar mxcUrl={member.avatarUrl} name={member.name} size="sm" />
<div class="flex-1 min-w-0">
<p class="text-light truncate">{member.name}</p>
<p class="text-xs text-light/40 truncate">{member.userId}</p>

View File

@@ -8,6 +8,7 @@
getRoomMembers,
} from "$lib/matrix";
import { toasts } from "$lib/stores/ui";
import * as m from "$lib/paraglide/messages";
import {
auth,
addPendingMessage,
@@ -20,6 +21,9 @@
import EmojiPicker from "$lib/components/ui/EmojiPicker.svelte";
import { convertEmojiShortcodes } from "$lib/utils/emojiData";
import { getTwemojiUrl } from "$lib/utils/twemoji";
import { createLogger, getErrorMessage } from "$lib/utils/logger";
const log = createLogger("matrix:input");
// Emoji detection regex
const emojiRegex =
@@ -32,14 +36,12 @@
// Render emojis as Twemoji images for preview
function renderEmojiPreview(text: string): string {
// Escape HTML first
const escaped = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\n/g, "<br>");
// Replace emojis with Twemoji images
return escaped.replace(emojiRegex, (emoji) => {
const url = getTwemojiUrl(emoji);
return `<img class="inline-block w-5 h-5 align-text-bottom" src="${url}" alt="${emoji}" draggable="false" />`;
@@ -79,21 +81,20 @@
let showMentions = $state(false);
let mentionQuery = $state("");
let mentionStartIndex = $state(0);
let autocompleteRef:
| { handleKeyDown: (e: KeyboardEvent) => void }
| undefined;
let autocompleteRef = $state<
{ handleKeyDown: (e: KeyboardEvent) => void } | undefined
>();
// Emoji picker state
let showEmojiPicker = $state(false);
let emojiButtonRef: HTMLButtonElement;
// Emoji autocomplete state
let showEmojiAutocomplete = $state(false);
let emojiQuery = $state("");
let emojiStartIndex = $state(0);
let emojiAutocompleteRef:
| { handleKeyDown: (e: KeyboardEvent) => void }
| undefined;
let emojiAutocompleteRef = $state<
{ handleKeyDown: (e: KeyboardEvent) => void } | undefined
>();
// Get room members for autocomplete
const roomMembers = $derived(getRoomMembers(roomId));
@@ -121,53 +122,41 @@
function autoResize() {
if (!inputRef) return;
inputRef.style.height = "auto";
inputRef.style.height = Math.min(inputRef.scrollHeight, 200) + "px";
inputRef.style.height = Math.min(inputRef.scrollHeight, 160) + "px";
}
// Handle typing indicator
function handleTyping() {
// Clear existing timeout
if (typingTimeout) {
clearTimeout(typingTimeout);
}
if (typingTimeout) clearTimeout(typingTimeout);
// Send typing indicator
setTyping(roomId, true).catch(console.error);
setTyping(roomId, true).catch((e) =>
log.error("Failed to send typing", { error: e }),
);
// Stop typing after 3 seconds of no input
typingTimeout = setTimeout(() => {
setTyping(roomId, false).catch(console.error);
setTyping(roomId, false).catch((e) =>
log.error("Failed to stop typing", { error: e }),
);
}, 3000);
}
// Handle input
function handleInput() {
autoResize();
if (message.trim()) {
handleTyping();
}
// Auto-convert completed emoji shortcodes like :heart: to actual emojis
if (message.trim()) handleTyping();
autoConvertShortcodes();
// Check for @ mentions and : emoji shortcodes
checkForMention();
checkForEmoji();
}
// Auto-convert completed emoji shortcodes (e.g., :heart:) to actual emojis
// Auto-convert completed emoji shortcodes
function autoConvertShortcodes() {
if (!inputRef) return;
const cursorPos = inputRef.selectionStart;
// Look for completed shortcodes like :name:
const converted = convertEmojiShortcodes(message);
if (converted !== message) {
// Calculate cursor offset based on length difference
const lengthDiff = message.length - converted.length;
message = converted;
// Restore cursor position (adjusted for shorter string)
setTimeout(() => {
if (inputRef) {
const newPos = Math.max(0, cursorPos - lengthDiff);
@@ -180,16 +169,12 @@
// Check if user is typing an emoji shortcode
function checkForEmoji() {
if (!inputRef) return;
const cursorPos = inputRef.selectionStart;
const textBeforeCursor = message.slice(0, cursorPos);
// Find the last : before cursor
const lastColonIndex = textBeforeCursor.lastIndexOf(":");
if (lastColonIndex >= 0) {
const textAfterColon = textBeforeCursor.slice(lastColonIndex + 1);
// Check if there's a space before : (or it's at start) and no space after, and query is at least 2 chars
const charBeforeColon =
lastColonIndex > 0 ? message[lastColonIndex - 1] : " ";
@@ -214,31 +199,23 @@
// Handle emoji selection from autocomplete
function handleEmojiSelect(emoji: string) {
// Replace :query with the emoji
const beforeEmoji = message.slice(0, emojiStartIndex);
const afterEmoji = message.slice(emojiStartIndex + emojiQuery.length + 1);
message = `${beforeEmoji}${emoji}${afterEmoji}`;
showEmojiAutocomplete = false;
emojiQuery = "";
// Focus back on textarea
inputRef?.focus();
}
// Check if user is typing a mention
function checkForMention() {
if (!inputRef) return;
const cursorPos = inputRef.selectionStart;
const textBeforeCursor = message.slice(0, cursorPos);
// Find the last @ before cursor that's not part of a completed mention
const lastAtIndex = textBeforeCursor.lastIndexOf("@");
if (lastAtIndex >= 0) {
const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1);
// Check if there's a space before @ (or it's at start) and no space after
const charBeforeAt = lastAtIndex > 0 ? message[lastAtIndex - 1] : " ";
if (
@@ -258,23 +235,19 @@
// Handle mention selection
function handleMentionSelect(member: RoomMember) {
// Replace @query with userId (userId already has @ prefix)
const beforeMention = message.slice(0, mentionStartIndex);
const afterMention = message.slice(
mentionStartIndex + mentionQuery.length + 1,
);
message = `${beforeMention}${member.userId} ${afterMention}`;
showMentions = false;
mentionQuery = "";
// Focus back on textarea
inputRef?.focus();
}
// Handle key press
function handleKeyDown(e: KeyboardEvent) {
// If mention autocomplete is open, let it handle navigation keys
// Mention autocomplete navigation
if (
showMentions &&
["ArrowUp", "ArrowDown", "Tab", "Escape"].includes(e.key)
@@ -282,15 +255,13 @@
autocompleteRef?.handleKeyDown(e);
return;
}
// Enter with mention autocomplete open selects the mention
if (showMentions && e.key === "Enter") {
e.preventDefault();
autocompleteRef?.handleKeyDown(e);
return;
}
// If emoji autocomplete is open, let it handle navigation keys
// Emoji autocomplete navigation
if (
showEmojiAutocomplete &&
["ArrowUp", "ArrowDown", "Tab", "Escape"].includes(e.key)
@@ -298,8 +269,6 @@
emojiAutocompleteRef?.handleKeyDown(e);
return;
}
// Enter with emoji autocomplete open selects the emoji
if (showEmojiAutocomplete && e.key === "Enter") {
e.preventDefault();
emojiAutocompleteRef?.handleKeyDown(e);
@@ -313,70 +282,42 @@
return;
}
// Auto-continue lists on Shift+Enter or regular Enter with list
// Auto-continue lists on Shift+Enter
if (e.key === "Enter" && e.shiftKey) {
const cursorPos = inputRef?.selectionStart || 0;
const textBefore = message.slice(0, cursorPos);
const currentLine = textBefore.split("\n").pop() || "";
// Check for numbered list (1. 2. etc)
const numberedMatch = currentLine.match(/^(\s*)(\d+)\.\s/);
if (numberedMatch) {
e.preventDefault();
const indent = numberedMatch[1];
const nextNum = parseInt(numberedMatch[2]) + 1;
const newText =
message =
message.slice(0, cursorPos) +
`\n${indent}${nextNum}. ` +
message.slice(cursorPos);
message = newText;
setTimeout(() => {
if (inputRef) {
if (inputRef)
inputRef.selectionStart = inputRef.selectionEnd =
cursorPos + indent.length + String(nextNum).length + 4;
}
}, 0);
return;
}
// Check for bullet list (- or *)
const bulletMatch = currentLine.match(/^(\s*)([-*])\s/);
if (bulletMatch) {
e.preventDefault();
const indent = bulletMatch[1];
const bullet = bulletMatch[2];
const newText =
message =
message.slice(0, cursorPos) +
`\n${indent}${bullet} ` +
message.slice(cursorPos);
message = newText;
setTimeout(() => {
if (inputRef) {
if (inputRef)
inputRef.selectionStart = inputRef.selectionEnd =
cursorPos + indent.length + 4;
}
}, 0);
return;
}
// Check for lettered sub-list (a. b. etc)
const letteredMatch = currentLine.match(/^(\s*)([a-z])\.\s/);
if (letteredMatch) {
e.preventDefault();
const indent = letteredMatch[1];
const nextLetter = String.fromCharCode(
letteredMatch[2].charCodeAt(0) + 1,
);
const newText =
message.slice(0, cursorPos) +
`\n${indent}${nextLetter}. ` +
message.slice(cursorPos);
message = newText;
setTimeout(() => {
if (inputRef) {
inputRef.selectionStart = inputRef.selectionEnd =
cursorPos + indent.length + 5;
}
}, 0);
return;
}
@@ -388,38 +329,33 @@
const trimmed = message.trim();
if (!trimmed || isSending || disabled) return;
// Convert emoji shortcodes like :heart: to actual emojis
const processedMessage = convertEmojiShortcodes(trimmed);
// Handle edit mode
if (editingMessage) {
if (processedMessage === editingMessage.content) {
// No changes, just cancel
onCancelEdit?.();
message = "";
return;
}
onSaveEdit?.(processedMessage);
message = "";
if (inputRef) {
inputRef.style.height = "auto";
}
if (inputRef) inputRef.style.height = "auto";
return;
}
isSending = true;
// Clear typing indicator
if (typingTimeout) {
clearTimeout(typingTimeout);
typingTimeout = null;
}
setTyping(roomId, false).catch(console.error);
setTyping(roomId, false).catch((e) =>
log.error("Failed to stop typing", { error: e }),
);
// Create a temporary event ID for the pending message
const tempEventId = `pending-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Add pending message immediately (optimistic update)
const pendingMessage: Message = {
eventId: tempEventId,
roomId,
@@ -438,14 +374,9 @@
addPendingMessage(roomId, pendingMessage);
message = "";
// Clear reply
onCancelReply?.();
// Reset textarea height
if (inputRef) {
inputRef.style.height = "auto";
}
if (inputRef) inputRef.style.height = "auto";
try {
const result = await sendMessage(
@@ -453,21 +384,17 @@
processedMessage,
replyTo?.eventId,
);
// Confirm the pending message with the real event ID
if (result?.event_id) {
confirmPendingMessage(roomId, tempEventId, result.event_id);
} else {
// If no event ID returned, just mark as not pending
confirmPendingMessage(roomId, tempEventId, tempEventId);
}
} catch (e: any) {
console.error("Failed to send message:", e);
// Remove the pending message on failure
} catch (e: unknown) {
log.error("Failed to send message", { error: e });
removePendingMessage(roomId, tempEventId);
toasts.error(e.message || "Failed to send message");
toasts.error(getErrorMessage(e, "Failed to send message"));
} finally {
isSending = false;
// Refocus after DOM settles from optimistic update
await tick();
inputRef?.focus();
}
@@ -478,14 +405,11 @@
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file || disabled) return;
// Reset input
input.value = "";
// Check file size (50MB limit)
const maxSize = 50 * 1024 * 1024;
if (file.size > maxSize) {
toasts.error("File too large. Maximum size is 50MB.");
toasts.error(m.toast_error_file_too_large());
return;
}
@@ -494,10 +418,10 @@
toasts.info(`Uploading ${file.name}...`);
const contentUri = await uploadFile(file);
await sendFileMessage(roomId, file, contentUri);
toasts.success("File sent!");
} catch (e: any) {
console.error("Failed to upload file:", e);
toasts.error(e.message || "Failed to upload file");
toasts.success(m.toast_success_file_sent());
} catch (e: unknown) {
log.error("Failed to upload file", { error: e });
toasts.error(getErrorMessage(e, "Failed to upload file"));
} finally {
isUploading = false;
}
@@ -508,35 +432,34 @@
}
</script>
<div class="border-t border-light/10">
<div class="border-t border-light/5">
<!-- Edit preview -->
{#if editingMessage}
<div class="px-4 pt-3 pb-0">
<div
class="flex items-center gap-2 px-3 py-2 bg-yellow-500/10 rounded-lg border-l-2 border-yellow-500"
>
<span
class="material-symbols-rounded text-yellow-400"
style="font-size: 16px;">edit</span
>
<div class="flex-1 min-w-0">
<p class="text-xs text-yellow-400 font-medium">Editing message</p>
<p class="text-sm text-light/60 truncate">{editingMessage.content}</p>
<p class="text-[11px] text-yellow-400 font-body">Editing message</p>
<p class="text-[12px] text-light/50 truncate">
{editingMessage.content}
</p>
</div>
<button
class="w-6 h-6 flex items-center justify-center text-light/40 hover:text-light rounded transition-colors"
class="w-6 h-6 flex items-center justify-center text-light/30 hover:text-white rounded transition-colors"
onclick={() => {
onCancelEdit?.();
message = "";
}}
title="Cancel edit"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<span class="material-symbols-rounded" style="font-size: 16px;"
>close</span
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div>
@@ -546,35 +469,47 @@
{#if replyTo && !editingMessage}
<div class="px-4 pt-3 pb-0">
<div
class="flex items-center gap-2 px-3 py-2 bg-light/5 rounded-lg border-l-2 border-primary"
class="flex items-center gap-2 px-3 py-2 bg-primary/5 rounded-lg border-l-2 border-primary"
>
<span
class="material-symbols-rounded text-primary"
style="font-size: 16px;">reply</span
>
<div class="flex-1 min-w-0">
<p class="text-xs text-primary font-medium">
<p class="text-[11px] text-primary font-body">
Replying to {replyTo.senderName}
</p>
<p class="text-sm text-light/60 truncate">{replyTo.content}</p>
<p class="text-[12px] text-light/50 truncate">{replyTo.content}</p>
</div>
<button
class="w-6 h-6 flex items-center justify-center text-light/40 hover:text-light rounded transition-colors"
class="w-6 h-6 flex items-center justify-center text-light/30 hover:text-white rounded transition-colors"
onclick={() => onCancelReply?.()}
title="Cancel reply"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<span class="material-symbols-rounded" style="font-size: 16px;"
>close</span
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div>
{/if}
<div class="p-4 flex items-end gap-3">
<!-- Emoji Picker (above input) -->
{#if showEmojiPicker}
<div class="flex justify-end px-4 pb-2">
<div class="relative">
<EmojiPicker
onSelect={(emoji) => {
message += emoji;
inputRef?.focus();
}}
onClose={() => (showEmojiPicker = false)}
/>
</div>
</div>
{/if}
<div class="px-4 py-3 flex items-end gap-2">
<!-- Hidden file input -->
<input
bind:this={fileInputRef}
@@ -586,40 +521,20 @@
<!-- Attachment button -->
<button
class="w-10 h-10 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded-full transition-colors shrink-0"
class="w-8 h-8 flex items-center justify-center text-light/30 hover:text-white hover:bg-light/5 rounded-lg transition-colors shrink-0"
class:animate-pulse={isUploading}
title="Add attachment"
onclick={openFilePicker}
disabled={disabled || isUploading}
>
{#if isUploading}
<svg class="w-5 h-5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg>
<div
class="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin"
></div>
{:else}
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<span class="material-symbols-rounded" style="font-size: 20px;"
>attach_file</span
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="16" />
<line x1="8" y1="12" x2="16" y2="12" />
</svg>
{/if}
</button>
@@ -646,13 +561,13 @@
/>
{/if}
<!-- Input wrapper with emoji button inside -->
<!-- Input wrapper -->
<div class="relative flex items-end">
<!-- Emoji preview overlay - shows rendered Twemoji -->
<!-- Emoji preview overlay -->
{#if message && hasEmoji(message)}
<div
class="absolute inset-0 pl-4 pr-12 py-3 pointer-events-none overflow-hidden rounded-2xl text-light whitespace-pre-wrap break-words"
style="min-height: 48px; max-height: 200px; line-height: 1.5;"
class="absolute inset-0 pl-3 pr-10 py-2.5 pointer-events-none overflow-hidden rounded-xl text-light text-[13px] whitespace-pre-wrap break-words"
style="min-height: 40px; max-height: 160px; line-height: 1.5;"
aria-hidden="true"
>
{@html renderEmojiPreview(message)}
@@ -666,96 +581,63 @@
{placeholder}
disabled={disabled || isSending}
rows="1"
class="w-full pl-4 pr-12 py-3 bg-dark rounded-2xl border border-light/20
placeholder:text-light/40 resize-none overflow-hidden
focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary
class="w-full pl-3 pr-10 py-2.5 bg-dark/50 rounded-xl border border-light/5 text-[13px]
placeholder:text-light/25 resize-none overflow-hidden
focus:outline-none focus:border-light/15
disabled:opacity-50 disabled:cursor-not-allowed
transition-colors {message && hasEmoji(message)
? 'text-transparent caret-light'
: 'text-light'}"
style="min-height: 48px; max-height: 200px;"
style="min-height: 40px; max-height: 160px;"
></textarea>
<!-- Emoji button inside input -->
<button
bind:this={emojiButtonRef}
type="button"
class="absolute right-3 bottom-3 w-6 h-6 flex items-center justify-center text-light/40 hover:text-light transition-colors"
class="absolute right-2.5 bottom-2 w-6 h-6 flex items-center justify-center text-light/25 hover:text-light/60 transition-colors"
onclick={() => (showEmojiPicker = !showEmojiPicker)}
title="Add emoji"
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<span class="material-symbols-rounded" style="font-size: 18px;"
>sentiment_satisfied</span
>
<circle cx="12" cy="12" r="10" />
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
<line x1="9" y1="9" x2="9.01" y2="9" />
<line x1="15" y1="9" x2="15.01" y2="9" />
</svg>
</button>
</div>
<!-- Emoji Picker -->
{#if showEmojiPicker}
<div class="absolute bottom-full right-0 mb-2">
<EmojiPicker
onSelect={(emoji) => {
message += emoji;
inputRef?.focus();
}}
onClose={() => (showEmojiPicker = false)}
position={{ x: 0, y: 0 }}
/>
</div>
{/if}
</div>
<!-- Send button -->
<button
class="w-10 h-10 flex items-center justify-center rounded-full transition-all shrink-0
class="w-8 h-8 flex items-center justify-center rounded-lg transition-all shrink-0
{message.trim()
? 'bg-primary text-white hover:brightness-110'
: 'bg-light/10 text-light/30 cursor-not-allowed'}"
: 'text-light/20 cursor-not-allowed'}"
onclick={handleSend}
disabled={!message.trim() || isSending || disabled}
title="Send message"
>
{#if isSending}
<svg class="w-5 h-5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg>
<div
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"
></div>
{:else}
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
</svg>
<span
class="material-symbols-rounded"
style="font-size: 20px; font-variation-settings: 'FILL' 1;">send</span
>
{/if}
</button>
</div>
<!-- Character count (optional, show when > 1000) -->
<!-- Character count -->
{#if message.length > 1000}
<div
class="text-right text-xs mt-1 {message.length > 4000
? 'text-red-400'
: 'text-light/40'}"
>
{message.length} / 4000
<div class="px-4 pb-2 text-right">
<span
class="text-[10px] {message.length > 4000
? 'text-red-400'
: 'text-light/25'}"
>
{message.length} / 4000
</span>
</div>
{/if}
</div>

View File

@@ -1,7 +1,5 @@
<script lang="ts">
import { onMount, tick, untrack } from "svelte";
import { createVirtualizer, elementScroll } from "@tanstack/svelte-virtual";
import type { SvelteVirtualizer } from "@tanstack/svelte-virtual";
import { onMount, tick } from "svelte";
import { MessageContainer } from "$lib/components/message";
import type { Message as MessageType } from "$lib/matrix/types";
import { auth } from "$lib/stores/matrix";
@@ -17,9 +15,6 @@
onEdit?: (message: MessageType) => void;
onDelete?: (messageId: string) => void;
onReply?: (message: MessageType) => void;
onLoadMore?: () => void;
isLoading?: boolean;
enableVirtualization?: boolean;
}
let {
@@ -29,175 +24,23 @@
onEdit,
onDelete,
onReply,
onLoadMore,
isLoading = false,
enableVirtualization = false, // Disabled until we find a Svelte 5-compatible solution
}: Props = $props();
let containerRef: HTMLDivElement | undefined = $state();
let shouldAutoScroll = $state(true);
let previousMessageCount = $state(0);
// Filter out deleted/redacted messages (hide them like Discord)
// Filter out deleted/redacted messages
const allVisibleMessages = $derived(messages.filter((m) => !m.isRedacted));
// Virtualizer state - managed via subscription
let virtualizer = $state<SvelteVirtualizer<HTMLDivElement, Element> | null>(
null,
);
let virtualizerCleanup: (() => void) | null = null;
// Estimate size based on message type
function estimateSize(index: number): number {
const msg = allVisibleMessages[index];
if (!msg) return 80;
if (msg.type === "image") return 300;
if (msg.type === "video") return 350;
if (msg.type === "file" || msg.type === "audio") return 100;
const lines = Math.ceil((msg.content?.length || 0) / 60);
return Math.max(60, Math.min(lines * 24 + 40, 400));
}
// Create/update virtualizer when container or messages change
$effect(() => {
if (
!containerRef ||
!enableVirtualization ||
allVisibleMessages.length === 0
) {
virtualizer = null;
return;
}
// Clean up previous subscription
if (virtualizerCleanup) {
virtualizerCleanup();
virtualizerCleanup = null;
}
// Create new virtualizer store
const store = createVirtualizer({
count: allVisibleMessages.length,
getScrollElement: () => containerRef!,
estimateSize,
overscan: 5,
getItemKey: (index) => allVisibleMessages[index]?.eventId ?? index,
scrollToFn: elementScroll,
});
// Subscribe to store updates
virtualizerCleanup = store.subscribe((v) => {
virtualizer = v;
});
// Cleanup on effect re-run or component destroy
return () => {
if (virtualizerCleanup) {
virtualizerCleanup();
virtualizerCleanup = null;
}
};
});
// Get virtual items for rendering (reactive to virtualizer changes)
const virtualItems = $derived(virtualizer?.getVirtualItems() ?? []);
const totalSize = $derived(virtualizer?.getTotalSize() ?? 0);
/**
* Svelte action for dynamic height measurement
* Re-measures when images/media finish loading
*/
function measureRow(node: HTMLElement, index: number) {
function measure() {
if (virtualizer) {
virtualizer.measureElement(node);
}
}
// Initial measurement
measure();
// Re-measure when images load
const images = node.querySelectorAll("img");
const imageHandlers: Array<() => void> = [];
images.forEach((img) => {
if (!img.complete) {
const handler = () => measure();
img.addEventListener("load", handler, { once: true });
img.addEventListener("error", handler, { once: true });
imageHandlers.push(() => {
img.removeEventListener("load", handler);
img.removeEventListener("error", handler);
});
}
});
// Re-measure when videos load metadata
const videos = node.querySelectorAll("video");
const videoHandlers: Array<() => void> = [];
videos.forEach((video) => {
if (video.readyState < 1) {
const handler = () => measure();
video.addEventListener("loadedmetadata", handler, { once: true });
videoHandlers.push(() =>
video.removeEventListener("loadedmetadata", handler),
);
}
});
return {
update(newIndex: number) {
// Re-measure on update
measure();
},
destroy() {
// Cleanup listeners
imageHandlers.forEach((cleanup) => cleanup());
videoHandlers.forEach((cleanup) => cleanup());
},
};
}
// Track if we're currently loading to prevent scroll jumps
let isLoadingMore = $state(false);
let scrollTopBeforeLoad = $state(0);
let scrollHeightBeforeLoad = $state(0);
// Check if we should auto-scroll and load more
// Track scroll position to decide auto-scroll
function handleScroll() {
if (!containerRef) return;
const { scrollTop, scrollHeight, clientHeight } = containerRef;
// Check if at bottom for auto-scroll
const distanceToBottom = scrollHeight - scrollTop - clientHeight;
shouldAutoScroll = distanceToBottom < 100;
// Check if at top to load more messages (with debounce via isLoadingMore)
if (scrollTop < 100 && onLoadMore && !isLoading && !isLoadingMore) {
// Save scroll position before loading
isLoadingMore = true;
scrollTopBeforeLoad = scrollTop;
scrollHeightBeforeLoad = scrollHeight;
onLoadMore();
}
}
// Restore scroll position after loading older messages
$effect(() => {
if (!isLoading && isLoadingMore && containerRef) {
// Loading finished - restore scroll position
tick().then(() => {
if (containerRef) {
const newScrollHeight = containerRef.scrollHeight;
const addedHeight = newScrollHeight - scrollHeightBeforeLoad;
// Adjust scroll to maintain visual position
containerRef.scrollTop = scrollTopBeforeLoad + addedHeight;
}
isLoadingMore = false;
});
}
});
// Scroll to bottom
async function scrollToBottom(force = false) {
if (!containerRef) return;
@@ -207,25 +50,20 @@
}
}
// Auto-scroll when new messages arrive (only if at bottom)
// Auto-scroll when new messages arrive
$effect(() => {
const count = allVisibleMessages.length;
if (count > previousMessageCount) {
if (shouldAutoScroll || previousMessageCount === 0) {
// User is at bottom or first load - scroll to new messages
scrollToBottom(true);
}
// If user is scrolled up, scroll anchoring handles it
}
previousMessageCount = count;
});
// Initial scroll to bottom
onMount(() => {
tick().then(() => {
scrollToBottom(true);
});
tick().then(() => scrollToBottom(true));
});
// Check if message should be grouped with previous
@@ -235,8 +73,6 @@
): boolean {
if (!previous) return false;
if (current.sender !== previous.sender) return false;
// Group if within 5 minutes
const timeDiff = current.timestamp - previous.timestamp;
return timeDiff < 5 * 60 * 1000;
}
@@ -247,10 +83,8 @@
previous: MessageType | null,
): boolean {
if (!previous) return true;
const currentDate = new Date(current.timestamp).toDateString();
const previousDate = new Date(previous.timestamp).toDateString();
return currentDate !== previousDate;
}
@@ -314,9 +148,8 @@
const element = document.getElementById(`message-${eventId}`);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "center" });
// Highlight briefly
element.classList.add("bg-primary/20");
setTimeout(() => element.classList.remove("bg-primary/20"), 2000);
element.classList.add("bg-primary/10");
setTimeout(() => element.classList.remove("bg-primary/10"), 2000);
}
}
</script>
@@ -324,97 +157,23 @@
<div class="relative flex-1 min-h-0">
<div
bind:this={containerRef}
class="h-full overflow-y-auto bg-night"
class="h-full overflow-y-auto scrollbar-thin"
onscroll={handleScroll}
>
<!-- Load more button -->
{#if onLoadMore}
<div class="flex justify-center py-4">
<button
class="text-sm text-primary hover:underline disabled:opacity-50"
onclick={() => onLoadMore?.()}
disabled={isLoading}
>
{isLoading ? "Loading..." : "Load older messages"}
</button>
</div>
{/if}
<!-- Messages -->
{#if allVisibleMessages.length === 0}
<div
class="flex flex-col items-center justify-center h-full text-light/40"
class="flex flex-col items-center justify-center h-full text-light/30"
>
<svg
class="w-16 h-16 mb-4 opacity-50"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
<span
class="material-symbols-rounded mb-3"
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 200, 'GRAD' 0, 'opsz' 48;"
>forum</span
>
<path
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
/>
</svg>
<p class="text-lg">No messages yet</p>
<p class="text-sm">Be the first to send a message!</p>
</div>
{:else if virtualizer && enableVirtualization}
<!-- TanStack Virtual: True DOM recycling -->
<div class="relative w-full" style="height: {totalSize}px;">
{#each virtualItems as virtualRow (virtualRow.key)}
{@const message = allVisibleMessages[virtualRow.index]}
{@const previousMessage =
virtualRow.index > 0
? allVisibleMessages[virtualRow.index - 1]
: null}
{@const isGrouped = shouldGroup(message, previousMessage)}
{@const showDateSeparator = needsDateSeparator(
message,
previousMessage,
)}
<div
class="absolute top-0 left-0 w-full"
style="transform: translateY({virtualRow.start}px);"
data-index={virtualRow.index}
use:measureRow={virtualRow.index}
>
<!-- Date separator -->
{#if showDateSeparator}
<div class="flex items-center gap-4 px-4 py-2 my-2">
<div class="flex-1 h-px bg-light/10"></div>
<span class="text-xs text-light/40 font-medium">
{formatDateSeparator(message.timestamp)}
</span>
<div class="flex-1 h-px bg-light/10"></div>
</div>
{/if}
<MessageContainer
{message}
{isGrouped}
isOwnMessage={message.sender === $auth.userId}
currentUserId={$auth.userId || ""}
onReact={(emoji: string) => onReact?.(message.eventId, emoji)}
onToggleReaction={(
emoji: string,
reactionEventId: string | null,
) => onToggleReaction?.(message.eventId, emoji, reactionEventId)}
onEdit={() => onEdit?.(message)}
onDelete={() => onDelete?.(message.eventId)}
onReply={() => onReply?.(message)}
onScrollToMessage={scrollToMessage}
replyPreview={message.replyTo
? getReplyPreview(message.replyTo)
: null}
/>
</div>
{/each}
<p class="text-body-sm text-light/40 mb-1">No messages yet</p>
<p class="text-[12px] text-light/20">Be the first to send a message!</p>
</div>
{:else}
<!-- Fallback: Non-virtualized rendering for small lists -->
<div class="py-4">
<div class="py-2">
{#each allVisibleMessages as message, i (message.eventId)}
{@const previousMessage = i > 0 ? allVisibleMessages[i - 1] : null}
{@const isGrouped = shouldGroup(message, previousMessage)}
@@ -425,12 +184,12 @@
<!-- Date separator -->
{#if showDateSeparator}
<div class="flex items-center gap-4 px-4 py-2 my-2">
<div class="flex-1 h-px bg-light/10"></div>
<span class="text-xs text-light/40 font-medium">
<div class="flex items-center gap-3 px-5 py-3">
<div class="flex-1 h-px bg-light/5"></div>
<span class="text-[11px] text-light/30 font-body select-none">
{formatDateSeparator(message.timestamp)}
</span>
<div class="flex-1 h-px bg-light/10"></div>
<div class="flex-1 h-px bg-light/5"></div>
</div>
{/if}
@@ -455,24 +214,19 @@
{/if}
</div>
<!-- Scroll to bottom button -->
<!-- Scroll to bottom FAB -->
{#if !shouldAutoScroll && allVisibleMessages.length > 0}
<button
class="absolute bottom-4 right-4 p-3 bg-primary text-white rounded-full shadow-lg
hover:bg-primary/90 transition-all transform hover:scale-105
animate-in fade-in slide-in-from-bottom-2 duration-200"
class="absolute bottom-3 right-3 w-9 h-9 flex items-center justify-center
bg-dark/80 backdrop-blur-sm border border-light/10 text-light/60
rounded-full shadow-lg hover:text-white hover:border-light/20
transition-all"
onclick={() => scrollToBottom(true)}
title="Scroll to bottom"
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<span class="material-symbols-rounded" style="font-size: 20px;"
>keyboard_arrow_down</span
>
<polyline points="6,9 12,15 18,9" />
</svg>
</button>
{/if}
</div>

View File

@@ -1,11 +1,12 @@
<script lang="ts">
import { Avatar } from "$lib/components/ui";
import { MatrixAvatar } from "$lib/components/ui";
import RoomSettingsModal from "./RoomSettingsModal.svelte";
import {
getRoomNotificationLevel,
setRoomNotificationLevel,
} from "$lib/matrix";
import { toasts } from "$lib/stores/ui";
import * as m from "$lib/paraglide/messages";
import type { RoomSummary, RoomMember } from "$lib/matrix/types";
interface Props {
@@ -17,6 +18,7 @@
let { room, members, onClose }: Props = $props();
let showSettings = $state(false);
// svelte-ignore state_referenced_locally
let isMuted = $state(getRoomNotificationLevel(room.roomId) === "mute");
let isTogglingMute = $state(false);
@@ -43,148 +45,111 @@
isMuted = !isMuted;
toasts.success(isMuted ? "Room muted" : "Room unmuted");
} catch (e) {
toasts.error("Failed to change notification settings");
toasts.error(m.toast_error_notification_settings());
} finally {
isTogglingMute = false;
}
}
</script>
<div class="h-full flex flex-col bg-dark/50">
<div class="h-full flex flex-col">
<!-- Header -->
<div class="p-4 border-b border-light/10 flex items-center justify-between">
<h2 class="font-semibold text-light">Room Info</h2>
<div
class="px-4 py-3 border-b border-light/5 flex items-center justify-between"
>
<h2 class="font-heading text-[13px] text-white">Room Info</h2>
<button
class="w-8 h-8 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded transition-colors"
class="p-1 text-light/30 hover:text-white hover:bg-light/5 rounded-lg transition-colors"
onclick={onClose}
title="Close"
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<span class="material-symbols-rounded" style="font-size: 18px;"
>close</span
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto p-4 space-y-6">
<div class="flex-1 overflow-y-auto scrollbar-thin p-4 space-y-5">
<!-- Room Avatar & Name -->
<div class="text-center">
<div class="flex justify-center mb-3">
<Avatar src={room.avatarUrl} name={room.name} size="xl" />
<MatrixAvatar mxcUrl={room.avatarUrl} name={room.name} size="xl" />
</div>
<h3 class="text-xl font-bold text-light">{room.name}</h3>
<h3 class="text-[15px] font-heading text-white">{room.name}</h3>
{#if room.topic}
<p class="text-sm text-light/60 mt-2">{room.topic}</p>
<p class="text-[12px] text-light/40 mt-1.5">{room.topic}</p>
{/if}
<button
class="mt-3 px-4 py-1.5 text-sm text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
onclick={() => (showSettings = true)}
>
<span class="inline-flex items-center gap-1">
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<div class="flex items-center justify-center gap-2 mt-3">
<button
class="px-3 py-1.5 text-[12px] text-light/50 hover:text-white hover:bg-light/5 rounded-lg transition-colors inline-flex items-center gap-1.5"
onclick={() => (showSettings = true)}
>
<span class="material-symbols-rounded" style="font-size: 16px;"
>settings</span
>
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg>
Edit Settings
</span>
</button>
<button
class="mt-2 px-4 py-1.5 text-sm rounded-lg transition-colors {isMuted
? 'bg-red-500/20 text-red-400 hover:bg-red-500/30'
: 'text-light/60 hover:text-light hover:bg-light/10'}"
onclick={toggleMute}
disabled={isTogglingMute}
>
<span class="inline-flex items-center gap-1">
{#if isMuted}
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M11 5L6 9H2v6h4l5 4V5z" />
<line x1="23" y1="9" x2="17" y2="15" />
<line x1="17" y1="9" x2="23" y2="15" />
</svg>
Muted
{:else}
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path
d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"
/>
</svg>
Notifications On
{/if}
</span>
</button>
Settings
</button>
<button
class="px-3 py-1.5 text-[12px] rounded-lg transition-colors inline-flex items-center gap-1.5
{isMuted
? 'bg-red-500/10 text-red-400 hover:bg-red-500/20'
: 'text-light/50 hover:text-white hover:bg-light/5'}"
onclick={toggleMute}
disabled={isTogglingMute}
>
<span class="material-symbols-rounded" style="font-size: 16px;">
{isMuted ? "notifications_off" : "notifications"}
</span>
{isMuted ? "Muted" : "Notifications"}
</button>
</div>
</div>
<!-- Room Stats -->
<div class="grid grid-cols-2 gap-3">
<div class="bg-night rounded-lg p-3 text-center">
<p class="text-2xl font-bold text-light">{room.memberCount}</p>
<p class="text-xs text-light/50">Members</p>
<div class="grid grid-cols-2 gap-2">
<div class="bg-dark/30 border border-light/5 rounded-lg p-3 text-center">
<p class="text-[18px] font-heading text-white">{room.memberCount}</p>
<p class="text-[10px] text-light/30">Members</p>
</div>
<div class="bg-night rounded-lg p-3 text-center">
<p class="text-2xl font-bold text-light">
{room.isEncrypted ? "🔒" : "🔓"}
</p>
<p class="text-xs text-light/50">
<div class="bg-dark/30 border border-light/5 rounded-lg p-3 text-center">
<span
class="material-symbols-rounded text-light/40"
style="font-size: 20px;"
>
{room.isEncrypted ? "lock" : "lock_open"}
</span>
<p class="text-[10px] text-light/30 mt-0.5">
{room.isEncrypted ? "Encrypted" : "Not Encrypted"}
</p>
</div>
</div>
<!-- Room Details -->
<div class="space-y-3">
<h4 class="text-sm font-semibold text-light/40 uppercase tracking-wider">
<div class="space-y-2">
<h4 class="text-[10px] font-body text-light/25 uppercase tracking-wider">
Details
</h4>
<div class="space-y-2 text-sm">
<div class="space-y-1.5 text-[12px]">
<div class="flex justify-between">
<span class="text-light/50">Room ID</span>
<span class="text-light/35">Room ID</span>
<span
class="text-light font-mono text-xs truncate max-w-[150px]"
class="text-light/60 font-mono text-[10px] truncate max-w-[140px]"
title={room.roomId}
>
{room.roomId}
</span>
</div>
<div class="flex justify-between">
<span class="text-light/50">Type</span>
<span class="text-light"
<span class="text-light/35">Type</span>
<span class="text-light/60"
>{room.isDirect ? "Direct Message" : "Room"}</span
>
</div>
{#if room.lastActivity}
<div class="flex justify-between">
<span class="text-light/50">Last Activity</span>
<span class="text-light">{formatDate(room.lastActivity)}</span>
<span class="text-light/35">Last Activity</span>
<span class="text-light/60">{formatDate(room.lastActivity)}</span>
</div>
{/if}
</div>
@@ -192,20 +157,29 @@
<!-- Members by Role -->
{#if admins.length > 0}
<div class="space-y-2">
<div class="space-y-1.5">
<h4
class="text-sm font-semibold text-light/40 uppercase tracking-wider"
class="text-[10px] font-body text-light/25 uppercase tracking-wider"
>
Admins ({admins.length})
</h4>
<ul class="space-y-1">
<ul class="space-y-0.5">
{#each admins as member}
<li
class="flex items-center gap-2 px-2 py-1 rounded hover:bg-light/5"
class="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-light/5 transition-colors"
>
<Avatar src={member.avatarUrl} name={member.name} size="xs" />
<span class="text-sm text-light truncate">{member.name}</span>
<span class="ml-auto text-xs text-yellow-400">👑</span>
<MatrixAvatar
mxcUrl={member.avatarUrl}
name={member.name}
size="xs"
/>
<span class="text-[12px] text-light/70 truncate"
>{member.name}</span
>
<span
class="material-symbols-rounded ml-auto text-yellow-400"
style="font-size: 14px;">shield_person</span
>
</li>
{/each}
</ul>
@@ -213,41 +187,55 @@
{/if}
{#if moderators.length > 0}
<div class="space-y-2">
<div class="space-y-1.5">
<h4
class="text-sm font-semibold text-light/40 uppercase tracking-wider"
class="text-[10px] font-body text-light/25 uppercase tracking-wider"
>
Moderators ({moderators.length})
</h4>
<ul class="space-y-1">
<ul class="space-y-0.5">
{#each moderators as member}
<li
class="flex items-center gap-2 px-2 py-1 rounded hover:bg-light/5"
class="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-light/5 transition-colors"
>
<Avatar src={member.avatarUrl} name={member.name} size="xs" />
<span class="text-sm text-light truncate">{member.name}</span>
<span class="ml-auto text-xs text-blue-400">🛡️</span>
<MatrixAvatar
mxcUrl={member.avatarUrl}
name={member.name}
size="xs"
/>
<span class="text-[12px] text-light/70 truncate"
>{member.name}</span
>
<span
class="material-symbols-rounded ml-auto text-blue-400"
style="font-size: 14px;">shield</span
>
</li>
{/each}
</ul>
</div>
{/if}
<div class="space-y-2">
<h4 class="text-sm font-semibold text-light/40 uppercase tracking-wider">
<div class="space-y-1.5">
<h4 class="text-[10px] font-body text-light/25 uppercase tracking-wider">
Members ({regularMembers.length})
</h4>
<ul class="space-y-1">
<ul class="space-y-0.5">
{#each regularMembers.slice(0, 20) as member}
<li
class="flex items-center gap-2 px-2 py-1 rounded hover:bg-light/5"
class="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-light/5 transition-colors"
>
<Avatar src={member.avatarUrl} name={member.name} size="xs" />
<span class="text-sm text-light truncate">{member.name}</span>
<MatrixAvatar
mxcUrl={member.avatarUrl}
name={member.name}
size="xs"
/>
<span class="text-[12px] text-light/70 truncate">{member.name}</span
>
</li>
{/each}
{#if regularMembers.length > 20}
<li class="text-xs text-light/40 text-center py-2">
<li class="text-[11px] text-light/25 text-center py-2">
+{regularMembers.length - 20} more members
</li>
{/if}

View File

@@ -1,7 +1,11 @@
<script lang="ts">
import { Avatar } from "$lib/components/ui";
import { MatrixAvatar } from "$lib/components/ui";
import { setRoomName, setRoomTopic, setRoomAvatar } from "$lib/matrix";
import { toasts } from "$lib/stores/ui";
import * as m from "$lib/paraglide/messages";
import { createLogger } from "$lib/utils/logger";
const log = createLogger("matrix:settings");
import { syncRoomsFromEvent } from "$lib/stores/matrix";
import type { RoomSummary } from "$lib/matrix/types";
@@ -12,7 +16,9 @@
let { room, onClose }: Props = $props();
// svelte-ignore state_referenced_locally
let name = $state(room.name);
// svelte-ignore state_referenced_locally
let topic = $state(room.topic || "");
let isSaving = $state(false);
let avatarFile = $state<File | null>(null);
@@ -50,11 +56,11 @@
await Promise.all(promises);
syncRoomsFromEvent("update", room.roomId);
toasts.success("Room settings updated");
toasts.success(m.toast_success_room_updated());
onClose();
} catch (e) {
console.error("Failed to update room settings:", e);
toasts.error("Failed to update room settings");
log.error("Failed to update room settings", { error: e });
toasts.error(m.toast_error_update_room());
} finally {
isSaving = false;
}
@@ -74,6 +80,7 @@
aria-labelledby="settings-title"
tabindex="-1"
>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="bg-dark rounded-2xl p-6 w-full max-w-md mx-4"
role="document"
@@ -105,7 +112,11 @@
<!-- Avatar -->
<div class="flex flex-col items-center mb-6">
<div class="relative group">
<Avatar src={avatarPreview || room.avatarUrl} {name} size="xl" />
<MatrixAvatar
mxcUrl={avatarPreview || room.avatarUrl}
{name}
size="xl"
/>
<label
class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 rounded-full cursor-pointer transition-opacity"
>

View File

@@ -1,7 +1,10 @@
<script lang="ts">
import { Avatar } from '$lib/components/ui';
import { searchUsers, createDirectMessage } from '$lib/matrix';
import { toasts } from '$lib/stores/ui';
import { MatrixAvatar } from "$lib/components/ui";
import { searchUsers, createDirectMessage } from "$lib/matrix";
import { toasts } from "$lib/stores/ui";
import { createLogger, getErrorMessage } from "$lib/utils/logger";
const log = createLogger("matrix:dm");
interface Props {
onClose: () => void;
@@ -10,15 +13,17 @@
let { onClose, onDMCreated }: Props = $props();
let searchQuery = $state('');
let searchResults = $state<Array<{ userId: string; displayName: string; avatarUrl: string | null }>>([]);
let searchQuery = $state("");
let searchResults = $state<
Array<{ userId: string; displayName: string; avatarUrl: string | null }>
>([]);
let isSearching = $state(false);
let isCreating = $state(false);
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
function handleSearch() {
if (searchTimeout) clearTimeout(searchTimeout);
if (!searchQuery.trim()) {
searchResults = [];
return;
@@ -29,7 +34,7 @@
try {
searchResults = await searchUsers(searchQuery);
} catch (e) {
console.error('Search failed:', e);
log.error("Search failed", { error: e });
} finally {
isSearching = false;
}
@@ -40,19 +45,19 @@
isCreating = true;
try {
const roomId = await createDirectMessage(userId);
toasts.success('Direct message started!');
toasts.success("Direct message started!");
onDMCreated(roomId);
onClose();
} catch (e: any) {
console.error('Failed to create DM:', e);
toasts.error(e.message || 'Failed to start direct message');
} catch (e: unknown) {
log.error("Failed to create DM", { error: e });
toasts.error(getErrorMessage(e, "Failed to start direct message"));
} finally {
isCreating = false;
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (e.key === "Escape") {
onClose();
}
}
@@ -60,6 +65,7 @@
<svelte:window onkeydown={handleKeyDown} />
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onclick={onClose}
@@ -67,6 +73,8 @@
aria-modal="true"
tabindex="-1"
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="bg-dark rounded-2xl p-6 w-full max-w-md mx-4"
onclick={(e) => e.stopPropagation()}
@@ -76,10 +84,17 @@
<div class="mb-4">
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-light/40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-light/40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
bind:value={searchQuery}
@@ -94,9 +109,24 @@
<div class="max-h-64 overflow-y-auto">
{#if isSearching}
<div class="text-center py-8 text-light/40">
<svg class="w-6 h-6 animate-spin mx-auto mb-2" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
<svg
class="w-6 h-6 animate-spin mx-auto mb-2"
viewBox="0 0 24 24"
fill="none"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg>
Searching...
</div>
@@ -109,9 +139,15 @@
onclick={() => handleStartDM(user.userId)}
disabled={isCreating}
>
<Avatar src={user.avatarUrl} name={user.displayName} size="sm" />
<MatrixAvatar
mxcUrl={user.avatarUrl}
name={user.displayName}
size="sm"
/>
<div class="flex-1 min-w-0">
<p class="text-light font-medium truncate">{user.displayName}</p>
<p class="text-light font-medium truncate">
{user.displayName}
</p>
<p class="text-xs text-light/40 truncate">{user.userId}</p>
</div>
</button>

View File

@@ -1,6 +1,9 @@
<script lang="ts">
import { syncState, syncError, clearState } from "$lib/stores/matrix";
import { clearAllCache } from "$lib/cache";
import { createLogger } from "$lib/utils/logger";
const log = createLogger('matrix:sync');
interface Props {
onHardRefresh?: () => void;
@@ -43,7 +46,7 @@
// Reload the page for clean state
window.location.reload();
} catch (error) {
console.error("[SyncRecovery] Hard refresh failed:", error);
log.error('Hard refresh failed', { error });
isRefreshing = false;
}
}

View File

@@ -6,21 +6,30 @@
let { userNames }: Props = $props();
function formatTypingText(names: string[]): string {
if (names.length === 0) return '';
if (names.length === 0) return "";
if (names.length === 1) return `${names[0]} is typing`;
if (names.length === 2) return `${names[0]} and ${names[1]} are typing`;
if (names.length === 3) return `${names[0]}, ${names[1]}, and ${names[2]} are typing`;
if (names.length === 3)
return `${names[0]}, ${names[1]}, and ${names[2]} are typing`;
return `${names[0]}, ${names[1]}, and ${names.length - 2} others are typing`;
}
</script>
{#if userNames.length > 0}
<div class="flex items-center gap-2 px-4 py-2 text-sm text-light/50">
<!-- Animated dots -->
<div class="flex gap-1">
<span class="w-2 h-2 bg-light/50 rounded-full animate-bounce" style="animation-delay: 0ms"></span>
<span class="w-2 h-2 bg-light/50 rounded-full animate-bounce" style="animation-delay: 150ms"></span>
<span class="w-2 h-2 bg-light/50 rounded-full animate-bounce" style="animation-delay: 300ms"></span>
<div class="flex items-center gap-1.5 px-5 py-1.5 text-[11px] text-light/35">
<div class="flex gap-0.5">
<span
class="w-1.5 h-1.5 bg-light/35 rounded-full animate-bounce"
style="animation-delay: 0ms"
></span>
<span
class="w-1.5 h-1.5 bg-light/35 rounded-full animate-bounce"
style="animation-delay: 150ms"
></span>
<span
class="w-1.5 h-1.5 bg-light/35 rounded-full animate-bounce"
style="animation-delay: 300ms"
></span>
</div>
<span>{formatTypingText(userNames)}</span>
</div>

View File

@@ -1,9 +1,12 @@
<script lang="ts">
import { Avatar } from '$lib/components/ui';
import { createDirectMessage } from '$lib/matrix';
import { userPresence } from '$lib/stores/matrix';
import { toasts } from '$lib/stores/ui';
import type { RoomMember } from '$lib/matrix/types';
import { MatrixAvatar } from "$lib/components/ui";
import { createDirectMessage } from "$lib/matrix";
import { userPresence } from "$lib/stores/matrix";
import { toasts } from "$lib/stores/ui";
import { createLogger } from "$lib/utils/logger";
const log = createLogger("matrix:profile");
import type { RoomMember } from "$lib/matrix/types";
interface Props {
member: RoomMember;
@@ -16,16 +19,18 @@
let isStartingDM = $state(false);
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
if (e.key === "Escape") onClose();
}
const presence = $derived($userPresence.get(member.userId) || 'offline');
const presence = $derived($userPresence.get(member.userId) || "offline");
const presenceLabel = $derived({
online: { text: 'Online', color: 'text-green-400' },
offline: { text: 'Offline', color: 'text-gray-400' },
unavailable: { text: 'Away', color: 'text-yellow-400' },
}[presence]);
const presenceLabel = $derived(
{
online: { text: "Online", color: "text-green-400" },
offline: { text: "Offline", color: "text-gray-400" },
unavailable: { text: "Away", color: "text-yellow-400" },
}[presence],
);
async function handleStartDM() {
isStartingDM = true;
@@ -35,16 +40,28 @@
onStartDM?.(roomId);
onClose();
} catch (e) {
console.error('Failed to start DM:', e);
toasts.error('Failed to start direct message');
log.error("Failed to start DM", { error: e });
toasts.error("Failed to start direct message");
} finally {
isStartingDM = false;
}
}
function getRoleBadge(powerLevel: number): { label: string; color: string; icon: string } | null {
if (powerLevel >= 100) return { label: 'Admin', color: 'bg-red-500/20 text-red-400', icon: '👑' };
if (powerLevel >= 50) return { label: 'Moderator', color: 'bg-blue-500/20 text-blue-400', icon: '🛡️' };
function getRoleBadge(
powerLevel: number,
): { label: string; color: string; icon: string } | null {
if (powerLevel >= 100)
return {
label: "Admin",
color: "bg-red-500/20 text-red-400",
icon: "👑",
};
if (powerLevel >= 50)
return {
label: "Moderator",
color: "bg-blue-500/20 text-blue-400",
icon: "🛡️",
};
return null;
}
@@ -60,8 +77,9 @@
aria-labelledby="profile-title"
tabindex="-1"
onclick={onClose}
onkeydown={(e) => e.key === 'Enter' && onClose()}
onkeydown={(e) => e.key === "Enter" && onClose()}
>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="bg-dark rounded-2xl w-full max-w-sm mx-4 overflow-hidden"
role="document"
@@ -75,7 +93,13 @@
onclick={onClose}
title="Close"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
@@ -85,26 +109,46 @@
<!-- Avatar -->
<div class="flex justify-center -mt-12 relative z-10">
<div class="ring-4 ring-dark rounded-full">
<Avatar src={member.avatarUrl} name={member.name} size="xl" status={presence === 'online' ? 'online' : presence === 'unavailable' ? 'away' : 'offline'} />
<MatrixAvatar
mxcUrl={member.avatarUrl}
name={member.name}
size="xl"
status={presence === "online"
? "online"
: presence === "unavailable"
? "away"
: "offline"}
/>
</div>
</div>
<!-- Content -->
<div class="p-6 pt-3 text-center">
<h2 id="profile-title" class="text-xl font-bold text-light">{member.name}</h2>
<h2 id="profile-title" class="text-xl font-bold text-light">
{member.name}
</h2>
<p class="text-sm text-light/50 mt-1">{member.userId}</p>
<!-- Status -->
<div class="flex items-center justify-center gap-2 mt-3">
<span class="w-2 h-2 rounded-full {presence === 'online' ? 'bg-green-400' : presence === 'unavailable' ? 'bg-yellow-400' : 'bg-gray-400'}"></span>
<span
class="w-2 h-2 rounded-full {presence === 'online'
? 'bg-green-400'
: presence === 'unavailable'
? 'bg-yellow-400'
: 'bg-gray-400'}"
></span>
<span class="text-sm {presenceLabel.color}">{presenceLabel.text}</span>
</div>
<!-- Role badge -->
{#if roleBadge}
<div class="mt-3">
<span class="inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs {roleBadge.color}">
{roleBadge.icon} {roleBadge.label}
<span
class="inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs {roleBadge.color}"
>
{roleBadge.icon}
{roleBadge.label}
</span>
</div>
{/if}
@@ -116,10 +160,18 @@
onclick={handleStartDM}
disabled={isStartingDM}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
/>
</svg>
{isStartingDM ? 'Starting...' : 'Send Message'}
{isStartingDM ? "Starting..." : "Send Message"}
</button>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { Avatar } from "$lib/components/ui";
import { MatrixAvatar } from "$lib/components/ui";
import { getReadReceiptsForEvent } from "$lib/matrix";
import type { Message } from "$lib/matrix/types";
import { formatTime } from "./utils";
@@ -66,8 +66,8 @@
</script>
<div
class="group relative px-4 py-0.5 hover:bg-light/5 transition-colors {message.isPending
? 'opacity-50'
class="group relative px-5 py-0.5 hover:bg-light/[0.02] transition-colors {message.isPending
? 'opacity-40'
: ''}"
onmouseenter={() => (showActions = true)}
onmouseleave={() => (showActions = false)}
@@ -77,32 +77,22 @@
<!-- Reply preview -->
{#if replyPreview && message.replyTo}
<button
class="flex items-center gap-1.5 ml-14 mt-1 text-xs hover:opacity-80 transition-opacity cursor-pointer"
class="flex items-center gap-1.5 ml-12 mt-1 mb-0.5 text-[11px] hover:opacity-80 transition-opacity cursor-pointer"
onclick={() => onScrollToMessage?.(message.replyTo!)}
>
<div class="flex items-center gap-1.5">
<div class="flex shrink-0">
<Avatar
src={replyPreview.senderAvatar}
name={replyPreview.senderName}
size="xs"
/>
</div>
<span class="text-light/70 font-medium">{replyPreview.senderName}</span>
</div>
<span class="text-light/50 truncate max-w-xs">
<div class="w-0.5 h-3 bg-primary/40 rounded-full shrink-0"></div>
<MatrixAvatar
mxcUrl={replyPreview.senderAvatar}
name={replyPreview.senderName}
size="xs"
/>
<span class="text-light/50 font-body">{replyPreview.senderName}</span>
<span class="text-light/30 truncate max-w-xs">
{#if replyPreview.hasAttachment}
<svg
class="w-3 h-3 inline mr-0.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<span
class="material-symbols-rounded align-middle mr-0.5"
style="font-size: 12px;">image</span
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21,15 16,10 5,21" />
</svg>
{/if}
{replyPreview.content}
</span>
@@ -110,11 +100,11 @@
{/if}
{#if isGrouped}
<!-- Grouped message (same sender, close in time) -->
<div class="flex gap-4">
<div class="w-10 shrink-0 flex items-center justify-center">
<!-- Grouped message -->
<div class="flex gap-3">
<div class="w-9 shrink-0 flex items-center justify-center">
<span
class="text-[10px] text-light/30 opacity-0 group-hover:opacity-100 transition-opacity"
class="text-[10px] text-light/20 opacity-0 group-hover:opacity-100 transition-opacity select-none"
>
{formatTime(message.timestamp)}
</span>
@@ -136,21 +126,23 @@
</div>
</div>
{:else}
<!-- Full message with avatar - mt-4 creates gap between message groups -->
<div class="flex gap-4 mt-4 first:mt-0">
<div class="w-10 shrink-0">
<Avatar
src={message.senderAvatar}
<!-- Full message with avatar -->
<div class="flex gap-3 mt-3 first:mt-0">
<div class="w-9 shrink-0 pt-0.5">
<MatrixAvatar
mxcUrl={message.senderAvatar}
name={message.senderName}
size="md"
size="sm"
/>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-baseline gap-2 mb-0.5">
<span class="font-semibold text-light hover:underline cursor-pointer">
<div class="flex items-baseline gap-2 mb-px">
<span
class="text-[13px] font-heading text-white hover:underline cursor-pointer"
>
{message.senderName}
</span>
<span class="text-xs text-light/40">
<span class="text-[10px] text-light/25 select-none">
{formatTime(message.timestamp)}
</span>
</div>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import Twemoji from '$lib/components/ui/Twemoji.svelte';
import EmojiPicker from '$lib/components/ui/EmojiPicker.svelte';
import Twemoji from "$lib/components/ui/Twemoji.svelte";
import EmojiPicker from "$lib/components/ui/EmojiPicker.svelte";
interface Props {
isOwnMessage?: boolean;
@@ -26,170 +26,152 @@
onPin,
}: Props = $props();
const quickReactions = ['👍', '❤️', '😂'];
const quickReactions = ["👍", "❤️", "😂"];
let showEmojiPicker = $state(false);
let showContextMenu = $state(false);
let menuPosition = $state({ x: 0, y: 0 });
let menuRef: HTMLDivElement | undefined = $state();
function openContextMenu(e: MouseEvent) {
const button = e.currentTarget as HTMLElement;
const rect = button.getBoundingClientRect();
const menuHeight = 200;
const viewportHeight = window.innerHeight;
let y = rect.bottom + 4;
if (y + menuHeight > viewportHeight) {
y = rect.top - menuHeight - 4;
}
menuPosition = { x: rect.right - 180, y: Math.max(8, y) };
showContextMenu = !showContextMenu;
showEmojiPicker = false;
}
function openEmojiPicker(e: MouseEvent) {
const button = e.currentTarget as HTMLElement;
const rect = button.getBoundingClientRect();
const menuHeight = 150;
const viewportHeight = window.innerHeight;
let y = rect.bottom + 4;
if (y + menuHeight > viewportHeight) {
y = rect.top - menuHeight - 4;
}
menuPosition = { x: rect.right - 220, y: Math.max(8, y) };
showEmojiPicker = !showEmojiPicker;
showContextMenu = false;
}
</script>
<div class="absolute right-4 -top-3 flex items-center gap-0.5 bg-dark border border-light/20 rounded-lg shadow-lg p-0.5">
<div
class="absolute right-3 -top-3 flex items-center gap-px bg-dark/90 backdrop-blur-sm border border-light/10 rounded-lg shadow-lg p-0.5 z-10"
>
<!-- Quick reactions -->
{#each quickReactions as emoji}
<button
class="w-8 h-8 flex items-center justify-center hover:bg-light/10 rounded transition-colors"
class="w-7 h-7 flex items-center justify-center hover:bg-light/10 rounded transition-colors"
onclick={() => onReact?.(emoji)}
title="React with {emoji}"
>
<Twemoji {emoji} size={18} />
<Twemoji {emoji} size={16} />
</button>
{/each}
<!-- Emoji picker -->
<!-- Emoji picker trigger -->
<div class="relative">
<button
class="w-8 h-8 flex items-center justify-center hover:bg-light/10 rounded transition-colors text-light/60 hover:text-light"
class="w-7 h-7 flex items-center justify-center hover:bg-light/10 rounded transition-colors text-light/40 hover:text-light"
onclick={openEmojiPicker}
title="Add reaction"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
<line x1="9" y1="9" x2="9.01" y2="9" />
<line x1="15" y1="9" x2="15.01" y2="9" />
</svg>
<span class="material-symbols-rounded" style="font-size: 16px;"
>add_reaction</span
>
</button>
{#if showEmojiPicker}
<EmojiPicker
position={menuPosition}
onSelect={(emoji) => onReact?.(emoji)}
onClose={() => (showEmojiPicker = false)}
/>
<div class="absolute bottom-full right-0 mb-2 z-50">
<EmojiPicker
onSelect={(emoji) => {
onReact?.(emoji);
showEmojiPicker = false;
}}
onClose={() => (showEmojiPicker = false)}
/>
</div>
{/if}
</div>
<div class="w-px h-6 bg-light/20 mx-0.5"></div>
<div class="w-px h-5 bg-light/10 mx-0.5"></div>
<!-- Reply button -->
<!-- Reply -->
<button
class="w-8 h-8 flex items-center justify-center hover:bg-light/10 rounded transition-colors text-light/60 hover:text-light"
class="w-7 h-7 flex items-center justify-center hover:bg-light/10 rounded transition-colors text-light/40 hover:text-light"
onclick={() => onReply?.()}
title="Reply"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9,17 4,12 9,7" />
<path d="M20,18 v-2 a4,4 0 0,0 -4,-4 H4" />
</svg>
<span class="material-symbols-rounded" style="font-size: 16px;">reply</span>
</button>
<!-- Edit button (own messages only) -->
<!-- Edit (own messages only) -->
{#if isOwnMessage}
<button
class="w-8 h-8 flex items-center justify-center hover:bg-light/10 rounded transition-colors text-light/60 hover:text-light"
class="w-7 h-7 flex items-center justify-center hover:bg-light/10 rounded transition-colors text-light/40 hover:text-light"
onclick={() => onEdit?.()}
title="Edit"
>
<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>
<span class="material-symbols-rounded" style="font-size: 16px;">edit</span
>
</button>
{/if}
<!-- Context menu -->
<div class="relative">
<!-- More options -->
<div class="relative" bind:this={menuRef}>
<button
class="w-8 h-8 flex items-center justify-center hover:bg-light/10 rounded transition-colors text-light/60 hover:text-light"
class="w-7 h-7 flex items-center justify-center hover:bg-light/10 rounded transition-colors text-light/40 hover:text-light"
onclick={openContextMenu}
title="More options"
title="More"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="1" />
<circle cx="19" cy="12" r="1" />
<circle cx="5" cy="12" r="1" />
</svg>
<span class="material-symbols-rounded" style="font-size: 16px;"
>more_horiz</span
>
</button>
{#if showContextMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="fixed bg-dark border border-light/20 rounded-lg shadow-xl py-1 z-[100] min-w-[180px]"
style="left: {menuPosition.x}px; top: {menuPosition.y}px;"
class="absolute right-0 top-full mt-1 bg-dark/95 backdrop-blur-sm border border-light/10 rounded-lg shadow-xl py-1 z-[100] min-w-[160px]"
onclick={(e) => e.stopPropagation()}
>
<button
class="w-full px-3 py-2 text-left text-sm text-light/80 hover:bg-light/10 flex items-center gap-2"
onclick={() => { onPin?.(); showContextMenu = false; }}
class="w-full px-3 py-1.5 text-left text-[12px] text-light/70 hover:bg-light/5 hover:text-white flex items-center gap-2 transition-colors"
onclick={() => {
onPin?.();
showContextMenu = false;
}}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill={isPinned ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2">
<path d="M12 2L12 12M12 12L8 8M12 12L16 8" transform="rotate(45 12 12)" />
<line x1="5" y1="12" x2="19" y2="12" transform="rotate(45 12 12)" />
</svg>
{isPinned ? 'Unpin' : 'Pin'} message
<span class="material-symbols-rounded" style="font-size: 16px;"
>{isPinned ? "push_pin" : "push_pin"}</span
>
{isPinned ? "Unpin" : "Pin"} message
</button>
<button
class="w-full px-3 py-2 text-left text-sm text-light/80 hover:bg-light/10 flex items-center gap-2"
onclick={() => { navigator.clipboard.writeText(messageContent); showContextMenu = false; }}
class="w-full px-3 py-1.5 text-left text-[12px] text-light/70 hover:bg-light/5 hover:text-white flex items-center gap-2 transition-colors"
onclick={() => {
navigator.clipboard.writeText(messageContent);
showContextMenu = false;
}}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
<span class="material-symbols-rounded" style="font-size: 16px;"
>content_copy</span
>
Copy text
</button>
<button
class="w-full px-3 py-2 text-left text-sm text-light/80 hover:bg-light/10 flex items-center gap-2"
onclick={() => { navigator.clipboard.writeText(messageEventId); showContextMenu = false; }}
class="w-full px-3 py-1.5 text-left text-[12px] text-light/70 hover:bg-light/5 hover:text-white flex items-center gap-2 transition-colors"
onclick={() => {
navigator.clipboard.writeText(messageEventId);
showContextMenu = false;
}}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
<span class="material-symbols-rounded" style="font-size: 16px;"
>link</span
>
Copy message ID
</button>
{#if isOwnMessage}
<div class="h-px bg-light/10 my-1"></div>
<div class="h-px bg-light/5 my-1"></div>
<button
class="w-full px-3 py-2 text-left text-sm text-red-400 hover:bg-red-500/10 flex items-center gap-2"
onclick={() => { onDelete?.(); showContextMenu = false; }}
class="w-full px-3 py-1.5 text-left text-[12px] text-red-400 hover:bg-red-500/10 flex items-center gap-2 transition-colors"
onclick={() => {
onDelete?.();
showContextMenu = false;
}}
>
<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,6 v14 a2,2 0 0,1 -2,2 H7 a2,2 0 0,1 -2,-2 V6 m3,0 V4 a2,2 0 0,1 2,-2 h4 a2,2 0 0,1 2,2 v2" />
</svg>
<span class="material-symbols-rounded" style="font-size: 16px;"
>delete</span
>
Delete message
</button>
{/if}

View File

@@ -56,7 +56,7 @@
</script>
{#if reactions.size > 0}
<div class="flex flex-wrap items-center gap-1 mt-1 ml-14">
<div class="flex flex-wrap items-center gap-1 mt-1 ml-12">
{#each [...reactions.entries()] as [emoji, userMap]}
{@const hasReacted = userMap.has(currentUserId)}
{@const reactionEventId = getUserReactionEventId(emoji)}

View File

@@ -15,7 +15,7 @@
{#if receipts.length > 0}
<div
class="flex items-center gap-1 mt-1 ml-14"
class="flex items-center gap-1 mt-1 ml-12"
title="Read by {receipts.map((r) => r.name).join(', ')}"
>
<span class="text-xs text-light/40 mr-1">Read by</span>

View File

@@ -0,0 +1,743 @@
<script lang="ts">
import type { BudgetCategory, BudgetItem } from "$lib/supabase/types";
import { Input, Select, Textarea } from "$lib/components/ui";
import * as m from "$lib/paraglide/messages";
import { formatCurrency as fmtCurrency } from "$lib/utils/currency";
interface Props {
categories: BudgetCategory[];
items: BudgetItem[];
isEditor: boolean;
currency?: string;
fullscreen?: boolean;
onCreateCategory: (name: string, color: string) => void;
onDeleteCategory: (categoryId: string) => void;
onCreateItem: (params: {
description: string;
item_type: "income" | "expense";
planned_amount?: number;
actual_amount?: number;
category_id?: string | null;
notes?: string;
}) => void;
onUpdateItem: (
itemId: string,
params: Partial<
Pick<
BudgetItem,
| "description"
| "item_type"
| "planned_amount"
| "actual_amount"
| "category_id"
| "notes"
| "receipt_document_id"
>
>,
) => void;
onDeleteItem: (itemId: string) => void;
onUploadReceipt?: (itemId: string) => void;
}
let {
categories,
items,
isEditor,
currency = "EUR",
fullscreen = false,
onCreateCategory,
onDeleteCategory,
onCreateItem,
onUpdateItem,
onDeleteItem,
onUploadReceipt,
}: Props = $props();
// Count items missing receipts
const missingReceiptCount = $derived(
items.filter(
(i) => Number(i.actual_amount) > 0 && !i.receipt_document_id,
).length,
);
let viewMode = $state<"overview" | "income" | "expense">("overview");
let showAddItemModal = $state(false);
let showAddCategoryModal = $state(false);
let editingItem = $state<BudgetItem | null>(null);
// Form state
let newCategoryName = $state("");
let newCategoryColor = $state("#6366f1");
let formDescription = $state("");
let formType = $state<"income" | "expense">("expense");
let formPlanned = $state("0");
let formActual = $state("0");
let formCategoryId = $state<string | null>(null);
let formNotes = $state("");
const CATEGORY_COLORS = [
"#6366f1",
"#10B981",
"#F59E0B",
"#EF4444",
"#EC4899",
"#8B5CF6",
"#06B6D4",
"#F97316",
];
// Computed totals
const incomeItems = $derived(items.filter((i) => i.item_type === "income"));
const expenseItems = $derived(
items.filter((i) => i.item_type === "expense"),
);
const totalPlannedIncome = $derived(
incomeItems.reduce((s, i) => s + Number(i.planned_amount), 0),
);
const totalActualIncome = $derived(
incomeItems.reduce((s, i) => s + Number(i.actual_amount), 0),
);
const totalPlannedExpense = $derived(
expenseItems.reduce((s, i) => s + Number(i.planned_amount), 0),
);
const totalActualExpense = $derived(
expenseItems.reduce((s, i) => s + Number(i.actual_amount), 0),
);
const plannedBalance = $derived(totalPlannedIncome - totalPlannedExpense);
const actualBalance = $derived(totalActualIncome - totalActualExpense);
const filteredItems = $derived(
viewMode === "income"
? incomeItems
: viewMode === "expense"
? expenseItems
: items,
);
function getCategoryName(categoryId: string | null): string {
if (!categoryId) return m.budget_uncategorized();
return (
categories.find((c) => c.id === categoryId)?.name ??
m.budget_uncategorized()
);
}
function getCategoryColor(categoryId: string | null): string {
if (!categoryId) return "#64748b";
return categories.find((c) => c.id === categoryId)?.color ?? "#64748b";
}
function formatCurrency(amount: number): string {
return fmtCurrency(amount, currency ?? "EUR");
}
function openAddItem(type: "income" | "expense" = "expense") {
editingItem = null;
formDescription = "";
formType = type;
formPlanned = "0";
formActual = "0";
formCategoryId = null;
formNotes = "";
showAddItemModal = true;
}
function openEditItem(item: BudgetItem) {
editingItem = item;
formDescription = item.description ?? "";
formType = item.item_type as "income" | "expense";
formPlanned = String(item.planned_amount);
formActual = String(item.actual_amount);
formCategoryId = item.category_id;
formNotes = item.notes ?? "";
showAddItemModal = true;
}
function handleSubmitItem() {
if (!formDescription.trim()) return;
if (editingItem) {
onUpdateItem(editingItem.id, {
description: formDescription.trim(),
item_type: formType,
planned_amount: parseFloat(formPlanned) || 0,
actual_amount: parseFloat(formActual) || 0,
category_id: formCategoryId,
notes: formNotes.trim() || undefined,
});
} else {
onCreateItem({
description: formDescription.trim(),
item_type: formType,
planned_amount: parseFloat(formPlanned) || 0,
actual_amount: parseFloat(formActual) || 0,
category_id: formCategoryId,
notes: formNotes.trim() || undefined,
});
}
showAddItemModal = false;
}
function handleAddCategory() {
if (!newCategoryName.trim()) return;
onCreateCategory(newCategoryName.trim(), newCategoryColor);
newCategoryName = "";
newCategoryColor = "#6366f1";
showAddCategoryModal = false;
}
// Group items by category for overview
const itemsByCategory = $derived(() => {
const map = new Map<string | null, BudgetItem[]>();
for (const item of filteredItems) {
const key = item.category_id;
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(item);
}
return map;
});
</script>
<div class="flex flex-col gap-3 {fullscreen ? 'h-full' : ''}">
<!-- Summary cards -->
<div class="grid grid-cols-2 {fullscreen ? 'md:grid-cols-4' : ''} gap-2">
<div
class="bg-emerald-500/10 border border-emerald-500/20 rounded-xl p-3"
>
<p class="text-[11px] text-emerald-400/70 uppercase tracking-wide">
{m.budget_income()}
</p>
<p class="text-body font-heading text-emerald-400">
{formatCurrency(totalActualIncome)}
</p>
<p class="text-[11px] text-light/30">
{m.budget_planned({
amount: formatCurrency(totalPlannedIncome),
})}
</p>
</div>
<div class="bg-red-500/10 border border-red-500/20 rounded-xl p-3">
<p class="text-[11px] text-red-400/70 uppercase tracking-wide">
{m.budget_expenses()}
</p>
<p class="text-body font-heading text-red-400">
{formatCurrency(totalActualExpense)}
</p>
<p class="text-[11px] text-light/30">
{m.budget_planned({
amount: formatCurrency(totalPlannedExpense),
})}
</p>
</div>
{#if fullscreen}
<div
class="bg-blue-500/10 border border-blue-500/20 rounded-xl p-3"
>
<p class="text-[11px] text-blue-400/70 uppercase tracking-wide">
{m.budget_planned_balance()}
</p>
<p
class="text-body font-heading {plannedBalance >= 0
? 'text-blue-400'
: 'text-red-400'}"
>
{formatCurrency(plannedBalance)}
</p>
</div>
<div class="bg-light/5 border border-light/10 rounded-xl p-3">
<p class="text-[11px] text-light/50 uppercase tracking-wide">
{m.budget_actual_balance()}
</p>
<p
class="text-body font-heading {actualBalance >= 0
? 'text-emerald-400'
: 'text-red-400'}"
>
{formatCurrency(actualBalance)}
</p>
</div>
{/if}
</div>
<!-- Toolbar -->
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-1 bg-dark/50 rounded-lg p-0.5">
{#each ["overview", "income", "expense"] as mode}
<button
class="px-2.5 py-1 rounded text-[11px] transition-colors {viewMode ===
mode
? 'bg-primary text-background'
: 'text-light/40 hover:text-light/70'}"
onclick={() =>
(viewMode = mode as "overview" | "income" | "expense")}
>
{mode === "overview"
? m.budget_view_all()
: mode === "income"
? m.budget_view_income()
: m.budget_view_expenses()}
</button>
{/each}
</div>
{#if isEditor}
<div class="flex items-center gap-1">
{#if fullscreen}
<button
class="flex items-center gap-1 px-2 py-1 rounded-lg bg-light/5 hover:bg-light/10 text-light/60 text-[11px] transition-colors"
onclick={() => (showAddCategoryModal = true)}
>
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>category</span
>
{m.budget_add_category()}
</button>
{/if}
<button
class="flex items-center gap-1 px-2 py-1 rounded-lg bg-primary/10 hover:bg-primary/20 text-primary text-[11px] transition-colors"
onclick={() =>
openAddItem(
viewMode === "income" ? "income" : "expense",
)}
>
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>add</span
>
{m.budget_add_item()}
</button>
</div>
{/if}
</div>
<!-- Items list -->
<div class="flex-1 overflow-auto space-y-1">
{#if filteredItems.length === 0}
<div
class="flex flex-col items-center justify-center py-8 text-light/30 gap-2"
>
<span
class="material-symbols-rounded"
style="font-size: 32px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 32;"
>account_balance</span
>
<p class="text-body-sm">{m.budget_no_items()}</p>
</div>
{:else}
<!-- Table header -->
<div
class="grid grid-cols-12 gap-2 px-3 py-1.5 text-[10px] uppercase tracking-wider text-light/30"
>
<div class="col-span-1">{m.budget_col_type()}</div>
<div class={fullscreen ? "col-span-3" : "col-span-4"}>
{m.budget_col_description()}
</div>
<div class="col-span-2">{m.budget_col_category()}</div>
<div class="col-span-2 text-right">
{m.budget_col_planned()}
</div>
<div class="col-span-2 text-right">{m.budget_col_actual()}</div>
{#if fullscreen}
<div class="col-span-1 text-right">
{m.budget_col_diff()}
</div>
<div class="col-span-1 text-center">
{m.budget_col_receipt()}
</div>
{:else}
<div class="col-span-1 text-center"></div>
{/if}
</div>
{#each filteredItems as item (item.id)}
{@const diff = Number(
item.item_type === "income"
? item.actual_amount - item.planned_amount
: item.planned_amount - item.actual_amount,
)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="grid grid-cols-12 gap-2 px-3 py-2 rounded-lg hover:bg-light/5 transition-colors w-full text-left items-center cursor-pointer"
onclick={() => isEditor && openEditItem(item)}
onkeydown={(e) =>
e.key === "Enter" && isEditor && openEditItem(item)}
role="button"
tabindex="0"
>
<div class="col-span-1">
<span
class="inline-flex items-center justify-center w-5 h-5 rounded {item.item_type ===
'income'
? 'bg-emerald-500/20 text-emerald-400'
: 'bg-red-500/20 text-red-400'}"
>
<span
class="material-symbols-rounded"
style="font-size: 12px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 12;"
>
{item.item_type === "income"
? "arrow_downward"
: "arrow_upward"}
</span>
</span>
</div>
<div
class="{fullscreen
? 'col-span-3'
: 'col-span-4'} text-body-sm text-white truncate"
>
{item.description}
</div>
<div class="col-span-2">
<span
class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px]"
style="background-color: {getCategoryColor(
item.category_id,
)}20; color: {getCategoryColor(item.category_id)}"
>
{getCategoryName(item.category_id)}
</span>
</div>
<div
class="col-span-2 text-right text-body-sm text-light/60"
>
{formatCurrency(Number(item.planned_amount))}
</div>
<div class="col-span-2 text-right text-body-sm text-white">
{formatCurrency(Number(item.actual_amount))}
</div>
{#if fullscreen}
<div
class="col-span-1 text-right text-[11px] {diff >= 0
? 'text-emerald-400'
: 'text-red-400'}"
>
{diff >= 0 ? "+" : ""}{formatCurrency(diff)}
</div>
<div
class="col-span-1 flex items-center justify-center gap-1"
>
{#if Number(item.actual_amount) > 0 && !item.receipt_document_id}
<button
class="p-0.5 rounded hover:bg-amber-500/10 transition-colors"
onclick={(e) => {
e.stopPropagation();
onUploadReceipt?.(item.id);
}}
title={m.budget_missing_receipt()}
>
<span
class="material-symbols-rounded text-amber-400"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>warning</span
>
</button>
{:else if item.receipt_document_id}
<span
class="material-symbols-rounded text-emerald-400"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
title={m.budget_receipt_attached()}
>check_circle</span
>
{/if}
{#if isEditor}
<button
class="p-0.5 rounded hover:bg-error/10 transition-colors"
onclick={(e) => {
e.stopPropagation();
onDeleteItem(item.id);
}}
>
<span
class="material-symbols-rounded text-light/30 hover:text-error"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>delete</span
>
</button>
{/if}
</div>
{:else}
<div
class="col-span-1 flex items-center justify-center gap-1"
>
{#if Number(item.actual_amount) > 0 && !item.receipt_document_id}
<span
class="material-symbols-rounded text-amber-400"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
title={m.budget_missing_receipt_short()}
>warning</span
>
{:else if item.receipt_document_id}
<span
class="material-symbols-rounded text-emerald-400"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
title={m.budget_receipt_attached()}
>check_circle</span
>
{/if}
{#if isEditor}
<button
class="p-0.5 rounded hover:bg-error/10 transition-colors"
onclick={(e) => {
e.stopPropagation();
onDeleteItem(item.id);
}}
>
<span
class="material-symbols-rounded text-light/30 hover:text-error"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>delete</span
>
</button>
{/if}
</div>
{/if}
</div>
{/each}
<!-- Totals row -->
<div
class="grid grid-cols-12 gap-2 px-3 py-2 border-t border-light/10 mt-2"
>
<div class="col-span-1"></div>
<div
class="{fullscreen
? 'col-span-3'
: 'col-span-4'} text-body-sm font-heading text-white"
>
{m.budget_total()}
</div>
<div class="col-span-2"></div>
<div
class="col-span-2 text-right text-body-sm font-heading text-light/60"
>
{formatCurrency(
filteredItems.reduce(
(s, i) => s + Number(i.planned_amount),
0,
),
)}
</div>
<div
class="col-span-2 text-right text-body-sm font-heading text-white"
>
{formatCurrency(
filteredItems.reduce(
(s, i) => s + Number(i.actual_amount),
0,
),
)}
</div>
{#if fullscreen}
<div class="col-span-2"></div>
{:else}
<div class="col-span-1"></div>
{/if}
</div>
{/if}
</div>
</div>
<!-- Add/Edit Item Modal -->
{#if showAddItemModal}
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div
class="fixed inset-0 z-[60] bg-black/60 flex items-center justify-center p-4"
onclick={() => (showAddItemModal = false)}
onkeydown={(e) => e.key === "Escape" && (showAddItemModal = false)}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="bg-surface rounded-2xl border border-light/10 p-5 w-full max-w-md space-y-4"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<h3 class="text-body font-heading text-white">
{editingItem
? m.budget_edit_item_title()
: m.budget_add_item_title()}
</h3>
<div class="space-y-3">
<Input
variant="compact"
label={m.budget_description_label()}
bind:value={formDescription}
placeholder={m.budget_description_placeholder()}
/>
<div class="grid grid-cols-2 gap-3">
<Select
variant="compact"
label={m.budget_type_label()}
bind:value={formType}
placeholder=""
options={[
{
value: "expense",
label: m.budget_type_expense(),
},
{ value: "income", label: m.budget_type_income() },
]}
/>
<Select
variant="compact"
label={m.budget_category_label()}
bind:value={formCategoryId}
placeholder={m.budget_uncategorized()}
options={categories.map((cat) => ({
value: cat.id,
label: cat.name,
}))}
/>
</div>
<div class="grid grid-cols-2 gap-3">
<Input
variant="compact"
type="number"
label={m.budget_planned_amount_label()}
bind:value={formPlanned}
/>
<Input
variant="compact"
type="number"
label={m.budget_actual_amount_label()}
bind:value={formActual}
/>
</div>
<Textarea
variant="compact"
label={m.budget_notes_label()}
bind:value={formNotes}
rows={2}
placeholder={m.budget_notes_placeholder()}
resize="none"
/>
</div>
<div class="flex justify-end gap-2">
<button
class="px-3 py-1.5 rounded-lg text-body-sm text-light/60 hover:text-white transition-colors"
onclick={() => (showAddItemModal = false)}
>
{m.btn_cancel()}
</button>
<button
class="px-3 py-1.5 rounded-lg bg-primary text-background text-body-sm font-heading hover:bg-primary/90 transition-colors"
onclick={handleSubmitItem}
>
{editingItem ? m.btn_save() : m.budget_add_item()}
</button>
</div>
</div>
</div>
{/if}
<!-- Add Category Modal -->
{#if showAddCategoryModal}
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div
class="fixed inset-0 z-[60] bg-black/60 flex items-center justify-center p-4"
onclick={() => (showAddCategoryModal = false)}
onkeydown={(e) => e.key === "Escape" && (showAddCategoryModal = false)}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="bg-surface rounded-2xl border border-light/10 p-5 w-full max-w-sm space-y-4"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<h3 class="text-body font-heading text-white">
{m.budget_add_category_title()}
</h3>
<div class="space-y-3">
<div>
<label
class="block text-[11px] text-light/50 mb-1"
for="cat-name"
>
{m.budget_category_name_label()}
</label>
<input
id="cat-name"
type="text"
bind:value={newCategoryName}
placeholder={m.budget_category_name_placeholder()}
class="w-full bg-dark/50 border border-light/10 rounded-lg px-3 py-2 text-body-sm text-white placeholder:text-light/20 focus:outline-none focus:border-primary/50"
/>
</div>
<div>
<p class="text-[11px] text-light/50 mb-1">
{m.budget_category_color_label()}
</p>
<div class="flex gap-1.5">
{#each CATEGORY_COLORS as color}
<button
class="w-6 h-6 rounded-full border-2 transition-all {newCategoryColor ===
color
? 'border-white scale-110'
: 'border-transparent'}"
style="background-color: {color}"
onclick={() => (newCategoryColor = color)}
aria-label={m.budget_select_color({ color })}
></button>
{/each}
</div>
</div>
<!-- Existing categories -->
{#if categories.length > 0}
<div>
<p class="text-[11px] text-light/50 mb-1">
{m.budget_existing_categories()}
</p>
<div class="flex flex-wrap gap-1.5">
{#each categories as cat}
<span
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px]"
style="background-color: {cat.color}20; color: {cat.color}"
>
{cat.name}
{#if isEditor}
<button
class="hover:text-white transition-colors"
onclick={() =>
onDeleteCategory(cat.id)}
>
<span
class="material-symbols-rounded"
style="font-size: 12px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 12;"
>close</span
>
</button>
{/if}
</span>
{/each}
</div>
</div>
{/if}
</div>
<div class="flex justify-end gap-2">
<button
class="px-3 py-1.5 rounded-lg text-body-sm text-light/60 hover:text-white transition-colors"
onclick={() => (showAddCategoryModal = false)}
>
{m.btn_close()}
</button>
<button
class="px-3 py-1.5 rounded-lg bg-primary text-background text-body-sm font-heading hover:bg-primary/90 transition-colors"
onclick={handleAddCategory}
>
{m.budget_add_category()}
</button>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,281 @@
<script lang="ts">
import type { ChecklistWithItems } from "$lib/api/department-dashboard";
import { Button } from "$lib/components/ui";
import * as m from "$lib/paraglide/messages";
interface Props {
checklists: ChecklistWithItems[];
isEditor: boolean;
fullscreen?: boolean;
onAddItem: (checklistId: string, content: string) => void;
onToggleItem: (itemId: string, checked: boolean) => void;
onDeleteItem: (itemId: string) => void;
onUpdateItem: (itemId: string, content: string) => void;
onAddChecklist: (title: string) => void;
onDeleteChecklist: (checklistId: string) => void;
onRenameChecklist: (checklistId: string, title: string) => void;
}
let {
checklists,
isEditor,
fullscreen = false,
onAddItem,
onToggleItem,
onDeleteItem,
onUpdateItem,
onAddChecklist,
onDeleteChecklist,
onRenameChecklist,
}: Props = $props();
let newItemContent: Record<string, string> = $state({});
let editingItemId = $state<string | null>(null);
let editingContent = $state("");
let showNewChecklist = $state(false);
let newChecklistTitle = $state("");
let renamingId = $state<string | null>(null);
let renamingTitle = $state("");
function handleAddItem(checklistId: string) {
const content = (newItemContent[checklistId] ?? "").trim();
if (!content) return;
onAddItem(checklistId, content);
newItemContent[checklistId] = "";
}
function startEdit(itemId: string, content: string) {
editingItemId = itemId;
editingContent = content;
}
function confirmEdit() {
if (editingItemId && editingContent.trim()) {
onUpdateItem(editingItemId, editingContent.trim());
}
editingItemId = null;
editingContent = "";
}
function startRename(checklistId: string, title: string) {
renamingId = checklistId;
renamingTitle = title;
}
function confirmRename() {
if (renamingId && renamingTitle.trim()) {
onRenameChecklist(renamingId, renamingTitle.trim());
}
renamingId = null;
renamingTitle = "";
}
function handleCreateChecklist() {
if (!newChecklistTitle.trim()) return;
onAddChecklist(newChecklistTitle.trim());
newChecklistTitle = "";
showNewChecklist = false;
}
function completionPercent(cl: ChecklistWithItems): number {
if (cl.items.length === 0) return 0;
return Math.round(
(cl.items.filter((i) => i.is_completed).length / cl.items.length) *
100,
);
}
</script>
<div class="flex flex-col gap-4 {fullscreen ? 'max-w-2xl mx-auto' : ''}">
{#each checklists as cl (cl.id)}
<div class="flex flex-col gap-2">
<!-- Checklist header -->
<div class="flex items-center justify-between">
{#if renamingId === cl.id}
<input
type="text"
bind:value={renamingTitle}
onkeydown={(e) => {
if (e.key === "Enter") confirmRename();
if (e.key === "Escape") {
renamingId = null;
renamingTitle = "";
}
}}
onblur={confirmRename}
class="bg-transparent text-body-sm font-heading text-white border-b border-primary outline-none px-0 py-0.5"
/>
{:else}
<div class="flex items-center gap-2">
<h3 class="text-body-sm font-heading text-white">
{cl.title}
</h3>
<span class="text-[11px] text-light/30">
{cl.items.filter((i) => i.is_completed).length}/{cl
.items.length}
</span>
{#if cl.items.length > 0}
<div
class="w-16 h-1 rounded-full bg-light/10 overflow-hidden"
>
<div
class="h-full bg-emerald-400 rounded-full transition-all"
style="width: {completionPercent(cl)}%"
></div>
</div>
{/if}
</div>
{/if}
{#if isEditor}
<div class="flex items-center gap-1">
<button
class="p-0.5 rounded hover:bg-light/10 transition-colors"
onclick={() => startRename(cl.id, cl.title)}
title={m.checklist_rename()}
>
<span
class="material-symbols-rounded text-light/30"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>edit</span
>
</button>
<button
class="p-0.5 rounded hover:bg-error/10 transition-colors"
onclick={() => onDeleteChecklist(cl.id)}
title={m.checklist_delete()}
>
<span
class="material-symbols-rounded text-light/30 hover:text-error"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>delete</span
>
</button>
</div>
{/if}
</div>
<!-- Items -->
<div class="flex flex-col gap-0.5">
{#each cl.items as item (item.id)}
<div
class="group flex items-start gap-2 px-2 py-1.5 rounded-lg hover:bg-light/5 transition-colors"
>
<input
type="checkbox"
checked={item.is_completed}
onchange={() =>
onToggleItem(item.id, !item.is_completed)}
class="mt-0.5 w-4 h-4 rounded border-light/20 text-primary accent-primary cursor-pointer"
/>
{#if editingItemId === item.id}
<input
type="text"
bind:value={editingContent}
onkeydown={(e) => {
if (e.key === "Enter") confirmEdit();
if (e.key === "Escape") {
editingItemId = null;
editingContent = "";
}
}}
onblur={confirmEdit}
class="flex-1 bg-transparent text-body-sm text-light border-b border-primary outline-none"
/>
{:else}
<button
class="flex-1 text-left text-body-sm {item.is_completed
? 'text-light/30 line-through'
: 'text-light'}"
ondblclick={() =>
isEditor &&
startEdit(item.id, item.content)}
>
{item.content}
</button>
{/if}
{#if isEditor}
<button
class="p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-error/10 transition-all"
onclick={() => onDeleteItem(item.id)}
>
<span
class="material-symbols-rounded text-light/30 hover:text-error"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>close</span
>
</button>
{/if}
</div>
{/each}
</div>
<!-- Add item input -->
{#if isEditor}
<div class="flex items-center gap-2 px-2">
<input
type="text"
placeholder={m.checklist_add_item_placeholder()}
bind:value={newItemContent[cl.id]}
onkeydown={(e) => {
if (e.key === "Enter") handleAddItem(cl.id);
}}
class="flex-1 bg-transparent text-body-sm text-light placeholder:text-light/20 border-b border-light/10 focus:border-primary outline-none py-1"
/>
<button
class="p-1 rounded-lg hover:bg-light/10 transition-colors"
onclick={() => handleAddItem(cl.id)}
disabled={!(newItemContent[cl.id] ?? "").trim()}
>
<span
class="material-symbols-rounded text-light/40"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>add</span
>
</button>
</div>
{/if}
</div>
{/each}
<!-- Add checklist -->
{#if isEditor}
{#if showNewChecklist}
<div class="flex items-center gap-2">
<input
type="text"
placeholder={m.checklist_name_placeholder()}
bind:value={newChecklistTitle}
onkeydown={(e) => {
if (e.key === "Enter") handleCreateChecklist();
if (e.key === "Escape") {
showNewChecklist = false;
newChecklistTitle = "";
}
}}
class="flex-1 bg-transparent text-body-sm text-light placeholder:text-light/20 border-b border-primary outline-none py-1"
/>
<Button size="sm" onclick={handleCreateChecklist}
>{m.btn_create()}</Button
>
</div>
{:else}
<button
class="flex items-center gap-1.5 text-body-sm text-light/30 hover:text-light/60 transition-colors"
onclick={() => (showNewChecklist = true)}
>
<span
class="material-symbols-rounded"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>add</span
>
{m.checklist_add()}
</button>
{/if}
{/if}
{#if checklists.length === 0}
<p class="text-body-sm text-light/30 text-center py-4">
{m.checklist_no_items()}
</p>
{/if}
</div>

View File

@@ -0,0 +1,497 @@
<script lang="ts">
import type { DepartmentContact } from "$lib/supabase/types";
import {
CONTACT_CATEGORIES,
CATEGORY_LABELS,
CATEGORY_ICONS,
} from "$lib/api/contacts";
import { Button, Modal, Input, Select, Textarea } from "$lib/components/ui";
import * as m from "$lib/paraglide/messages";
interface Props {
contacts: DepartmentContact[];
isEditor: boolean;
fullscreen?: boolean;
onCreate: (params: {
name: string;
role?: string;
company?: string;
email?: string;
phone?: string;
website?: string;
notes?: string;
category?: string;
color?: string;
}) => void;
onUpdate: (
contactId: string,
params: Partial<
Pick<
DepartmentContact,
| "name"
| "role"
| "company"
| "email"
| "phone"
| "website"
| "notes"
| "category"
| "color"
>
>,
) => void;
onDelete: (contactId: string) => void;
}
let {
contacts,
isEditor,
fullscreen = false,
onCreate,
onUpdate,
onDelete,
}: Props = $props();
// Filter
let filterCategory = $state<string>("all");
let searchQuery = $state("");
// Modal state
let showContactModal = $state(false);
let editingContact = $state<DepartmentContact | null>(null);
let contactName = $state("");
let contactRole = $state("");
let contactCompany = $state("");
let contactEmail = $state("");
let contactPhone = $state("");
let contactWebsite = $state("");
let contactNotes = $state("");
let contactCategory = $state("general");
// Expanded contact detail
let expandedId = $state<string | null>(null);
const filteredContacts = $derived.by(() => {
let result = contacts;
if (filterCategory !== "all") {
result = result.filter((c) => c.category === filterCategory);
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
(c.company ?? "").toLowerCase().includes(q) ||
(c.role ?? "").toLowerCase().includes(q) ||
(c.email ?? "").toLowerCase().includes(q),
);
}
return result;
});
// Categories that have contacts
const usedCategories = $derived(
[...new Set(contacts.map((c) => c.category))].sort(),
);
function openContactModal(contact?: DepartmentContact) {
if (contact) {
editingContact = contact;
contactName = contact.name;
contactRole = contact.role ?? "";
contactCompany = contact.company ?? "";
contactEmail = contact.email ?? "";
contactPhone = contact.phone ?? "";
contactWebsite = contact.website ?? "";
contactNotes = contact.notes ?? "";
contactCategory = contact.category ?? "general";
} else {
editingContact = null;
contactName = "";
contactRole = "";
contactCompany = "";
contactEmail = "";
contactPhone = "";
contactWebsite = "";
contactNotes = "";
contactCategory = "general";
}
showContactModal = true;
}
function handleSaveContact() {
if (!contactName.trim()) return;
const params = {
name: contactName.trim(),
role: contactRole.trim() || undefined,
company: contactCompany.trim() || undefined,
email: contactEmail.trim() || undefined,
phone: contactPhone.trim() || undefined,
website: contactWebsite.trim() || undefined,
notes: contactNotes.trim() || undefined,
category: contactCategory,
};
if (editingContact) {
onUpdate(editingContact.id, params);
} else {
onCreate(params);
}
showContactModal = false;
}
</script>
<div class="flex flex-col gap-3 {fullscreen ? 'max-w-3xl mx-auto' : ''} h-full">
<!-- Toolbar -->
<div class="flex items-center gap-2 shrink-0">
<!-- Search -->
<div class="relative flex-1">
<span
class="material-symbols-rounded absolute left-2 top-1/2 -translate-y-1/2 text-light/30"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>search</span
>
<input
type="text"
bind:value={searchQuery}
placeholder={m.contacts_search_placeholder()}
class="w-full pl-8 pr-3 py-1.5 bg-dark/50 border border-light/10 rounded-lg text-[12px] text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
/>
</div>
<!-- Category filter -->
<Select
variant="compact"
bind:value={filterCategory}
placeholder=""
options={[
{ value: "all", label: m.contacts_category_all() },
...CONTACT_CATEGORIES.map((cat) => ({
value: cat,
label: CATEGORY_LABELS[cat] ?? cat,
})),
]}
/>
{#if isEditor}
<Button size="sm" onclick={() => openContactModal()}>
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>add</span
>
{m.contacts_add()}
</Button>
{/if}
</div>
<!-- Contact list -->
<div class="flex-1 overflow-auto">
{#if filteredContacts.length === 0}
<div
class="flex flex-col items-center justify-center h-full gap-2 text-light/30"
>
<span
class="material-symbols-rounded"
style="font-size: 36px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 36;"
>contacts</span
>
<p class="text-body-sm">
{contacts.length === 0
? m.contacts_no_contacts()
: m.contacts_no_results()}
</p>
</div>
{:else}
<div class="flex flex-col gap-1">
{#each filteredContacts as contact (contact.id)}
<div
class="rounded-xl border border-light/5 overflow-hidden"
>
<!-- Contact row -->
<button
class="w-full flex items-center gap-3 px-3 py-2.5 hover:bg-light/5 transition-colors text-left group"
onclick={() =>
(expandedId =
expandedId === contact.id
? null
: contact.id)}
>
<!-- Category icon -->
<div
class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style="background-color: {contact.color ??
'#00A3E0'}20"
>
<span
class="material-symbols-rounded"
style="font-size: 16px; color: {contact.color ??
'#00A3E0'}; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>
{CATEGORY_ICONS[
contact.category ?? "general"
] ?? "person"}
</span>
</div>
<!-- Name & company -->
<div class="flex-1 min-w-0">
<span
class="text-body-sm text-white font-medium truncate block"
>{contact.name}</span
>
{#if contact.company || contact.role}
<span
class="text-[11px] text-light/40 truncate block"
>
{[contact.role, contact.company]
.filter(Boolean)
.join(" · ")}
</span>
{/if}
</div>
<!-- Quick actions -->
{#if contact.email}
<a
href="mailto:{contact.email}"
class="p-1 rounded-lg hover:bg-light/10 transition-colors shrink-0"
onclick={(e) => e.stopPropagation()}
title={contact.email}
>
<span
class="material-symbols-rounded text-light/30 hover:text-primary"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>mail</span
>
</a>
{/if}
{#if contact.phone}
<a
href="tel:{contact.phone}"
class="p-1 rounded-lg hover:bg-light/10 transition-colors shrink-0"
onclick={(e) => e.stopPropagation()}
title={contact.phone}
>
<span
class="material-symbols-rounded text-light/30 hover:text-primary"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>phone</span
>
</a>
{/if}
<!-- Category badge -->
<span
class="text-[10px] px-1.5 py-0.5 rounded bg-light/5 text-light/30 shrink-0"
>
{CATEGORY_LABELS[
contact.category ?? "general"
] ?? contact.category}
</span>
</button>
<!-- Expanded detail -->
{#if expandedId === contact.id}
<div
class="px-3 pb-3 pt-1 border-t border-light/5 bg-dark/30"
>
<div class="grid grid-cols-2 gap-2 text-[11px]">
{#if contact.email}
<div>
<span class="text-light/30"
>{m.contacts_email_label()}</span
>
<a
href="mailto:{contact.email}"
class="block text-primary hover:underline truncate"
>{contact.email}</a
>
</div>
{/if}
{#if contact.phone}
<div>
<span class="text-light/30"
>{m.contacts_phone_label()}</span
>
<a
href="tel:{contact.phone}"
class="block text-primary hover:underline"
>{contact.phone}</a
>
</div>
{/if}
{#if contact.website}
<div>
<span class="text-light/30"
>{m.contacts_website_label()}</span
>
<a
href={contact.website.startsWith(
"http",
)
? contact.website
: `https://${contact.website}`}
target="_blank"
rel="noopener noreferrer"
class="block text-primary hover:underline truncate"
>{contact.website}</a
>
</div>
{/if}
{#if contact.role}
<div>
<span class="text-light/30"
>{m.contacts_role_label()}</span
>
<span class="block text-light/60"
>{contact.role}</span
>
</div>
{/if}
</div>
{#if contact.notes}
<div class="mt-2 text-[11px]">
<span class="text-light/30"
>{m.contacts_notes_label()}</span
>
<p
class="text-light/60 whitespace-pre-wrap"
>
{contact.notes}
</p>
</div>
{/if}
{#if isEditor}
<div
class="flex items-center gap-2 mt-3 pt-2 border-t border-light/5"
>
<button
class="flex items-center gap-1 px-2 py-1 rounded-lg text-[11px] text-light/40 hover:text-white hover:bg-light/10 transition-colors"
onclick={() =>
openContactModal(contact)}
>
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>edit</span
>
{m.btn_edit()}
</button>
<button
class="flex items-center gap-1 px-2 py-1 rounded-lg text-[11px] text-light/40 hover:text-error hover:bg-error/10 transition-colors"
onclick={() => onDelete(contact.id)}
>
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>delete</span
>
{m.btn_delete()}
</button>
</div>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
<!-- Contact Modal -->
<Modal
isOpen={showContactModal}
onClose={() => (showContactModal = false)}
title={editingContact ? m.contacts_edit_title() : m.contacts_add_title()}
>
<div class="flex flex-col gap-4">
<div class="grid grid-cols-2 gap-3">
<Input
variant="compact"
label="{m.contacts_name_label()} *"
bind:value={contactName}
placeholder={m.contacts_name_placeholder()}
/>
<Input
variant="compact"
label={m.contacts_company_label()}
bind:value={contactCompany}
placeholder={m.contacts_company_placeholder()}
/>
</div>
<div class="grid grid-cols-2 gap-3">
<Input
variant="compact"
label={m.contacts_role_label()}
bind:value={contactRole}
placeholder={m.contacts_role_placeholder()}
/>
<Select
variant="compact"
label={m.contacts_category_label()}
bind:value={contactCategory}
placeholder=""
options={CONTACT_CATEGORIES.map((cat) => ({
value: cat,
label: CATEGORY_LABELS[cat] ?? cat,
}))}
/>
</div>
<div class="grid grid-cols-2 gap-3">
<Input
variant="compact"
type="email"
label={m.contacts_email_label()}
bind:value={contactEmail}
placeholder={m.contacts_email_placeholder()}
/>
<Input
variant="compact"
type="tel"
label={m.contacts_phone_label()}
bind:value={contactPhone}
placeholder={m.contacts_phone_placeholder()}
/>
</div>
<Input
variant="compact"
type="url"
label={m.contacts_website_label()}
bind:value={contactWebsite}
placeholder={m.contacts_website_placeholder()}
/>
<Textarea
variant="compact"
label={m.contacts_notes_label()}
bind:value={contactNotes}
placeholder={m.contacts_notes_placeholder()}
rows={2}
resize="none"
/>
<div
class="flex items-center justify-end gap-3 pt-2 border-t border-light/5"
>
<button
type="button"
class="px-4 py-2 text-body-sm text-light/60 hover:text-white transition-colors"
onclick={() => (showContactModal = false)}
>{m.btn_cancel()}</button
>
<button
type="button"
disabled={!contactName.trim()}
class="px-4 py-2 bg-primary text-background rounded-xl font-body text-body-sm hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onclick={handleSaveContact}
>
{editingContact ? m.btn_save() : m.btn_create()}
</button>
</div>
</div>
</Modal>

View File

@@ -0,0 +1,488 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { getContext } from "svelte";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database, Document } from "$lib/supabase/types";
import { Input, Select } from "$lib/components/ui";
import {
createDocument,
fetchFolderContents,
uploadFile,
getFileMetadata,
formatFileSize,
} from "$lib/api/documents";
import { toasts } from "$lib/stores/ui";
import * as m from "$lib/paraglide/messages";
import { getErrorMessage } from "$lib/utils/logger";
interface Props {
departmentId: string;
orgSlug: string;
orgId: string;
folderId: string | null;
fullscreen?: boolean;
}
let {
departmentId,
orgSlug,
orgId,
folderId,
fullscreen = false,
}: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
let documents = $state<Document[]>([]);
let loading = $state(true);
let showCreateModal = $state(false);
let newDocName = $state("");
let newDocType = $state<"document" | "folder" | "kanban">("document");
let creating = $state(false);
let uploading = $state(false);
let uploadProgress = $state("");
let draggingOver = $state(false);
let fileInput: HTMLInputElement | undefined = $state();
$effect(() => {
if (folderId) {
loadDocuments();
} else {
loading = false;
}
});
async function loadDocuments() {
if (!folderId) return;
loading = true;
try {
documents = await fetchFolderContents(supabase, folderId);
} catch {
documents = [];
} finally {
loading = false;
}
}
async function handleCreate() {
if (!newDocName.trim() || !folderId) return;
creating = true;
try {
const userId = (await supabase.auth.getUser()).data.user?.id;
if (!userId) throw new Error("Not authenticated");
if (newDocType === "kanban") {
const { data: newBoard, error: boardError } = await supabase
.from("kanban_boards")
.insert({ org_id: orgId, name: newDocName.trim() })
.select()
.single();
if (boardError || !newBoard)
throw boardError ?? new Error("Failed to create board");
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 },
]);
await createDocument(
supabase,
orgId,
newDocName.trim(),
"kanban",
folderId,
userId,
{
id: newBoard.id,
content: {
type: "kanban",
board_id: newBoard.id,
} as import("$lib/supabase/types").Json,
},
);
} else {
await createDocument(
supabase,
orgId,
newDocName.trim(),
newDocType,
folderId,
userId,
);
}
toasts.success(
`${newDocType === "folder" ? "Folder" : newDocType === "kanban" ? "Kanban board" : "Document"} created`,
);
showCreateModal = false;
newDocName = "";
newDocType = "document";
await loadDocuments();
} catch (e: unknown) {
toasts.error(getErrorMessage(e, "Failed to create"));
} finally {
creating = false;
}
}
async function handleUploadFiles(files: FileList | File[]) {
if (!folderId || files.length === 0) return;
uploading = true;
try {
const userId = (await supabase.auth.getUser()).data.user?.id;
if (!userId) throw new Error("Not authenticated");
const fileArr = Array.from(files);
for (let i = 0; i < fileArr.length; i++) {
uploadProgress = `Uploading ${i + 1}/${fileArr.length}: ${fileArr[i].name}`;
await uploadFile(supabase, orgId, folderId, userId, fileArr[i]);
}
toasts.success(
fileArr.length === 1
? `Uploaded "${fileArr[0].name}"`
: `Uploaded ${fileArr.length} files`,
);
await loadDocuments();
} catch (e: unknown) {
toasts.error(getErrorMessage(e, "Failed to upload file"));
} finally {
uploading = false;
uploadProgress = "";
draggingOver = false;
}
}
function handleFileInputChange(e: globalThis.Event) {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
handleUploadFiles(input.files);
input.value = "";
}
}
function handleDrop(e: DragEvent) {
e.preventDefault();
draggingOver = false;
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
handleUploadFiles(e.dataTransfer.files);
}
}
function handleDragOver(e: DragEvent) {
e.preventDefault();
draggingOver = true;
}
function handleDragLeave() {
draggingOver = false;
}
function getFileIcon(mimeType: string): string {
if (mimeType.startsWith("image/")) return "image";
if (mimeType.startsWith("video/")) return "movie";
if (mimeType.startsWith("audio/")) return "audio_file";
if (mimeType === "application/pdf") return "picture_as_pdf";
if (mimeType.includes("spreadsheet") || mimeType.includes("excel"))
return "table_chart";
if (
mimeType.includes("presentation") ||
mimeType.includes("powerpoint")
)
return "slideshow";
if (
mimeType.includes("zip") ||
mimeType.includes("archive") ||
mimeType.includes("compressed")
)
return "folder_zip";
return "attach_file";
}
function getIcon(type: string): string {
if (type === "folder") return "folder";
if (type === "kanban") return "view_kanban";
return "description";
}
function getColor(type: string): string {
if (type === "folder") return "#F59E0B";
if (type === "kanban") return "#6366f1";
return "#00A3E0";
}
function handleOpen(doc: Document) {
if (doc.type === "folder") {
goto(`/${orgSlug}/documents/folder/${doc.id}`);
} else if (doc.type === "file") {
const meta = getFileMetadata(doc);
if (meta?.public_url) {
window.open(meta.public_url, "_blank");
}
} else {
goto(`/${orgSlug}/documents/file/${doc.id}`);
}
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return "";
return new Date(dateStr).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
</script>
{#if !folderId}
<div
class="flex flex-col items-center justify-center h-full gap-3 {fullscreen
? 'py-12'
: 'py-6'}"
>
<span
class="material-symbols-rounded text-light/20"
style="font-size: 36px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 36;"
>folder_off</span
>
<p class="text-body-sm text-light/40">
{m.files_widget_no_folder()}
</p>
</div>
{:else if loading}
<div class="flex items-center justify-center h-full">
<span
class="material-symbols-rounded text-light/20 animate-spin"
style="font-size: 24px;">progress_activity</span
>
</div>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex flex-col h-full gap-2 relative {draggingOver
? 'ring-4 ring-primary ring-inset rounded-xl'
: ''}"
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
>
<!-- Hidden file input -->
<input
bind:this={fileInput}
type="file"
multiple
class="hidden"
onchange={handleFileInputChange}
/>
<!-- Drag overlay -->
{#if draggingOver}
<div
class="absolute inset-0 bg-primary/5 rounded-xl z-10 flex items-center justify-center pointer-events-none"
>
<div class="flex flex-col items-center gap-2">
<span
class="material-symbols-rounded text-primary"
style="font-size: 32px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 32;"
>cloud_upload</span
>
<p class="text-body-sm text-primary font-heading">
{m.files_widget_drop_files()}
</p>
</div>
</div>
{/if}
<!-- Upload progress -->
{#if uploading}
<div
class="flex items-center gap-2 px-2 py-1.5 bg-primary/5 rounded-lg"
>
<span
class="material-symbols-rounded text-primary animate-spin"
style="font-size: 14px;">progress_activity</span
>
<span class="text-[11px] text-primary truncate"
>{uploadProgress}</span
>
</div>
{/if}
<!-- Toolbar -->
<div class="flex items-center justify-between">
<span class="text-[11px] text-light/40"
>{documents.length} item{documents.length !== 1
? "s"
: ""}</span
>
<div class="flex items-center gap-1">
<button
class="flex items-center gap-1 px-2 py-1 rounded-lg text-[11px] text-primary hover:bg-primary/10 transition-colors"
onclick={() => fileInput?.click()}
title="Upload files"
>
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>upload</span
>
Upload
</button>
<button
class="flex items-center gap-1 px-2 py-1 rounded-lg text-[11px] text-primary hover:bg-primary/10 transition-colors"
onclick={() => (showCreateModal = true)}
>
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>add</span
>
New
</button>
<button
class="flex items-center gap-1 px-2 py-1 rounded-lg text-[11px] text-light/50 hover:text-white hover:bg-light/10 transition-colors"
onclick={() =>
goto(`/${orgSlug}/documents/folder/${folderId}`)}
>
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>open_in_new</span
>
{m.files_widget_full_view()}
</button>
</div>
</div>
<!-- File list -->
{#if documents.length === 0 && !uploading}
<div
class="flex flex-col items-center justify-center flex-1 gap-2 py-4"
>
<span
class="material-symbols-rounded text-light/15"
style="font-size: 32px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 32;"
>folder_open</span
>
<p class="text-[11px] text-light/30">
{m.files_widget_empty()}
</p>
<p class="text-[10px] text-light/20">
Drag & drop files here or click Upload
</p>
</div>
{:else}
<div
class="flex flex-col gap-0.5 {fullscreen
? 'max-h-none'
: 'max-h-[300px]'} overflow-auto"
>
{#each documents as doc}
{@const fileMeta =
doc.type === "file" ? getFileMetadata(doc) : null}
<button
class="flex items-center gap-2.5 px-2.5 py-2 rounded-lg hover:bg-light/5 transition-colors text-left w-full group"
onclick={() => handleOpen(doc)}
>
{#if fileMeta}
<span
class="material-symbols-rounded shrink-0"
style="font-size: 18px; color: #EC4899; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>{getFileIcon(fileMeta.mime_type)}</span
>
{:else}
<span
class="material-symbols-rounded shrink-0"
style="font-size: 18px; color: {getColor(
doc.type,
)}; font-variation-settings: 'FILL' {doc.type ===
'folder'
? 1
: 0}, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>{getIcon(doc.type)}</span
>
{/if}
<div class="flex-1 min-w-0">
<span class="text-body-sm text-white truncate block"
>{doc.name}</span
>
{#if fileMeta}
<span class="text-[10px] text-light/25"
>{formatFileSize(fileMeta.file_size)}</span
>
{/if}
</div>
<span class="text-[10px] text-light/25 shrink-0"
>{formatDate(
doc.updated_at ?? doc.created_at,
)}</span
>
</button>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Create modal -->
{#if showCreateModal}
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div
class="fixed inset-0 z-[60] bg-black/60 flex items-center justify-center p-4"
onclick={() => (showCreateModal = false)}
onkeydown={(e) => e.key === "Escape" && (showCreateModal = false)}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="bg-surface rounded-2xl border border-light/10 p-5 w-full max-w-sm space-y-4"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<h3 class="text-body font-heading text-white">
{m.files_widget_create_title()}
</h3>
<div class="space-y-3">
<Input
variant="compact"
label={m.files_widget_name_label()}
bind:value={newDocName}
placeholder={m.files_widget_doc_placeholder()}
/>
<Select
variant="compact"
label={m.budget_col_type()}
bind:value={newDocType}
placeholder=""
options={[
{
value: "document",
label: m.files_widget_type_document(),
},
{
value: "folder",
label: m.files_widget_type_folder(),
},
{
value: "kanban",
label: m.files_widget_type_kanban(),
},
]}
/>
</div>
<div class="flex justify-end gap-2">
<button
class="px-3 py-1.5 rounded-lg text-body-sm text-light/60 hover:text-white transition-colors"
onclick={() => (showCreateModal = false)}
>{m.btn_cancel()}</button
>
<button
class="px-3 py-1.5 rounded-lg bg-primary text-background text-body-sm font-heading hover:bg-primary/90 transition-colors disabled:opacity-50"
onclick={handleCreate}
disabled={!newDocName.trim() || creating}
>
{creating ? m.btn_creating() : m.btn_create()}
</button>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,237 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { getContext } from "svelte";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database, Document } from "$lib/supabase/types";
import { createDocument, fetchFolderContents } from "$lib/api/documents";
import { toasts } from "$lib/stores/ui";
import * as m from "$lib/paraglide/messages";
import { getErrorMessage } from "$lib/utils/logger";
interface Props {
departmentId: string;
eventId: string;
orgSlug: string;
orgId: string;
folderId: string | null;
fullscreen?: boolean;
}
let {
departmentId,
eventId,
orgSlug,
orgId,
folderId,
fullscreen = false,
}: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
let boards = $state<Document[]>([]);
let loading = $state(true);
let showCreateModal = $state(false);
let newBoardName = $state("");
let creating = $state(false);
$effect(() => {
if (folderId) {
loadBoards();
} else {
loading = false;
}
});
async function loadBoards() {
if (!folderId) return;
loading = true;
try {
const docs = await fetchFolderContents(supabase, folderId);
boards = docs.filter((d) => d.type === "kanban");
} catch {
boards = [];
} finally {
loading = false;
}
}
async function handleCreate() {
if (!newBoardName.trim() || !folderId) return;
creating = true;
try {
const userId = (await supabase.auth.getUser()).data.user?.id;
if (!userId) throw new Error("Not authenticated");
const { data: newBoard, error: boardError } = await supabase
.from("kanban_boards")
.insert({ org_id: orgId, name: newBoardName.trim() })
.select()
.single();
if (boardError || !newBoard)
throw boardError ?? new Error("Failed to create board");
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 },
]);
await createDocument(
supabase,
orgId,
newBoardName.trim(),
"kanban",
folderId,
userId,
{
id: newBoard.id,
content: {
type: "kanban",
board_id: newBoard.id,
} as import("$lib/supabase/types").Json,
},
);
toasts.success(m.toast_success_board_created());
showCreateModal = false;
newBoardName = "";
await loadBoards();
} catch (e: unknown) {
toasts.error(getErrorMessage(e, "Failed to create board"));
} finally {
creating = false;
}
}
</script>
{#if !folderId}
<div
class="flex flex-col items-center justify-center h-full gap-3 {fullscreen
? 'py-12'
: 'py-6'}"
>
<span
class="material-symbols-rounded text-light/20"
style="font-size: 36px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 36;"
>folder_off</span
>
<p class="text-body-sm text-light/40">
{m.kanban_widget_no_folder()}
</p>
</div>
{:else if loading}
<div class="flex items-center justify-center h-full">
<span
class="material-symbols-rounded text-light/20 animate-spin"
style="font-size: 24px;">progress_activity</span
>
</div>
{:else}
<div class="flex flex-col h-full gap-2">
<!-- Toolbar -->
<div class="flex items-center justify-between">
<span class="text-[11px] text-light/40"
>{boards.length} board{boards.length !== 1 ? "s" : ""}</span
>
<button
class="flex items-center gap-1 px-2 py-1 rounded-lg text-[11px] text-primary hover:bg-primary/10 transition-colors"
onclick={() => (showCreateModal = true)}
>
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>add</span
>
{m.kanban_widget_create()}
</button>
</div>
<!-- Board list -->
{#if boards.length === 0}
<div
class="flex flex-col items-center justify-center flex-1 gap-2 py-4"
>
<span
class="material-symbols-rounded text-light/15"
style="font-size: 32px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 32;"
>view_kanban</span
>
<p class="text-[11px] text-light/30">
{m.kanban_widget_no_boards()}
</p>
</div>
{:else}
<div
class="flex flex-col gap-0.5 {fullscreen
? 'max-h-none'
: 'max-h-[300px]'} overflow-auto"
>
{#each boards as board}
<button
class="flex items-center gap-2.5 px-2.5 py-2 rounded-lg hover:bg-light/5 transition-colors text-left w-full"
onclick={() =>
goto(`/${orgSlug}/documents/file/${board.id}`)}
>
<span
class="material-symbols-rounded shrink-0"
style="font-size: 18px; color: #6366f1; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>view_kanban</span
>
<span class="text-body-sm text-white truncate flex-1"
>{board.name}</span
>
</button>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Create modal -->
{#if showCreateModal}
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div
class="fixed inset-0 z-[60] bg-black/60 flex items-center justify-center p-4"
onclick={() => (showCreateModal = false)}
onkeydown={(e) => e.key === "Escape" && (showCreateModal = false)}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="bg-surface rounded-2xl border border-light/10 p-5 w-full max-w-sm space-y-4"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<h3 class="text-body font-heading text-white">
{m.kanban_widget_create_title()}
</h3>
<div>
<label
for="board-name"
class="block text-[11px] text-light/50 mb-1"
>{m.kanban_widget_name_label()}</label
>
<input
id="board-name"
type="text"
bind:value={newBoardName}
placeholder={m.kanban_widget_name_placeholder()}
class="w-full bg-dark/50 border border-light/10 rounded-lg px-3 py-2 text-body-sm text-white placeholder:text-light/20 focus:outline-none focus:border-primary/50"
/>
</div>
<div class="flex justify-end gap-2">
<button
class="px-3 py-1.5 rounded-lg text-body-sm text-light/60 hover:text-white transition-colors"
onclick={() => (showCreateModal = false)}
>{m.btn_cancel()}</button
>
<button
class="px-3 py-1.5 rounded-lg bg-primary text-background text-body-sm font-heading hover:bg-primary/90 transition-colors disabled:opacity-50"
onclick={handleCreate}
disabled={!newBoardName.trim() || creating}
>
{creating ? m.btn_creating() : m.btn_create()}
</button>
</div>
</div>
</div>
{/if}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,262 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { getContext } from "svelte";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database, DepartmentNote } from "$lib/supabase/types";
import { createDocument } from "$lib/api/documents";
import { toasts } from "$lib/stores/ui";
import { getErrorMessage } from "$lib/utils/logger";
import * as m from "$lib/paraglide/messages";
interface Props {
notes: DepartmentNote[];
isEditor: boolean;
fullscreen?: boolean;
orgId?: string;
orgSlug?: string;
folderId?: string | null;
onCreate: (title: string) => void;
onUpdate: (
noteId: string,
params: { title?: string; content?: string },
) => void;
onDelete: (noteId: string) => void;
}
let {
notes,
isEditor,
fullscreen = false,
orgId,
orgSlug,
folderId,
onCreate,
onUpdate,
onDelete,
}: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
// svelte-ignore state_referenced_locally
let selectedNoteId = $state<string | null>(
notes.length > 0 ? notes[0].id : null,
);
let editingTitle = $state(false);
let titleInput = $state("");
let showNewNote = $state(false);
let newNoteTitle = $state("");
let exporting = $state(false);
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
const selectedNote = $derived(
notes.find((n) => n.id === selectedNoteId) ?? null,
);
$effect(() => {
if (notes.length > 0 && !notes.find((n) => n.id === selectedNoteId)) {
selectedNoteId = notes[0].id;
}
});
function handleContentChange(e: Event) {
const target = e.target as HTMLTextAreaElement;
if (!selectedNoteId) return;
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
onUpdate(selectedNoteId!, { content: target.value });
}, 500);
}
function startTitleEdit() {
if (!selectedNote || !isEditor) return;
editingTitle = true;
titleInput = selectedNote.title;
}
function confirmTitleEdit() {
if (selectedNoteId && titleInput.trim()) {
onUpdate(selectedNoteId, { title: titleInput.trim() });
}
editingTitle = false;
}
function handleCreateNote() {
if (!newNoteTitle.trim()) return;
onCreate(newNoteTitle.trim());
newNoteTitle = "";
showNewNote = false;
}
async function handleExportToDocument() {
if (!selectedNote || !orgId || !orgSlug || !folderId) return;
exporting = true;
try {
const userId = (await supabase.auth.getUser()).data.user?.id;
if (!userId) throw new Error("Not authenticated");
// Convert plain text to TipTap JSON
const paragraphs = (selectedNote.content ?? "")
.split("\n")
.map((line) => ({
type: "paragraph",
content: line ? [{ type: "text", text: line }] : [],
}));
const tiptapContent = { type: "doc", content: paragraphs };
const doc = await createDocument(
supabase,
orgId,
selectedNote.title,
"document",
folderId,
userId,
{
content:
tiptapContent as import("$lib/supabase/types").Json,
},
);
toasts.success(m.notes_exported({ title: selectedNote.title }));
goto(`/${orgSlug}/documents/file/${doc.id}`);
} catch (e: unknown) {
toasts.error(getErrorMessage(e, m.notes_export_error()));
} finally {
exporting = false;
}
}
</script>
<div class="flex {fullscreen ? 'h-full' : 'h-full min-h-[200px]'} gap-0">
<!-- Note list sidebar -->
<div
class="w-40 shrink-0 border-r border-light/5 flex flex-col {fullscreen
? 'w-56'
: ''}"
>
<div class="flex-1 overflow-auto">
{#each notes as note (note.id)}
<button
class="w-full text-left px-3 py-2 text-body-sm transition-colors truncate {selectedNoteId ===
note.id
? 'bg-primary/10 text-primary'
: 'text-light/60 hover:text-white hover:bg-light/5'}"
onclick={() => (selectedNoteId = note.id)}
>
{note.title}
</button>
{/each}
</div>
{#if isEditor}
{#if showNewNote}
<div class="p-2 border-t border-light/5">
<input
type="text"
placeholder={m.notes_title_placeholder()}
bind:value={newNoteTitle}
onkeydown={(e) => {
if (e.key === "Enter") handleCreateNote();
if (e.key === "Escape") {
showNewNote = false;
newNoteTitle = "";
}
}}
class="w-full bg-transparent text-body-sm text-light placeholder:text-light/20 border-b border-primary outline-none py-1 px-1"
/>
</div>
{:else}
<button
class="flex items-center gap-1 px-3 py-2 border-t border-light/5 text-body-sm text-light/30 hover:text-light/60 transition-colors"
onclick={() => (showNewNote = true)}
>
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>add</span
>
{m.notes_new()}
</button>
{/if}
{/if}
</div>
<!-- Note content -->
<div class="flex-1 flex flex-col">
{#if selectedNote}
<!-- Title -->
<div
class="px-4 py-2 border-b border-light/5 flex items-center justify-between"
>
{#if editingTitle}
<input
type="text"
bind:value={titleInput}
onkeydown={(e) => {
if (e.key === "Enter") confirmTitleEdit();
if (e.key === "Escape") editingTitle = false;
}}
onblur={confirmTitleEdit}
class="bg-transparent text-body font-heading text-white border-b border-primary outline-none flex-1"
/>
{:else}
<button
class="text-body font-heading text-white text-left flex-1"
ondblclick={startTitleEdit}
>
{selectedNote.title}
</button>
{/if}
<div class="flex items-center gap-0.5">
{#if folderId && orgId && orgSlug}
<button
class="p-1 rounded-lg hover:bg-primary/10 transition-colors"
onclick={handleExportToDocument}
title={m.notes_export_document()}
disabled={exporting}
>
<span
class="material-symbols-rounded text-light/30 hover:text-primary"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>{exporting
? "progress_activity"
: "upload_file"}</span
>
</button>
{/if}
{#if isEditor}
<button
class="p-1 rounded-lg hover:bg-error/10 transition-colors"
onclick={() => {
if (selectedNoteId) onDelete(selectedNoteId);
}}
title={m.notes_delete()}
>
<span
class="material-symbols-rounded text-light/30 hover:text-error"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>delete</span
>
</button>
{/if}
</div>
</div>
<!-- Content -->
<textarea
class="flex-1 w-full bg-transparent text-body-sm text-light p-4 outline-none resize-none placeholder:text-light/20 {fullscreen
? 'text-body'
: ''}"
placeholder={m.notes_placeholder()}
value={selectedNote.content ?? ""}
oninput={handleContentChange}
disabled={!isEditor}
></textarea>
{:else}
<div
class="flex items-center justify-center h-full text-light/30 text-body-sm"
>
{notes.length === 0 ? m.notes_no_notes() : m.notes_select()}
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,616 @@
<script lang="ts">
import type { ScheduleStage, ScheduleBlock } from "$lib/supabase/types";
import { Button, Modal, Input, Select, Textarea } from "$lib/components/ui";
import * as m from "$lib/paraglide/messages";
interface Props {
stages: ScheduleStage[];
blocks: ScheduleBlock[];
isEditor: boolean;
fullscreen?: boolean;
onCreateStage: (name: string, color: string) => void;
onDeleteStage: (stageId: string) => void;
onCreateBlock: (params: {
title: string;
start_time: string;
end_time: string;
stage_id?: string | null;
description?: string;
color?: string;
speaker?: string;
}) => void;
onUpdateBlock: (
blockId: string,
params: Partial<
Pick<
ScheduleBlock,
| "title"
| "description"
| "start_time"
| "end_time"
| "stage_id"
| "color"
| "speaker"
>
>,
) => void;
onDeleteBlock: (blockId: string) => void;
}
let {
stages,
blocks,
isEditor,
fullscreen = false,
onCreateStage,
onDeleteStage,
onCreateBlock,
onUpdateBlock,
onDeleteBlock,
}: Props = $props();
// View mode: timeline or list
let viewMode = $state<"timeline" | "list">("timeline");
// Add block modal
let showBlockModal = $state(false);
let editingBlock = $state<ScheduleBlock | null>(null);
let blockTitle = $state("");
let blockDescription = $state("");
let blockDate = $state("");
let blockStartTime = $state("09:00");
let blockEndTime = $state("10:00");
let blockStageId = $state<string | null>(null);
let blockColor = $state("#6366f1");
let blockSpeaker = $state("");
// Add stage modal
let showStageModal = $state(false);
let stageName = $state("");
let stageColor = $state("#6366f1");
const PRESET_COLORS = [
"#6366f1",
"#EC4899",
"#10B981",
"#F59E0B",
"#00A3E0",
"#EF4444",
"#8B5CF6",
"#14B8A6",
];
// Group blocks by date
const blocksByDate = $derived.by(() => {
const groups: Record<string, ScheduleBlock[]> = {};
for (const block of blocks) {
const date = new Date(block.start_time).toLocaleDateString("en-CA");
if (!groups[date]) groups[date] = [];
groups[date].push(block);
}
// Sort dates
const sorted: [string, ScheduleBlock[]][] = Object.entries(groups).sort(
([a], [b]) => a.localeCompare(b),
);
return sorted;
});
function openBlockModal(block?: ScheduleBlock) {
if (block) {
editingBlock = block;
blockTitle = block.title;
blockDescription = block.description ?? "";
const start = new Date(block.start_time);
blockDate = start.toLocaleDateString("en-CA");
blockStartTime = start.toTimeString().slice(0, 5);
const end = new Date(block.end_time);
blockEndTime = end.toTimeString().slice(0, 5);
blockStageId = block.stage_id;
blockColor = block.color ?? "#6366f1";
blockSpeaker = block.speaker ?? "";
} else {
editingBlock = null;
blockTitle = "";
blockDescription = "";
blockDate = new Date().toLocaleDateString("en-CA");
blockStartTime = "09:00";
blockEndTime = "10:00";
blockStageId = null;
blockColor = "#6366f1";
blockSpeaker = "";
}
showBlockModal = true;
}
function handleSaveBlock() {
if (!blockTitle.trim() || !blockDate) return;
const start_time = new Date(
`${blockDate}T${blockStartTime}:00`,
).toISOString();
const end_time = new Date(
`${blockDate}T${blockEndTime}:00`,
).toISOString();
if (editingBlock) {
onUpdateBlock(editingBlock.id, {
title: blockTitle.trim(),
description: blockDescription.trim() || null,
start_time,
end_time,
stage_id: blockStageId,
color: blockColor,
speaker: blockSpeaker.trim() || null,
});
} else {
onCreateBlock({
title: blockTitle.trim(),
start_time,
end_time,
stage_id: blockStageId,
description: blockDescription.trim() || undefined,
color: blockColor,
speaker: blockSpeaker.trim() || undefined,
});
}
showBlockModal = false;
}
function handleCreateStage() {
if (!stageName.trim()) return;
onCreateStage(stageName.trim(), stageColor);
stageName = "";
stageColor = "#6366f1";
showStageModal = false;
}
function formatTime(iso: string): string {
return new Date(iso).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
}
function formatDateLabel(dateStr: string): string {
const d = new Date(dateStr + "T00:00:00");
return d.toLocaleDateString(undefined, {
weekday: "long",
month: "short",
day: "numeric",
});
}
function durationMinutes(block: ScheduleBlock): number {
return (
(new Date(block.end_time).getTime() -
new Date(block.start_time).getTime()) /
60000
);
}
function stageName_for(stageId: string | null): string {
if (!stageId) return "";
return stages.find((s) => s.id === stageId)?.name ?? "";
}
</script>
<div class="flex flex-col gap-3 {fullscreen ? 'max-w-3xl mx-auto' : ''} h-full">
<!-- Toolbar -->
<div class="flex items-center justify-between gap-2 shrink-0">
<div class="flex items-center gap-1 bg-dark/50 rounded-lg p-0.5">
<button
class="px-2 py-1 rounded text-[11px] transition-colors {viewMode ===
'timeline'
? 'bg-primary text-background'
: 'text-light/40 hover:text-light/70'}"
onclick={() => (viewMode = "timeline")}
>
{m.schedule_timeline()}
</button>
<button
class="px-2 py-1 rounded text-[11px] transition-colors {viewMode ===
'list'
? 'bg-primary text-background'
: 'text-light/40 hover:text-light/70'}"
onclick={() => (viewMode = "list")}
>
{m.schedule_list()}
</button>
</div>
{#if isEditor}
<div class="flex items-center gap-1.5">
<button
class="flex items-center gap-1 px-2 py-1 rounded-lg text-[11px] text-light/40 hover:text-light/70 hover:bg-light/5 transition-colors"
onclick={() => (showStageModal = true)}
>
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>add</span
>
{m.schedule_manage_stages()}
</button>
<Button size="sm" onclick={() => openBlockModal()}>
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>add</span
>
{m.schedule_add_block()}
</Button>
</div>
{/if}
</div>
<!-- Stages bar -->
{#if stages.length > 0}
<div class="flex items-center gap-2 flex-wrap shrink-0">
{#each stages as stage (stage.id)}
<div
class="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-dark/50 border border-light/5 group"
>
<div
class="w-2.5 h-2.5 rounded-full shrink-0"
style="background-color: {stage.color}"
></div>
<span class="text-[11px] text-light/60">{stage.name}</span>
{#if isEditor}
<button
class="p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-error/10 transition-all"
onclick={() => onDeleteStage(stage.id)}
>
<span
class="material-symbols-rounded text-light/30 hover:text-error"
style="font-size: 12px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 12;"
>close</span
>
</button>
{/if}
</div>
{/each}
</div>
{/if}
<!-- Content -->
<div class="flex-1 overflow-auto">
{#if blocks.length === 0}
<div
class="flex flex-col items-center justify-center h-full gap-2 text-light/30"
>
<span
class="material-symbols-rounded"
style="font-size: 36px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 36;"
>calendar_today</span
>
<p class="text-body-sm">{m.schedule_no_blocks()}</p>
</div>
{:else if viewMode === "timeline"}
<!-- Timeline view: grouped by date -->
<div class="flex flex-col gap-6">
{#each blocksByDate as [date, dayBlocks] (date)}
<div>
<h3
class="text-body-sm font-heading text-light/50 mb-3 sticky top-0 bg-surface/80 backdrop-blur-sm py-1 z-10"
>
{formatDateLabel(date)}
</h3>
<div class="flex flex-col gap-1 relative ml-3">
<!-- Timeline line -->
<div
class="absolute left-0 top-2 bottom-2 w-px bg-light/10"
></div>
{#each dayBlocks as block (block.id)}
{@const mins = durationMinutes(block)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative pl-6 py-1.5 group {isEditor
? 'cursor-pointer hover:bg-light/5 rounded-lg'
: ''}"
onclick={() =>
isEditor && openBlockModal(block)}
>
<!-- Dot on timeline -->
<div
class="absolute left-[-3px] top-3 w-[7px] h-[7px] rounded-full border-2 border-surface"
style="background-color: {block.color ??
'#6366f1'}"
></div>
<div class="flex items-start gap-3">
<div class="shrink-0 w-24">
<span
class="text-[11px] text-light/40 font-mono"
>
{formatTime(block.start_time)}
{formatTime(block.end_time)}
</span>
<span
class="block text-[10px] text-light/20"
>{mins}min</span
>
</div>
<div class="flex-1 min-w-0">
<div
class="flex items-center gap-2"
>
<div
class="w-1 h-4 rounded-full shrink-0"
style="background-color: {block.color ??
'#6366f1'}"
></div>
<span
class="text-body-sm text-white font-medium truncate"
>{block.title}</span
>
</div>
{#if block.speaker}
<span
class="text-[11px] text-light/40 ml-3"
>{block.speaker}</span
>
{/if}
{#if block.stage_id}
<span
class="text-[10px] text-light/30 ml-3"
>{stageName_for(
block.stage_id,
)}</span
>
{/if}
</div>
{#if isEditor}
<button
class="p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-error/10 transition-all shrink-0"
onclick={(e) => {
e.stopPropagation();
onDeleteBlock(block.id);
}}
>
<span
class="material-symbols-rounded text-light/30 hover:text-error"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>delete</span
>
</button>
{/if}
</div>
</div>
{/each}
</div>
</div>
{/each}
</div>
{:else}
<!-- List view: simple table -->
<div class="flex flex-col gap-1">
{#each blocks as block (block.id)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-light/5 transition-colors group {isEditor
? 'cursor-pointer'
: ''}"
onclick={() => isEditor && openBlockModal(block)}
>
<div
class="w-1.5 h-8 rounded-full shrink-0"
style="background-color: {block.color ?? '#6366f1'}"
></div>
<div class="flex-1 min-w-0">
<span
class="text-body-sm text-white font-medium truncate block"
>{block.title}</span
>
{#if block.speaker}
<span class="text-[11px] text-light/40"
>{block.speaker}</span
>
{/if}
</div>
<div class="text-right shrink-0">
<span class="text-[11px] text-light/40 font-mono">
{formatTime(block.start_time)} {formatTime(
block.end_time,
)}
</span>
<span class="block text-[10px] text-light/20">
{new Date(block.start_time).toLocaleDateString(
undefined,
{
month: "short",
day: "numeric",
},
)}
</span>
</div>
{#if block.stage_id}
<span
class="text-[10px] px-1.5 py-0.5 rounded bg-light/5 text-light/30 shrink-0"
>{stageName_for(block.stage_id)}</span
>
{/if}
{#if isEditor}
<button
class="p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-error/10 transition-all shrink-0"
onclick={(e) => {
e.stopPropagation();
onDeleteBlock(block.id);
}}
>
<span
class="material-symbols-rounded text-light/30 hover:text-error"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>delete</span
>
</button>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
<!-- Block Modal -->
<Modal
isOpen={showBlockModal}
onClose={() => (showBlockModal = false)}
title={editingBlock
? m.schedule_edit_block_title()
: m.schedule_add_block_title()}
>
<div class="flex flex-col gap-4">
<Input
variant="compact"
label={m.schedule_block_title_label()}
bind:value={blockTitle}
placeholder={m.schedule_block_title_placeholder()}
/>
<div class="grid grid-cols-3 gap-3">
<Input
variant="compact"
type="date"
label={m.schedule_block_start_label()}
bind:value={blockDate}
/>
<Input
variant="compact"
type="time"
label={m.schedule_block_start_label()}
bind:value={blockStartTime}
/>
<Input
variant="compact"
type="time"
label={m.schedule_block_end_label()}
bind:value={blockEndTime}
/>
</div>
<Input
variant="compact"
label={m.schedule_block_speaker_label()}
bind:value={blockSpeaker}
placeholder={m.schedule_block_speaker_placeholder()}
/>
{#if stages.length > 0}
<Select
variant="compact"
label={m.schedule_block_stage_label()}
bind:value={blockStageId}
placeholder={m.schedule_block_no_stage()}
options={stages.map((s) => ({ value: s.id, label: s.name }))}
/>
{/if}
<Textarea
variant="compact"
label={m.schedule_block_description_label()}
bind:value={blockDescription}
placeholder={m.schedule_block_description_placeholder()}
rows={2}
resize="none"
/>
<div class="flex flex-col gap-1.5">
<span class="text-body-sm text-light/60 font-body"
>{m.schedule_block_color_label()}</span
>
<div class="flex items-center gap-2">
{#each PRESET_COLORS as c}
<button
type="button"
class="w-6 h-6 rounded-full border-2 transition-all {blockColor ===
c
? 'border-white scale-110'
: 'border-transparent hover:border-light/30'}"
style="background-color: {c}"
onclick={() => (blockColor = c)}
aria-label="Color {c}"
></button>
{/each}
</div>
</div>
<div
class="flex items-center justify-end gap-3 pt-2 border-t border-light/5"
>
<button
type="button"
class="px-4 py-2 text-body-sm text-light/60 hover:text-white transition-colors"
onclick={() => (showBlockModal = false)}
>{m.btn_cancel()}</button
>
<button
type="button"
disabled={!blockTitle.trim() || !blockDate}
class="px-4 py-2 bg-primary text-background rounded-xl font-body text-body-sm hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onclick={handleSaveBlock}
>
{editingBlock ? m.btn_save() : m.btn_create()}
</button>
</div>
</div>
</Modal>
<!-- Stage Modal -->
<Modal
isOpen={showStageModal}
onClose={() => (showStageModal = false)}
title={m.schedule_add_stage_title()}
>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1.5">
<label for="stage-name" class="text-body-sm text-light/60 font-body"
>{m.schedule_stage_name_placeholder()}</label
>
<input
id="stage-name"
type="text"
bind:value={stageName}
placeholder={m.schedule_stage_name_placeholder()}
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
/>
</div>
<div class="flex flex-col gap-1.5">
<span class="text-body-sm text-light/60 font-body"
>{m.schedule_block_color_label()}</span
>
<div class="flex items-center gap-2">
{#each PRESET_COLORS as c}
<button
type="button"
class="w-6 h-6 rounded-full border-2 transition-all {stageColor ===
c
? 'border-white scale-110'
: 'border-transparent hover:border-light/30'}"
style="background-color: {c}"
onclick={() => (stageColor = c)}
aria-label="Color {c}"
></button>
{/each}
</div>
</div>
<div
class="flex items-center justify-end gap-3 pt-2 border-t border-light/5"
>
<button
type="button"
class="px-4 py-2 text-body-sm text-light/60 hover:text-white transition-colors"
onclick={() => (showStageModal = false)}
>{m.btn_cancel()}</button
>
<button
type="button"
disabled={!stageName.trim()}
class="px-4 py-2 bg-primary text-background rounded-xl font-body text-body-sm hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onclick={handleCreateStage}
>
{m.btn_create()}
</button>
</div>
</div>
</Modal>

View File

@@ -0,0 +1,878 @@
<script lang="ts">
import type {
SponsorTier,
Sponsor,
SponsorDeliverable,
} from "$lib/supabase/types";
import { STATUS_LABELS, STATUS_COLORS } from "$lib/api/sponsors";
import { Input, Select, Textarea } from "$lib/components/ui";
import * as m from "$lib/paraglide/messages";
import { formatCurrency as fmtCurrency } from "$lib/utils/currency";
interface Props {
tiers: SponsorTier[];
sponsors: Sponsor[];
deliverables: SponsorDeliverable[];
isEditor: boolean;
currency?: string;
fullscreen?: boolean;
onCreateTier: (name: string, amount: number, color: string) => void;
onDeleteTier: (tierId: string) => void;
onCreateSponsor: (params: {
name: string;
tier_id?: string | null;
contact_name?: string;
contact_email?: string;
contact_phone?: string;
website?: string;
status?: string;
amount?: number;
notes?: string;
}) => void;
onUpdateSponsor: (
sponsorId: string,
params: Partial<
Pick<
Sponsor,
| "name"
| "tier_id"
| "contact_name"
| "contact_email"
| "contact_phone"
| "website"
| "status"
| "amount"
| "notes"
>
>,
) => void;
onDeleteSponsor: (sponsorId: string) => void;
onCreateDeliverable: (
sponsorId: string,
description: string,
dueDate?: string,
) => void;
onToggleDeliverable: (
deliverableId: string,
completed: boolean,
) => void;
onDeleteDeliverable: (deliverableId: string) => void;
}
let {
tiers,
sponsors,
deliverables,
isEditor,
currency = "EUR",
fullscreen = false,
onCreateTier,
onDeleteTier,
onCreateSponsor,
onUpdateSponsor,
onDeleteSponsor,
onCreateDeliverable,
onToggleDeliverable,
onDeleteDeliverable,
}: Props = $props();
let filterStatus = $state<string>("all");
let filterTier = $state<string>("all");
let expandedSponsor = $state<string | null>(null);
let showAddSponsorModal = $state(false);
let showAddTierModal = $state(false);
let editingSponsor = $state<Sponsor | null>(null);
let newDeliverableText = $state("");
// Form state
let formName = $state("");
let formTierId = $state<string | null>(null);
let formContactName = $state("");
let formContactEmail = $state("");
let formContactPhone = $state("");
let formWebsite = $state("");
let formStatus = $state("prospect");
let formAmount = $state("0");
let formNotes = $state("");
// Tier form
let tierName = $state("");
let tierAmount = $state("0");
let tierColor = $state("#F59E0B");
const TIER_COLORS = [
"#F59E0B",
"#94a3b8",
"#CD7F32",
"#6366f1",
"#10B981",
"#EC4899",
"#EF4444",
"#06B6D4",
];
const STATUSES = [
"prospect",
"contacted",
"confirmed",
"declined",
"active",
] as const;
// Computed
const totalCommitted = $derived(
sponsors
.filter((s) => s.status === "confirmed" || s.status === "active")
.reduce((sum, s) => sum + Number(s.amount), 0),
);
const totalProspect = $derived(
sponsors
.filter((s) => s.status === "prospect" || s.status === "contacted")
.reduce((sum, s) => sum + Number(s.amount), 0),
);
const filteredSponsors = $derived(
sponsors.filter((s) => {
if (filterStatus !== "all" && s.status !== filterStatus)
return false;
if (filterTier !== "all" && (s.tier_id ?? "none") !== filterTier)
return false;
return true;
}),
);
function getTierName(tierId: string | null): string {
if (!tierId) return "No Tier";
return tiers.find((t) => t.id === tierId)?.name ?? "No Tier";
}
function getTierColor(tierId: string | null): string {
if (!tierId) return "#64748b";
return tiers.find((t) => t.id === tierId)?.color ?? "#64748b";
}
function getDeliverables(sponsorId: string): SponsorDeliverable[] {
return deliverables.filter((d) => d.sponsor_id === sponsorId);
}
function formatCurrency(amount: number): string {
return fmtCurrency(amount, currency ?? "EUR");
}
function openAddSponsor() {
editingSponsor = null;
formName = "";
formTierId = null;
formContactName = "";
formContactEmail = "";
formContactPhone = "";
formWebsite = "";
formStatus = "prospect";
formAmount = "0";
formNotes = "";
showAddSponsorModal = true;
}
function openEditSponsor(sponsor: Sponsor) {
editingSponsor = sponsor;
formName = sponsor.name;
formTierId = sponsor.tier_id;
formContactName = sponsor.contact_name ?? "";
formContactEmail = sponsor.contact_email ?? "";
formContactPhone = sponsor.contact_phone ?? "";
formWebsite = sponsor.website ?? "";
formStatus = sponsor.status;
formAmount = String(sponsor.amount);
formNotes = sponsor.notes ?? "";
showAddSponsorModal = true;
}
function handleSubmitSponsor() {
if (!formName.trim()) return;
if (editingSponsor) {
onUpdateSponsor(editingSponsor.id, {
name: formName.trim(),
tier_id: formTierId,
contact_name: formContactName.trim() || undefined,
contact_email: formContactEmail.trim() || undefined,
contact_phone: formContactPhone.trim() || undefined,
website: formWebsite.trim() || undefined,
status: formStatus as Sponsor["status"],
amount: parseFloat(formAmount) || 0,
notes: formNotes.trim() || undefined,
});
} else {
onCreateSponsor({
name: formName.trim(),
tier_id: formTierId,
contact_name: formContactName.trim() || undefined,
contact_email: formContactEmail.trim() || undefined,
contact_phone: formContactPhone.trim() || undefined,
website: formWebsite.trim() || undefined,
status: formStatus,
amount: parseFloat(formAmount) || 0,
notes: formNotes.trim() || undefined,
});
}
showAddSponsorModal = false;
}
function handleAddTier() {
if (!tierName.trim()) return;
onCreateTier(tierName.trim(), parseFloat(tierAmount) || 0, tierColor);
tierName = "";
tierAmount = "0";
tierColor = "#F59E0B";
showAddTierModal = false;
}
function handleAddDeliverable(sponsorId: string) {
if (!newDeliverableText.trim()) return;
onCreateDeliverable(sponsorId, newDeliverableText.trim());
newDeliverableText = "";
}
</script>
<div class="flex flex-col gap-3 {fullscreen ? 'h-full' : ''}">
<!-- Summary -->
<div class="grid grid-cols-2 {fullscreen ? 'md:grid-cols-4' : ''} gap-2">
<div
class="bg-emerald-500/10 border border-emerald-500/20 rounded-xl p-3"
>
<p class="text-[11px] text-emerald-400/70 uppercase tracking-wide">
Confirmed
</p>
<p class="text-body font-heading text-emerald-400">
{formatCurrency(totalCommitted)}
</p>
<p class="text-[11px] text-light/30">
{sponsors.filter(
(s) => s.status === "confirmed" || s.status === "active",
).length} sponsors
</p>
</div>
<div class="bg-amber-500/10 border border-amber-500/20 rounded-xl p-3">
<p class="text-[11px] text-amber-400/70 uppercase tracking-wide">
Pipeline
</p>
<p class="text-body font-heading text-amber-400">
{formatCurrency(totalProspect)}
</p>
<p class="text-[11px] text-light/30">
{sponsors.filter(
(s) => s.status === "prospect" || s.status === "contacted",
).length} prospects
</p>
</div>
{#if fullscreen}
<div class="bg-light/5 border border-light/10 rounded-xl p-3">
<p class="text-[11px] text-light/50 uppercase tracking-wide">
Total Sponsors
</p>
<p class="text-body font-heading text-white">
{sponsors.length}
</p>
</div>
<div
class="bg-indigo-500/10 border border-indigo-500/20 rounded-xl p-3"
>
<p
class="text-[11px] text-indigo-400/70 uppercase tracking-wide"
>
Tiers
</p>
<p class="text-body font-heading text-indigo-400">
{tiers.length}
</p>
</div>
{/if}
</div>
<!-- Toolbar -->
<div class="flex items-center justify-between gap-2 flex-wrap">
<div class="flex items-center gap-2">
<Select
variant="compact"
bind:value={filterStatus}
placeholder=""
options={[
{ value: "all", label: m.sponsors_filter_all_statuses() },
...STATUSES.map((s) => ({
value: s,
label: STATUS_LABELS[s],
})),
]}
/>
{#if tiers.length > 0}
<Select
variant="compact"
bind:value={filterTier}
placeholder=""
options={[
{ value: "all", label: m.sponsors_filter_all_tiers() },
{ value: "none", label: m.sponsors_no_tier_filter() },
...tiers.map((t) => ({ value: t.id, label: t.name })),
]}
/>
{/if}
</div>
{#if isEditor}
<div class="flex items-center gap-1">
{#if fullscreen}
<button
class="flex items-center gap-1 px-2 py-1 rounded-lg bg-light/5 hover:bg-light/10 text-light/60 text-[11px] transition-colors"
onclick={() => (showAddTierModal = true)}
>
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>workspace_premium</span
>
{m.sponsors_add_tier()}
</button>
{/if}
<button
class="flex items-center gap-1 px-2 py-1 rounded-lg bg-primary/10 hover:bg-primary/20 text-primary text-[11px] transition-colors"
onclick={openAddSponsor}
>
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>add</span
>
{m.sponsors_add_sponsor()}
</button>
</div>
{/if}
</div>
<!-- Sponsors list -->
<div class="flex-1 overflow-auto space-y-1">
{#if filteredSponsors.length === 0}
<div
class="flex flex-col items-center justify-center py-8 text-light/30 gap-2"
>
<span
class="material-symbols-rounded"
style="font-size: 32px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 32;"
>handshake</span
>
<p class="text-body-sm">{m.sponsors_no_sponsors()}</p>
</div>
{:else}
{#each filteredSponsors as sponsor (sponsor.id)}
{@const sponsorDeliverables = getDeliverables(sponsor.id)}
{@const completedCount = sponsorDeliverables.filter(
(d) => d.is_completed,
).length}
{@const isExpanded = expandedSponsor === sponsor.id}
<div
class="rounded-xl border border-light/5 overflow-hidden transition-colors {isExpanded
? 'bg-light/5'
: 'hover:bg-light/[0.03]'}"
>
<!-- Sponsor row -->
<button
class="w-full flex items-center gap-3 px-3 py-2.5 text-left"
onclick={() =>
(expandedSponsor = isExpanded ? null : sponsor.id)}
>
<!-- Status dot -->
<span
class="w-2.5 h-2.5 rounded-full flex-shrink-0"
style="background-color: {STATUS_COLORS[
sponsor.status
]}"
></span>
<!-- Name + tier -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span
class="text-body-sm text-white font-heading truncate"
>{sponsor.name}</span
>
<span
class="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] uppercase tracking-wider flex-shrink-0"
style="background-color: {getTierColor(
sponsor.tier_id,
)}20; color: {getTierColor(
sponsor.tier_id,
)}"
>
{getTierName(sponsor.tier_id)}
</span>
</div>
{#if sponsor.contact_name}
<p class="text-[11px] text-light/40 truncate">
{sponsor.contact_name}
</p>
{/if}
</div>
<!-- Amount -->
<span
class="text-body-sm font-heading text-white flex-shrink-0"
>{formatCurrency(Number(sponsor.amount))}</span
>
<!-- Status badge -->
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] flex-shrink-0"
style="background-color: {STATUS_COLORS[
sponsor.status
]}20; color: {STATUS_COLORS[sponsor.status]}"
>
{STATUS_LABELS[sponsor.status]}
</span>
<!-- Deliverables count -->
{#if sponsorDeliverables.length > 0}
<span
class="text-[10px] text-light/30 flex-shrink-0"
>{completedCount}/{sponsorDeliverables.length}</span
>
{/if}
<!-- Expand icon -->
<span
class="material-symbols-rounded text-light/30 transition-transform {isExpanded
? 'rotate-180'
: ''}"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>expand_more</span
>
</button>
<!-- Expanded details -->
{#if isExpanded}
<div
class="px-3 pb-3 space-y-3 border-t border-light/5 pt-3"
>
<!-- Contact info -->
<div class="grid grid-cols-2 gap-2 text-[11px]">
{#if sponsor.contact_email}
<a
href="mailto:{sponsor.contact_email}"
class="flex items-center gap-1 text-primary hover:underline"
>
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>mail</span
>
{sponsor.contact_email}
</a>
{/if}
{#if sponsor.contact_phone}
<a
href="tel:{sponsor.contact_phone}"
class="flex items-center gap-1 text-primary hover:underline"
>
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>phone</span
>
{sponsor.contact_phone}
</a>
{/if}
{#if sponsor.website}
<a
href={sponsor.website}
target="_blank"
rel="noopener"
class="flex items-center gap-1 text-primary hover:underline"
>
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>language</span
>
Website
</a>
{/if}
</div>
{#if sponsor.notes}
<p
class="text-[11px] text-light/40 bg-dark/30 rounded-lg px-2 py-1.5"
>
{sponsor.notes}
</p>
{/if}
<!-- Deliverables -->
<div>
<p
class="text-[10px] text-light/40 uppercase tracking-wider mb-1.5"
>
{m.sponsors_deliverables_label()}
</p>
{#if sponsorDeliverables.length > 0}
<div class="space-y-1">
{#each sponsorDeliverables as del (del.id)}
<div
class="flex items-center gap-2 group"
>
<button
class="w-4 h-4 rounded border flex items-center justify-center flex-shrink-0 transition-colors {del.is_completed
? 'bg-primary border-primary'
: 'border-light/20 hover:border-primary/50'}"
onclick={() =>
onToggleDeliverable(
del.id,
!del.is_completed,
)}
disabled={!isEditor}
>
{#if del.is_completed}
<span
class="material-symbols-rounded text-background"
style="font-size: 12px; font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 12;"
>check</span
>
{/if}
</button>
<span
class="text-[11px] flex-1 {del.is_completed
? 'text-light/30 line-through'
: 'text-light/70'}"
>{del.description}</span
>
{#if del.due_date}
<span
class="text-[10px] text-light/30"
>{new Date(
del.due_date,
).toLocaleDateString()}</span
>
{/if}
{#if isEditor}
<button
class="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-error/10 transition-all"
onclick={() =>
onDeleteDeliverable(
del.id,
)}
>
<span
class="material-symbols-rounded text-light/30 hover:text-error"
style="font-size: 12px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 12;"
>close</span
>
</button>
{/if}
</div>
{/each}
</div>
{/if}
{#if isEditor}
<div class="flex items-center gap-2 mt-1.5">
<input
type="text"
bind:value={newDeliverableText}
placeholder={m.sponsors_deliverable_placeholder()}
class="flex-1 bg-dark/30 border border-light/10 rounded px-2 py-1 text-[11px] text-white placeholder:text-light/20 focus:outline-none focus:border-primary/50"
onkeydown={(e) =>
e.key === "Enter" &&
handleAddDeliverable(
sponsor.id,
)}
/>
<button
class="p-1 rounded hover:bg-primary/10 transition-colors"
onclick={() =>
handleAddDeliverable(
sponsor.id,
)}
>
<span
class="material-symbols-rounded text-primary"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>add</span
>
</button>
</div>
{/if}
</div>
<!-- Actions -->
{#if isEditor}
<div class="flex items-center gap-2 pt-1">
<button
class="flex items-center gap-1 px-2 py-1 rounded-lg bg-light/5 hover:bg-light/10 text-light/60 text-[11px] transition-colors"
onclick={() => openEditSponsor(sponsor)}
>
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>edit</span
>
Edit
</button>
<button
class="flex items-center gap-1 px-2 py-1 rounded-lg hover:bg-error/10 text-light/40 hover:text-error text-[11px] transition-colors"
onclick={() =>
onDeleteSponsor(sponsor.id)}
>
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>delete</span
>
Delete
</button>
</div>
{/if}
</div>
{/if}
</div>
{/each}
{/if}
</div>
</div>
<!-- Add/Edit Sponsor Modal -->
{#if showAddSponsorModal}
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div
class="fixed inset-0 z-[60] bg-black/60 flex items-center justify-center p-4"
onclick={() => (showAddSponsorModal = false)}
onkeydown={(e) => e.key === "Escape" && (showAddSponsorModal = false)}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="bg-surface rounded-2xl border border-light/10 p-5 w-full max-w-md space-y-4 max-h-[80vh] overflow-auto"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<h3 class="text-body font-heading text-white">
{editingSponsor
? m.sponsors_edit_sponsor_title()
: m.sponsors_add_sponsor_title()}
</h3>
<div class="space-y-3">
<Input
variant="compact"
label={m.sponsors_name_label()}
bind:value={formName}
placeholder={m.sponsors_name_placeholder()}
/>
<div class="grid grid-cols-2 gap-3">
<Select
variant="compact"
label={m.sponsors_tier_label()}
bind:value={formTierId}
placeholder={m.sponsors_no_tier()}
options={tiers.map((t) => ({
value: t.id,
label: t.name,
}))}
/>
<Select
variant="compact"
label={m.sponsors_status_label()}
bind:value={formStatus}
placeholder=""
options={STATUSES.map((s) => ({
value: s,
label: STATUS_LABELS[s],
}))}
/>
</div>
<Input
variant="compact"
type="number"
label={m.sponsors_sponsor_amount_label()}
bind:value={formAmount}
/>
<div class="grid grid-cols-2 gap-3">
<Input
variant="compact"
label={m.sponsors_contact_name_label()}
bind:value={formContactName}
placeholder={m.sponsors_contact_name_placeholder()}
/>
<Input
variant="compact"
type="email"
label={m.sponsors_contact_email_label()}
bind:value={formContactEmail}
placeholder={m.sponsors_contact_email_placeholder()}
/>
</div>
<div class="grid grid-cols-2 gap-3">
<Input
variant="compact"
type="tel"
label={m.sponsors_contact_phone_label()}
bind:value={formContactPhone}
placeholder={m.sponsors_contact_phone_placeholder()}
/>
<Input
variant="compact"
type="url"
label={m.sponsors_website_label()}
bind:value={formWebsite}
placeholder={m.sponsors_website_placeholder()}
/>
</div>
<Textarea
variant="compact"
label={m.sponsors_notes_label()}
bind:value={formNotes}
rows={2}
placeholder={m.sponsors_notes_placeholder()}
resize="none"
/>
</div>
<div class="flex justify-end gap-2">
<button
class="px-3 py-1.5 rounded-lg text-body-sm text-light/60 hover:text-white transition-colors"
onclick={() => (showAddSponsorModal = false)}
>{m.btn_cancel()}</button
>
<button
class="px-3 py-1.5 rounded-lg bg-primary text-background text-body-sm font-heading hover:bg-primary/90 transition-colors"
onclick={handleSubmitSponsor}
>
{editingSponsor ? m.btn_save() : m.sponsors_add_sponsor()}
</button>
</div>
</div>
</div>
{/if}
<!-- Add Tier Modal -->
{#if showAddTierModal}
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div
class="fixed inset-0 z-[60] bg-black/60 flex items-center justify-center p-4"
onclick={() => (showAddTierModal = false)}
onkeydown={(e) => e.key === "Escape" && (showAddTierModal = false)}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="bg-surface rounded-2xl border border-light/10 p-5 w-full max-w-sm space-y-4"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<h3 class="text-body font-heading text-white">
{m.sponsors_add_tier_title()}
</h3>
<div class="space-y-3">
<div>
<label
class="block text-[11px] text-light/50 mb-1"
for="tier-name">{m.sponsors_tier_name_label()}</label
>
<input
id="tier-name"
type="text"
bind:value={tierName}
placeholder={m.sponsors_tier_name_placeholder()}
class="w-full bg-dark/50 border border-light/10 rounded-lg px-3 py-2 text-body-sm text-white placeholder:text-light/20 focus:outline-none focus:border-primary/50"
/>
</div>
<div>
<label
class="block text-[11px] text-light/50 mb-1"
for="tier-amount"
>{m.sponsors_tier_amount_label()}</label
>
<input
id="tier-amount"
type="number"
step="0.01"
bind:value={tierAmount}
class="w-full bg-dark/50 border border-light/10 rounded-lg px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary/50"
/>
</div>
<div>
<p class="text-[11px] text-light/50 mb-1">
{m.sponsors_tier_color_label()}
</p>
<div class="flex gap-1.5">
{#each TIER_COLORS as color}
<button
class="w-6 h-6 rounded-full border-2 transition-all {tierColor ===
color
? 'border-white scale-110'
: 'border-transparent'}"
style="background-color: {color}"
onclick={() => (tierColor = color)}
aria-label={m.sponsors_select_color({ color })}
></button>
{/each}
</div>
</div>
<!-- Existing tiers -->
{#if tiers.length > 0}
<div>
<p class="text-[11px] text-light/50 mb-1">
{m.sponsors_existing_tiers()}
</p>
<div class="space-y-1">
{#each tiers as tier}
<div
class="flex items-center justify-between px-2 py-1 rounded-lg bg-dark/30"
>
<div class="flex items-center gap-2">
<span
class="w-3 h-3 rounded-full"
style="background-color: {tier.color}"
></span>
<span class="text-[11px] text-white"
>{tier.name}</span
>
<span class="text-[10px] text-light/30"
>{formatCurrency(
Number(tier.amount),
)}</span
>
</div>
{#if isEditor}
<button
class="p-0.5 rounded hover:bg-error/10 transition-colors"
onclick={() =>
onDeleteTier(tier.id)}
>
<span
class="material-symbols-rounded text-light/30 hover:text-error"
style="font-size: 12px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 12;"
>close</span
>
</button>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</div>
<div class="flex justify-end gap-2">
<button
class="px-3 py-1.5 rounded-lg text-body-sm text-light/60 hover:text-white transition-colors"
onclick={() => (showAddTierModal = false)}
>{m.btn_close()}</button
>
<button
class="px-3 py-1.5 rounded-lg bg-primary text-background text-body-sm font-heading hover:bg-primary/90 transition-colors"
onclick={handleAddTier}
>
{m.sponsors_add_tier()}
</button>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,446 @@
<script lang="ts">
import { untrack } from "svelte";
import {
Avatar,
Button,
Spinner,
EmptyState,
Input,
} from "$lib/components/ui";
import * as m from "$lib/paraglide/messages";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
interface ActivityEntry {
id: string;
action: string;
entity_type: string;
entity_id: string | null;
entity_name: string | null;
created_at: string | null;
metadata: Record<string, unknown> | null;
profiles: {
full_name: string | null;
email: string | null;
avatar_url: string | null;
} | null;
}
interface Props {
supabase: SupabaseClient<Database>;
orgId: string;
}
let { supabase, orgId }: Props = $props();
const PAGE_SIZE = 50;
let entries = $state<ActivityEntry[]>([]);
let isLoading = $state(false);
let isLoadingMore = $state(false);
let hasMore = $state(true);
let totalCount = $state<number | null>(null);
let activeFilter = $state<string>("all");
let searchQuery = $state("");
let initialLoaded = $state(false);
const filters: { id: string; label: () => string; icon: string }[] = [
{ id: "all", label: m.settings_activity_filter_all, icon: "list" },
{
id: "create",
label: m.settings_activity_filter_create,
icon: "add_circle",
},
{
id: "update",
label: m.settings_activity_filter_update,
icon: "edit",
},
{
id: "delete",
label: m.settings_activity_filter_delete,
icon: "delete",
},
{
id: "move",
label: m.settings_activity_filter_move,
icon: "drive_file_move",
},
{
id: "rename",
label: m.settings_activity_filter_rename,
icon: "edit_note",
},
];
const filteredEntries = $derived(
searchQuery.trim()
? entries.filter((e) => {
const q = searchQuery.toLowerCase();
const name = (e.entity_name ?? "").toLowerCase();
const user = (
e.profiles?.full_name ??
e.profiles?.email ??
""
).toLowerCase();
const entityType = e.entity_type.toLowerCase();
return (
name.includes(q) ||
user.includes(q) ||
entityType.includes(q)
);
})
: entries,
);
async function loadEntries(reset = false) {
if (reset) {
entries = [];
hasMore = true;
isLoading = true;
} else {
isLoadingMore = true;
}
let query = supabase
.from("activity_log")
.select(
`id, action, entity_type, entity_id, entity_name, created_at, metadata,
profiles:user_id ( full_name, email, avatar_url )`,
{ count: "exact" },
)
.eq("org_id", orgId)
.order("created_at", { ascending: false })
.range(entries.length, entries.length + PAGE_SIZE - 1);
if (activeFilter !== "all") {
query = query.eq("action", activeFilter);
}
const { data, count, error } = await query;
if (!error && data) {
if (reset) {
entries = data as unknown as ActivityEntry[];
} else {
entries = [...entries, ...(data as unknown as ActivityEntry[])];
}
totalCount = count;
hasMore = data.length === PAGE_SIZE;
}
isLoading = false;
isLoadingMore = false;
initialLoaded = true;
}
$effect(() => {
// Re-load when filter changes — only track activeFilter, not internal state
const _filter = activeFilter;
untrack(() => loadEntries(true));
});
function getEntityTypeLabel(entityType: string): string {
const map: Record<string, () => string> = {
document: m.entity_document,
folder: m.entity_folder,
kanban_board: m.entity_kanban_board,
kanban_card: m.entity_kanban_card,
kanban_column: m.entity_kanban_column,
member: m.entity_member,
role: m.entity_role,
invite: m.entity_invite,
event: m.entity_event,
};
return (map[entityType] ?? (() => entityType))();
}
function getActivityIcon(action: string): string {
const map: Record<string, string> = {
create: "add_circle",
update: "edit",
delete: "delete",
move: "drive_file_move",
rename: "edit_note",
};
return map[action] ?? "info";
}
function getActivityColor(action: string): string {
const map: Record<string, string> = {
create: "text-emerald-400",
update: "text-blue-400",
delete: "text-red-400",
move: "text-amber-400",
rename: "text-purple-400",
};
return map[action] ?? "text-light/50";
}
function getActivityBg(action: string): string {
const map: Record<string, string> = {
create: "bg-emerald-400/10",
update: "bg-blue-400/10",
delete: "bg-red-400/10",
move: "bg-amber-400/10",
rename: "bg-purple-400/10",
};
return map[action] ?? "bg-light/5";
}
function getDescription(entry: ActivityEntry): string {
const userName =
entry.profiles?.full_name || entry.profiles?.email || "Someone";
const entityType = getEntityTypeLabel(entry.entity_type);
const name = entry.entity_name ?? "-";
const map: Record<string, () => string> = {
create: () =>
m.activity_created({ user: userName, entityType, name }),
update: () =>
m.activity_updated({ user: userName, entityType, name }),
delete: () =>
m.activity_deleted({ user: userName, entityType, name }),
move: () => m.activity_moved({ user: userName, entityType, name }),
rename: () =>
m.activity_renamed({ user: userName, entityType, name }),
};
return (map[entry.action] ?? map["update"]!)();
}
function formatFullDate(dateStr: string | null): string {
if (!dateStr) return "";
const d = new Date(dateStr);
return d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function formatRelativeDate(dateStr: string | null): string {
if (!dateStr) return "";
const now = Date.now();
const then = new Date(dateStr).getTime();
const diffMs = now - then;
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return m.activity_just_now();
if (diffMin < 60)
return m.activity_minutes_ago({ count: String(diffMin) });
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return m.activity_hours_ago({ count: String(diffHr) });
const diffDay = Math.floor(diffHr / 24);
return m.activity_days_ago({ count: String(diffDay) });
}
function getDateGroup(dateStr: string | null): string {
if (!dateStr) return "";
const d = new Date(dateStr);
const now = new Date();
const today = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
);
const entryDate = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const diffDays = Math.floor(
(today.getTime() - entryDate.getTime()) / 86400000,
);
if (diffDays === 0) return "Today";
if (diffDays === 1) return "Yesterday";
if (diffDays < 7) return `${diffDays} days ago`;
return d.toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
});
}
// Group entries by date
const groupedEntries = $derived(() => {
const groups: { label: string; entries: ActivityEntry[] }[] = [];
let currentLabel = "";
for (const entry of filteredEntries) {
const label = getDateGroup(entry.created_at);
if (label !== currentLabel) {
currentLabel = label;
groups.push({ label, entries: [] });
}
groups[groups.length - 1].entries.push(entry);
}
return groups;
});
</script>
<div class="space-y-4">
<!-- Header -->
<div
class="flex flex-col sm:flex-row sm:items-center justify-between gap-3"
>
<div>
<h2 class="text-body font-heading text-white">
{m.settings_activity_title()}
</h2>
<p class="text-body-sm text-light/50 mt-0.5">
{m.settings_activity_desc()}
</p>
</div>
{#if totalCount !== null}
<span
class="text-[11px] text-light/30 bg-dark/50 px-3 py-1.5 rounded-lg shrink-0"
>
{m.settings_activity_count({ count: String(totalCount) })}
</span>
{/if}
</div>
<!-- Filters + Search -->
<div class="flex flex-col sm:flex-row gap-3">
<div class="flex flex-wrap gap-1.5">
{#each filters as filter}
<button
type="button"
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[12px] font-body transition-colors {activeFilter ===
filter.id
? 'bg-primary text-background'
: 'text-light/50 hover:text-white hover:bg-dark/50 bg-dark/20'}"
onclick={() => (activeFilter = filter.id)}
>
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>{filter.icon}</span
>
{filter.label()}
</button>
{/each}
</div>
<div class="sm:ml-auto w-full sm:w-64">
<Input
placeholder={m.settings_activity_search_placeholder()}
bind:value={searchQuery}
icon="search"
/>
</div>
</div>
<!-- Content -->
{#if isLoading}
<div class="flex items-center justify-center py-16">
<Spinner size="lg" />
</div>
{:else if filteredEntries.length === 0 && initialLoaded}
<div class="bg-dark/30 border border-light/5 rounded-xl p-8">
<EmptyState
title={m.settings_activity_empty()}
description={searchQuery
? "Try a different search term."
: m.settings_activity_desc()}
/>
</div>
{:else}
<div class="flex flex-col gap-1">
{#each groupedEntries() as group}
<!-- Date group header -->
<div
class="sticky top-0 z-10 bg-background/80 backdrop-blur-sm px-1 py-2 mt-2 first:mt-0"
>
<span
class="text-[11px] font-heading text-light/30 uppercase tracking-wider"
>
{group.label}
</span>
</div>
{#each group.entries as entry (entry.id)}
<div
class="flex items-start gap-3 px-3 py-3 rounded-xl hover:bg-dark/30 transition-colors group"
>
<!-- Icon -->
<div
class="w-8 h-8 rounded-lg {getActivityBg(
entry.action,
)} flex items-center justify-center shrink-0 mt-0.5"
>
<span
class="material-symbols-rounded {getActivityColor(
entry.action,
)}"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>{getActivityIcon(entry.action)}</span
>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<p
class="text-body-sm text-light/80 leading-relaxed"
>
{getDescription(entry)}
</p>
<div class="flex items-center gap-3 mt-1">
{#if entry.profiles}
<div class="flex items-center gap-1.5">
<Avatar
name={entry.profiles.full_name ??
entry.profiles.email ??
"?"}
src={entry.profiles.avatar_url}
size="xs"
/>
<span class="text-[11px] text-light/40">
{entry.profiles.full_name ??
entry.profiles.email}
</span>
</div>
{/if}
<span class="text-[11px] text-light/20">·</span>
<span
class="text-[11px] text-light/30"
title={formatFullDate(entry.created_at)}
>
{formatRelativeDate(entry.created_at)}
</span>
<span
class="text-[11px] text-light/20 hidden group-hover:inline"
>
{formatFullDate(entry.created_at)}
</span>
</div>
</div>
<!-- Entity type badge -->
<span
class="text-[10px] text-light/30 bg-dark/50 px-2 py-1 rounded-md shrink-0 mt-1"
>
{getEntityTypeLabel(entry.entity_type)}
</span>
</div>
{/each}
{/each}
</div>
<!-- Load more / End -->
<div class="flex justify-center py-4">
{#if isLoadingMore}
<Spinner />
{:else if hasMore}
<Button
variant="ghost"
size="sm"
icon="expand_more"
onclick={() => loadEntries(false)}
>
{m.settings_activity_load_more()}
</Button>
{:else if entries.length > 0}
<p class="text-[11px] text-light/20">
{m.settings_activity_end()}
</p>
{/if}
</div>
{/if}
</div>

View File

@@ -1,18 +1,55 @@
<script lang="ts">
import { Button, Input, Avatar } from "$lib/components/ui";
import {
Button,
Input,
Avatar,
Select,
Textarea,
} from "$lib/components/ui";
import type { SupabaseClient } from "@supabase/supabase-js";
import * as m from "$lib/paraglide/messages";
import type { Database } from "$lib/supabase/types";
import { toasts } from "$lib/stores/toast.svelte";
import { invalidateAll } from "$app/navigation";
import {
SUPPORTED_CURRENCIES,
DATE_FORMAT_OPTIONS,
WEEK_START_OPTIONS,
CALENDAR_VIEW_OPTIONS,
TIMEZONE_OPTIONS,
EVENT_STATUS_OPTIONS,
DASHBOARD_LAYOUT_OPTIONS,
DEPT_MODULE_OPTIONS,
EVENT_COLOR_PRESETS,
formatCurrency,
} from "$lib/utils/currency";
interface OrgData {
id: string;
name: string;
slug: string;
avatar_url?: string | null;
description?: string | null;
theme_color?: string | null;
currency?: string;
date_format?: string;
timezone?: string;
week_start_day?: string;
default_calendar_view?: string;
default_event_color?: string;
default_event_status?: string;
default_dept_modules?: string[];
default_dept_layout?: string;
feature_chat?: boolean;
feature_sponsors?: boolean;
feature_contacts?: boolean;
feature_budget?: boolean;
social_links?: Record<string, string>;
}
interface Props {
supabase: SupabaseClient<Database>;
org: {
id: string;
name: string;
slug: string;
avatar_url?: string | null;
};
org: OrgData;
isOwner: boolean;
onLeave: () => void;
onDelete: () => void;
@@ -20,17 +57,116 @@
let { supabase, org, isOwner, onLeave, onDelete }: Props = $props();
// ── Organization details ──
// svelte-ignore state_referenced_locally
let orgName = $state(org.name);
// svelte-ignore state_referenced_locally
let orgSlug = $state(org.slug);
// svelte-ignore state_referenced_locally
let avatarUrl = $state(org.avatar_url ?? null);
// svelte-ignore state_referenced_locally
let orgDescription = $state(org.description ?? "");
// svelte-ignore state_referenced_locally
let themeColor = $state(org.theme_color ?? "#00a3e0");
let isSaving = $state(false);
let isUploading = $state(false);
let avatarInput = $state<HTMLInputElement | null>(null);
// ── Preferences ──
// svelte-ignore state_referenced_locally
let currency = $state(org.currency ?? "EUR");
// svelte-ignore state_referenced_locally
let dateFormat = $state(org.date_format ?? "DD/MM/YYYY");
// svelte-ignore state_referenced_locally
let timezone = $state(org.timezone ?? "Europe/Tallinn");
// svelte-ignore state_referenced_locally
let weekStartDay = $state(org.week_start_day ?? "monday");
// svelte-ignore state_referenced_locally
let defaultCalendarView = $state(org.default_calendar_view ?? "month");
let isSavingPrefs = $state(false);
// ── Event defaults ──
// svelte-ignore state_referenced_locally
let defaultEventColor = $state(org.default_event_color ?? "#7986cb");
// svelte-ignore state_referenced_locally
let defaultEventStatus = $state(org.default_event_status ?? "planning");
// svelte-ignore state_referenced_locally
let defaultDeptModules = $state<string[]>(
org.default_dept_modules ?? ["kanban", "files", "checklist"],
);
// svelte-ignore state_referenced_locally
let defaultDeptLayout = $state(org.default_dept_layout ?? "split");
let isSavingDefaults = $state(false);
// ── Social links ──
// svelte-ignore state_referenced_locally
let socialWebsite = $state(org.social_links?.website ?? "");
// svelte-ignore state_referenced_locally
let socialInstagram = $state(org.social_links?.instagram ?? "");
// svelte-ignore state_referenced_locally
let socialFacebook = $state(org.social_links?.facebook ?? "");
// svelte-ignore state_referenced_locally
let socialDiscord = $state(org.social_links?.discord ?? "");
// svelte-ignore state_referenced_locally
let socialLinkedin = $state(org.social_links?.linkedin ?? "");
// svelte-ignore state_referenced_locally
let socialX = $state(org.social_links?.x ?? "");
// svelte-ignore state_referenced_locally
let socialYoutube = $state(org.social_links?.youtube ?? "");
// svelte-ignore state_referenced_locally
let socialTiktok = $state(org.social_links?.tiktok ?? "");
// svelte-ignore state_referenced_locally
let socialFienta = $state(org.social_links?.fienta ?? "");
// svelte-ignore state_referenced_locally
let socialTwitch = $state(org.social_links?.twitch ?? "");
let isSavingSocial = $state(false);
// ── Feature toggles ──
// svelte-ignore state_referenced_locally
let featureChat = $state(org.feature_chat ?? true);
// svelte-ignore state_referenced_locally
let featureSponsors = $state(org.feature_sponsors ?? true);
// svelte-ignore state_referenced_locally
let featureContacts = $state(org.feature_contacts ?? true);
// svelte-ignore state_referenced_locally
let featureBudget = $state(org.feature_budget ?? true);
let isSavingFeatures = $state(false);
const currencyPreview = $derived(formatCurrency(1234.56, currency));
$effect(() => {
orgName = org.name;
orgSlug = org.slug;
avatarUrl = org.avatar_url ?? null;
orgDescription = org.description ?? "";
themeColor = org.theme_color ?? "#00a3e0";
currency = org.currency ?? "EUR";
dateFormat = org.date_format ?? "DD/MM/YYYY";
timezone = org.timezone ?? "Europe/Tallinn";
weekStartDay = org.week_start_day ?? "monday";
defaultCalendarView = org.default_calendar_view ?? "month";
defaultEventColor = org.default_event_color ?? "#7986cb";
defaultEventStatus = org.default_event_status ?? "planning";
defaultDeptModules = org.default_dept_modules ?? [
"kanban",
"files",
"checklist",
];
defaultDeptLayout = org.default_dept_layout ?? "split";
socialWebsite = org.social_links?.website ?? "";
socialInstagram = org.social_links?.instagram ?? "";
socialFacebook = org.social_links?.facebook ?? "";
socialDiscord = org.social_links?.discord ?? "";
socialLinkedin = org.social_links?.linkedin ?? "";
socialX = org.social_links?.x ?? "";
socialYoutube = org.social_links?.youtube ?? "";
socialTiktok = org.social_links?.tiktok ?? "";
socialFienta = org.social_links?.fienta ?? "";
socialTwitch = org.social_links?.twitch ?? "";
featureChat = org.feature_chat ?? true;
featureSponsors = org.feature_sponsors ?? true;
featureContacts = org.feature_contacts ?? true;
featureBudget = org.feature_budget ?? true;
});
async function handleAvatarUpload(e: Event) {
@@ -38,13 +174,12 @@
const file = input.files?.[0];
if (!file) return;
// Validate file
if (!file.type.startsWith("image/")) {
toasts.error("Please select an image file.");
toasts.error(m.toast_error_select_image());
return;
}
if (file.size > 2 * 1024 * 1024) {
toasts.error("Image must be under 2MB.");
toasts.error(m.toast_error_image_too_large());
return;
}
@@ -58,7 +193,7 @@
.upload(path, file, { upsert: true });
if (uploadError) {
toasts.error("Failed to upload avatar.");
toasts.error(m.toast_error_upload_avatar());
return;
}
@@ -74,15 +209,15 @@
.eq("id", org.id);
if (dbError) {
toasts.error("Failed to save avatar URL.");
toasts.error(m.toast_error_save_avatar_url());
return;
}
avatarUrl = publicUrl;
await invalidateAll();
toasts.success("Avatar updated.");
toasts.success(m.toast_success_avatar_updated());
} catch (err) {
toasts.error("Avatar upload failed.");
toasts.error(m.toast_error_avatar_upload());
} finally {
isUploading = false;
input.value = "";
@@ -97,11 +232,11 @@
.eq("id", org.id);
if (error) {
toasts.error("Failed to remove avatar.");
toasts.error(m.toast_error_remove_avatar());
} else {
avatarUrl = null;
await invalidateAll();
toasts.success("Avatar removed.");
toasts.success(m.toast_success_avatar_removed());
}
isSaving = false;
}
@@ -110,23 +245,131 @@
isSaving = true;
const { error } = await supabase
.from("organizations")
.update({ name: orgName, slug: orgSlug })
.update({
name: orgName,
slug: orgSlug,
description: orgDescription.trim(),
theme_color: themeColor,
})
.eq("id", org.id);
if (error) {
toasts.error("Failed to save settings.");
toasts.error(m.toast_error_save_settings());
} else if (orgSlug !== org.slug) {
window.location.href = `/${orgSlug}/settings`;
} else {
toasts.success("Settings saved.");
await invalidateAll();
toasts.success(m.toast_success_settings_saved());
}
isSaving = false;
}
async function savePreferences() {
isSavingPrefs = true;
const { error } = await supabase
.from("organizations")
.update({
currency,
date_format: dateFormat,
timezone,
week_start_day: weekStartDay,
default_calendar_view: defaultCalendarView,
})
.eq("id", org.id);
if (error) {
toasts.error(m.toast_error_save_preferences());
} else {
await invalidateAll();
toasts.success(m.toast_success_preferences_saved());
}
isSavingPrefs = false;
}
async function saveEventDefaults() {
isSavingDefaults = true;
const { error } = await supabase
.from("organizations")
.update({
default_event_color: defaultEventColor,
default_event_status: defaultEventStatus,
default_dept_modules: defaultDeptModules,
default_dept_layout: defaultDeptLayout,
})
.eq("id", org.id);
if (error) {
toasts.error(m.toast_error_save_event_defaults());
} else {
await invalidateAll();
toasts.success(m.toast_success_event_defaults_saved());
}
isSavingDefaults = false;
}
async function saveFeatureToggles() {
isSavingFeatures = true;
const { error } = await supabase
.from("organizations")
.update({
feature_chat: featureChat,
feature_sponsors: featureSponsors,
feature_contacts: featureContacts,
feature_budget: featureBudget,
})
.eq("id", org.id);
if (error) {
toasts.error(m.toast_error_save_features());
} else {
await invalidateAll();
toasts.success(m.toast_success_features_saved());
}
isSavingFeatures = false;
}
async function saveSocialLinks() {
isSavingSocial = true;
const links: Record<string, string> = {};
if (socialWebsite.trim()) links.website = socialWebsite.trim();
if (socialInstagram.trim()) links.instagram = socialInstagram.trim();
if (socialFacebook.trim()) links.facebook = socialFacebook.trim();
if (socialDiscord.trim()) links.discord = socialDiscord.trim();
if (socialLinkedin.trim()) links.linkedin = socialLinkedin.trim();
if (socialX.trim()) links.x = socialX.trim();
if (socialYoutube.trim()) links.youtube = socialYoutube.trim();
if (socialTiktok.trim()) links.tiktok = socialTiktok.trim();
if (socialFienta.trim()) links.fienta = socialFienta.trim();
if (socialTwitch.trim()) links.twitch = socialTwitch.trim();
const { error } = await (supabase as any)
.from("organizations")
.update({ social_links: links })
.eq("id", org.id);
if (error) {
toasts.error(m.toast_error_save_social());
} else {
await invalidateAll();
toasts.success(m.toast_success_social_saved());
}
isSavingSocial = false;
}
function toggleModule(mod: string) {
if (defaultDeptModules.includes(mod)) {
defaultDeptModules = defaultDeptModules.filter((m) => m !== mod);
} else {
defaultDeptModules = [...defaultDeptModules, mod];
}
}
</script>
<div class="flex flex-col gap-6 max-w-2xl">
<!-- Organization Details -->
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 flex flex-col gap-5">
<div
class="bg-dark/30 border border-light/5 rounded-xl p-5 flex flex-col gap-5"
>
<h2 class="font-heading text-body text-white">Organization details</h2>
<!-- Avatar Upload -->
@@ -152,7 +395,7 @@
</Button>
{#if avatarUrl}
<Button
variant="tertiary"
variant="ghost"
size="sm"
onclick={removeAvatar}
>
@@ -172,6 +415,29 @@
bind:value={orgSlug}
placeholder="my-org"
/>
<Textarea
variant="compact"
label="Description"
bind:value={orgDescription}
placeholder="What does your organization do?"
rows={2}
resize="none"
/>
<!-- Theme Color (hidden for now) -->
<!-- <div class="flex flex-col gap-2">
<span class="font-body text-body-sm text-light/60">Theme color</span>
<div class="flex items-center gap-3">
<label
class="w-8 h-8 rounded-lg cursor-pointer overflow-hidden border border-light/10"
style="background-color: {themeColor}"
>
<input type="color" bind:value={themeColor} class="opacity-0 w-0 h-0" />
</label>
<span class="text-[12px] text-light/40 font-mono">{themeColor}</span>
</div>
</div> -->
<div>
<Button size="sm" onclick={saveGeneralSettings} loading={isSaving}
>Save Changes</Button
@@ -179,9 +445,412 @@
</div>
</div>
<!-- Social & Links -->
<div
class="bg-dark/30 border border-light/5 rounded-xl p-5 flex flex-col gap-5"
>
<div>
<h2 class="font-heading text-body text-white">
{m.settings_social_title()}
</h2>
<p class="text-[11px] text-light/40 mt-0.5">
{m.settings_social_desc()}
</p>
</div>
<Input
variant="compact"
type="url"
label={m.settings_social_website()}
bind:value={socialWebsite}
placeholder={m.settings_social_website_placeholder()}
icon="language"
/>
<div class="grid grid-cols-2 gap-4">
<Input
variant="compact"
type="url"
label={m.settings_social_instagram()}
bind:value={socialInstagram}
placeholder={m.settings_social_instagram_placeholder()}
/>
<Input
variant="compact"
type="url"
label={m.settings_social_facebook()}
bind:value={socialFacebook}
placeholder={m.settings_social_facebook_placeholder()}
/>
</div>
<div class="grid grid-cols-2 gap-4">
<Input
variant="compact"
type="url"
label={m.settings_social_discord()}
bind:value={socialDiscord}
placeholder={m.settings_social_discord_placeholder()}
/>
<Input
variant="compact"
type="url"
label={m.settings_social_linkedin()}
bind:value={socialLinkedin}
placeholder={m.settings_social_linkedin_placeholder()}
/>
</div>
<div class="grid grid-cols-2 gap-4">
<Input
variant="compact"
type="url"
label={m.settings_social_x()}
bind:value={socialX}
placeholder={m.settings_social_x_placeholder()}
/>
<Input
variant="compact"
type="url"
label={m.settings_social_youtube()}
bind:value={socialYoutube}
placeholder={m.settings_social_youtube_placeholder()}
/>
</div>
<div class="grid grid-cols-2 gap-4">
<Input
variant="compact"
type="url"
label={m.settings_social_tiktok()}
bind:value={socialTiktok}
placeholder={m.settings_social_tiktok_placeholder()}
/>
<Input
variant="compact"
type="url"
label={m.settings_social_fienta()}
bind:value={socialFienta}
placeholder={m.settings_social_fienta_placeholder()}
/>
</div>
<Input
variant="compact"
type="url"
label={m.settings_social_twitch()}
bind:value={socialTwitch}
placeholder={m.settings_social_twitch_placeholder()}
/>
<div>
<Button size="sm" onclick={saveSocialLinks} loading={isSavingSocial}
>{m.settings_social_save()}</Button
>
</div>
</div>
<!-- Preferences -->
<div
class="bg-dark/30 border border-light/5 rounded-xl p-5 flex flex-col gap-5"
>
<div>
<h2 class="font-heading text-body text-white">Preferences</h2>
<p class="text-[11px] text-light/40 mt-0.5">
Currency, date format, timezone, and calendar defaults.
</p>
</div>
<!-- Currency -->
<Select
variant="compact"
label="Currency"
bind:value={currency}
placeholder=""
options={SUPPORTED_CURRENCIES.map((c) => ({
value: c.code,
label: c.label,
}))}
hint={`Preview: ${currencyPreview}`}
/>
<!-- Date Format -->
<Select
variant="compact"
label="Date format"
bind:value={dateFormat}
placeholder=""
options={DATE_FORMAT_OPTIONS}
/>
<!-- Timezone -->
<Select
variant="compact"
label="Timezone"
bind:value={timezone}
placeholder=""
groups={TIMEZONE_OPTIONS.map((g) => ({
label: g.group,
options: g.zones.map((z) => ({
value: z,
label: z.replace(/_/g, " "),
})),
}))}
/>
<div class="grid grid-cols-2 gap-4">
<Select
variant="compact"
label="Week starts on"
bind:value={weekStartDay}
placeholder=""
options={WEEK_START_OPTIONS}
/>
<Select
variant="compact"
label="Default calendar view"
bind:value={defaultCalendarView}
placeholder=""
options={CALENDAR_VIEW_OPTIONS}
/>
</div>
<div>
<Button size="sm" onclick={savePreferences} loading={isSavingPrefs}
>Save Preferences</Button
>
</div>
</div>
<!-- Event Defaults -->
<div
class="bg-dark/30 border border-light/5 rounded-xl p-5 flex flex-col gap-5"
>
<div>
<h2 class="font-heading text-body text-white">Event defaults</h2>
<p class="text-[11px] text-light/40 mt-0.5">
Defaults applied when creating new events and departments.
</p>
</div>
<!-- Default Event Color -->
<div class="flex flex-col gap-2">
<span class="font-body text-body-sm text-light/60"
>Default event color</span
>
<div class="flex flex-wrap gap-2">
{#each EVENT_COLOR_PRESETS as color}
<button
type="button"
class="w-7 h-7 rounded-lg border-2 transition-all {defaultEventColor ===
color
? 'border-white scale-110'
: 'border-transparent hover:border-light/30'}"
style="background-color: {color}"
onclick={() => (defaultEventColor = color)}
aria-label="Color {color}"
></button>
{/each}
<label
class="w-7 h-7 rounded-lg border-2 border-dashed border-light/20 hover:border-light/40 transition-all cursor-pointer flex items-center justify-center overflow-hidden"
title="Custom color"
>
<input
type="color"
class="opacity-0 absolute w-0 h-0"
bind:value={defaultEventColor}
/>
<span
class="material-symbols-rounded text-light/30"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>colorize</span
>
</label>
</div>
</div>
<!-- Default Event Status -->
<Select
variant="compact"
label="Default event status"
bind:value={defaultEventStatus}
placeholder=""
options={EVENT_STATUS_OPTIONS}
/>
<!-- Default Department Layout -->
<Select
variant="compact"
label="Default department layout"
bind:value={defaultDeptLayout}
placeholder=""
options={DASHBOARD_LAYOUT_OPTIONS}
/>
<!-- Default Department Modules -->
<div class="flex flex-col gap-2">
<span class="font-body text-body-sm text-light/60"
>Default department modules</span
>
<p class="text-[11px] text-light/30">
Modules auto-added when a new department is created.
</p>
<div class="grid grid-cols-2 gap-2">
{#each DEPT_MODULE_OPTIONS as mod}
<button
type="button"
class="flex items-center gap-2.5 px-3 py-2.5 rounded-xl border transition-all text-left {defaultDeptModules.includes(
mod.value,
)
? 'bg-primary/10 border-primary/30 text-white'
: 'bg-dark/30 border-light/5 text-light/40 hover:border-light/10'}"
onclick={() => toggleModule(mod.value)}
>
<span
class="material-symbols-rounded {defaultDeptModules.includes(
mod.value,
)
? 'text-primary'
: 'text-light/30'}"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>{mod.icon}</span
>
<span class="text-body-sm font-body">{mod.label}</span>
</button>
{/each}
</div>
</div>
<div>
<Button
size="sm"
onclick={saveEventDefaults}
loading={isSavingDefaults}>Save Event Defaults</Button
>
</div>
</div>
<!-- Feature Toggles -->
<div
class="bg-dark/30 border border-light/5 rounded-xl p-5 flex flex-col gap-5"
>
<div>
<h2 class="font-heading text-body text-white">Features</h2>
<p class="text-[11px] text-light/40 mt-0.5">
Enable or disable features for this organization.
</p>
</div>
<div class="flex flex-col gap-3">
<!-- Chat toggle disabled until fully developed -->
<!-- <label
class="flex items-center justify-between px-3 py-3 rounded-xl bg-dark/30 border border-light/5 cursor-pointer hover:border-light/10 transition-colors"
>
<div class="flex items-center gap-3">
<span
class="material-symbols-rounded text-purple-400"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>chat</span
>
<div>
<p class="text-body-sm text-white">Chat</p>
<p class="text-[11px] text-light/30">
Real-time messaging via Matrix
</p>
</div>
</div>
<input
type="checkbox"
bind:checked={featureChat}
class="w-4 h-4 rounded accent-primary"
/>
</label> -->
<label
class="flex items-center justify-between px-3 py-3 rounded-xl bg-dark/30 border border-light/5 cursor-pointer hover:border-light/10 transition-colors"
>
<div class="flex items-center gap-3">
<span
class="material-symbols-rounded text-emerald-400"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>account_balance</span
>
<div>
<p class="text-body-sm text-white">Budget & Finances</p>
<p class="text-[11px] text-light/30">
Income/expense tracking, planned vs actual budgets
</p>
</div>
</div>
<input
type="checkbox"
bind:checked={featureBudget}
class="w-4 h-4 rounded accent-primary"
/>
</label>
<label
class="flex items-center justify-between px-3 py-3 rounded-xl bg-dark/30 border border-light/5 cursor-pointer hover:border-light/10 transition-colors"
>
<div class="flex items-center gap-3">
<span
class="material-symbols-rounded text-indigo-400"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>handshake</span
>
<div>
<p class="text-body-sm text-white">Sponsors</p>
<p class="text-[11px] text-light/30">
Sponsor CRM with tiers, deliverables, and fund
tracking
</p>
</div>
</div>
<input
type="checkbox"
bind:checked={featureSponsors}
class="w-4 h-4 rounded accent-primary"
/>
</label>
<label
class="flex items-center justify-between px-3 py-3 rounded-xl bg-dark/30 border border-light/5 cursor-pointer hover:border-light/10 transition-colors"
>
<div class="flex items-center gap-3">
<span
class="material-symbols-rounded text-blue-400"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>contacts</span
>
<div>
<p class="text-body-sm text-white">Contacts</p>
<p class="text-[11px] text-light/30">
Vendor and contact directory per department
</p>
</div>
</div>
<input
type="checkbox"
bind:checked={featureContacts}
class="w-4 h-4 rounded accent-primary"
/>
</label>
</div>
<div>
<Button
size="sm"
onclick={saveFeatureToggles}
loading={isSavingFeatures}>Save Features</Button
>
</div>
</div>
<!-- Danger Zone -->
{#if isOwner}
<div class="bg-dark/30 border border-error/10 rounded-xl p-5 flex flex-col gap-3">
<div
class="bg-dark/30 border border-error/10 rounded-xl p-5 flex flex-col gap-3"
>
<h4 class="font-heading text-body-sm text-error">Danger Zone</h4>
<p class="font-body text-[11px] text-light/40">
Permanently delete this organization and all its data.
@@ -196,10 +865,15 @@
<!-- Leave Organization (non-owners) -->
{#if !isOwner}
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 flex flex-col gap-3">
<h4 class="font-heading text-body-sm text-white">Leave Organization</h4>
<div
class="bg-dark/30 border border-light/5 rounded-xl p-5 flex flex-col gap-3"
>
<h4 class="font-heading text-body-sm text-white">
Leave Organization
</h4>
<p class="font-body text-[11px] text-light/40">
Leave this organization. You will need to be re-invited to rejoin.
Leave this organization. You will need to be re-invited to
rejoin.
</p>
<div>
<Button variant="secondary" size="sm" onclick={onLeave}

View File

@@ -43,6 +43,7 @@
setTimeout(() => (emailCopied = false), 2000);
}
// svelte-ignore state_referenced_locally
let showConnectModal = $state(initialShowConnect);
let isLoading = $state(false);
let calendarUrlInput = $state("");
@@ -95,7 +96,7 @@
}
async function disconnectOrgCalendar() {
if (!confirm("Disconnect Google Calendar?")) return;
if (!confirm(m.settings_confirm_disconnect_cal())) return;
const { error } = await supabase
.from("org_google_calendars")
.delete()
@@ -112,51 +113,107 @@
<!-- Google Calendar -->
<div class="bg-dark/30 border border-light/5 rounded-xl p-5">
<div class="flex items-start gap-4">
<div class="w-10 h-10 bg-white rounded-xl flex items-center justify-center shrink-0">
<div
class="w-10 h-10 bg-white rounded-xl flex items-center justify-center shrink-0"
>
<svg class="w-6 h-6" viewBox="0 0 24 24">
<path fill="#4285F4" 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="#34A853" 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="#FBBC05" 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="#EA4335" 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"/>
<path
fill="#4285F4"
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="#34A853"
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="#FBBC05"
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="#EA4335"
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>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-heading text-body-sm text-white">Google Calendar</h3>
<h3 class="font-heading text-body-sm text-white">
Google Calendar
</h3>
<p class="text-[11px] text-light/40 mt-0.5">
Sync events between your organization and Google Calendar.
</p>
{#if orgCalendar}
<div class="mt-3 p-3 bg-green-500/10 border border-green-500/20 rounded-lg">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div
class="mt-3 p-3 bg-green-500/10 border border-green-500/20 rounded-lg"
>
<div
class="flex flex-col sm:flex-row sm:items-center justify-between gap-3"
>
<div class="min-w-0 flex-1">
<p class="text-[11px] font-medium text-green-400">Connected</p>
<p class="text-body-sm text-white">{orgCalendar.calendar_name || "Google Calendar"}</p>
<p class="text-[10px] text-light/40 truncate" title={orgCalendar.calendar_id}>{orgCalendar.calendar_id}</p>
<p class="text-[10px] text-light/30 mt-1">Events sync both ways.</p>
<p
class="text-[11px] font-medium text-green-400"
>
Connected
</p>
<p class="text-body-sm text-white">
{orgCalendar.calendar_name ||
"Google Calendar"}
</p>
<p
class="text-[10px] text-light/40 truncate"
title={orgCalendar.calendar_id}
>
{orgCalendar.calendar_id}
</p>
<p class="text-[10px] text-light/30 mt-1">
Events sync both ways.
</p>
<a
href="https://calendar.google.com/calendar/u/0/r?cid={encodeURIComponent(orgCalendar.calendar_id)}"
href="https://calendar.google.com/calendar/u/0/r?cid={encodeURIComponent(
orgCalendar.calendar_id,
)}"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-[11px] text-blue-400 hover:text-blue-300 mt-1.5"
>
<span class="material-symbols-rounded" style="font-size: 14px;">open_in_new</span>
<span
class="material-symbols-rounded"
style="font-size: 14px;"
>open_in_new</span
>
Open in Google Calendar
</a>
</div>
<Button variant="danger" size="sm" onclick={disconnectOrgCalendar}>Disconnect</Button>
<Button
variant="danger"
size="sm"
onclick={disconnectOrgCalendar}
>Disconnect</Button
>
</div>
</div>
{:else if !serviceAccountEmail}
<div class="mt-3 p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
<p class="text-[11px] text-yellow-400 font-medium">Setup required</p>
<div
class="mt-3 p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg"
>
<p class="text-[11px] text-yellow-400 font-medium">
Setup required
</p>
<p class="text-[10px] text-light/40 mt-1">
A server administrator needs to configure the <code class="bg-light/10 px-1 rounded">GOOGLE_SERVICE_ACCOUNT_KEY</code> environment variable.
A server administrator needs to configure the <code
class="bg-light/10 px-1 rounded"
>GOOGLE_SERVICE_ACCOUNT_KEY</code
> environment variable.
</p>
</div>
{:else}
<div class="mt-3">
<Button size="sm" onclick={() => (showConnectModal = true)}>Connect Google Calendar</Button>
<Button
size="sm"
onclick={() => (showConnectModal = true)}
>Connect Google Calendar</Button
>
</div>
{/if}
</div>
@@ -166,12 +223,19 @@
<!-- Discord (coming soon) -->
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 opacity-40">
<div class="flex items-start gap-4">
<div class="w-10 h-10 bg-[#5865F2] rounded-xl flex items-center justify-center shrink-0">
<span class="material-symbols-rounded text-white" style="font-size: 22px;">forum</span>
<div
class="w-10 h-10 bg-[#5865F2] rounded-xl flex items-center justify-center shrink-0"
>
<span
class="material-symbols-rounded text-white"
style="font-size: 22px;">forum</span
>
</div>
<div class="flex-1">
<h3 class="font-heading text-body-sm text-white">Discord</h3>
<p class="text-[11px] text-light/40 mt-0.5">Get notifications in your Discord server.</p>
<p class="text-[11px] text-light/40 mt-0.5">
Get notifications in your Discord server.
</p>
<p class="text-[10px] text-light/30 mt-1">Coming soon</p>
</div>
</div>
@@ -180,12 +244,19 @@
<!-- Slack (coming soon) -->
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 opacity-40">
<div class="flex items-start gap-4">
<div class="w-10 h-10 bg-[#4A154B] rounded-xl flex items-center justify-center shrink-0">
<span class="material-symbols-rounded text-white" style="font-size: 22px;">tag</span>
<div
class="w-10 h-10 bg-[#4A154B] rounded-xl flex items-center justify-center shrink-0"
>
<span
class="material-symbols-rounded text-white"
style="font-size: 22px;">tag</span
>
</div>
<div class="flex-1">
<h3 class="font-heading text-body-sm text-white">Slack</h3>
<p class="text-[11px] text-light/40 mt-0.5">Get notifications in your Slack workspace.</p>
<p class="text-[11px] text-light/40 mt-0.5">
Get notifications in your Slack workspace.
</p>
<p class="text-[10px] text-light/30 mt-1">Coming soon</p>
</div>
</div>
@@ -227,7 +298,7 @@
</code>
<Button
size="sm"
variant="tertiary"
variant="ghost"
onclick={copyServiceEmail}
>
{emailCopied ? "Copied!" : "Copy"}
@@ -259,9 +330,8 @@
{/if}
<div class="flex justify-end gap-2 pt-2">
<Button
variant="tertiary"
onclick={() => (showConnectModal = false)}>Cancel</Button
<Button variant="ghost" onclick={() => (showConnectModal = false)}
>Cancel</Button
>
<Button
onclick={handleSaveOrgCalendar}

View File

@@ -1,11 +1,5 @@
<script lang="ts">
import {
Button,
Modal,
Input,
Select,
Avatar,
} from "$lib/components/ui";
import { Button, Modal, Input, Select, Avatar } from "$lib/components/ui";
import { toasts } from "$lib/stores/toast.svelte";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
@@ -53,6 +47,7 @@
interface Props {
supabase: SupabaseClient<Database>;
orgId: string;
orgName: string;
userId: string;
members: Member[];
roles: OrgRole[];
@@ -62,6 +57,7 @@
let {
supabase,
orgId,
orgName,
userId,
members = $bindable(),
roles,
@@ -75,6 +71,11 @@
let showMemberModal = $state(false);
let selectedMember = $state<Member | null>(null);
let selectedMemberRole = $state("");
let isTransferring = $state(false);
const currentUserRole = $derived(
members.find((m) => m.user_id === userId)?.role ?? "viewer",
);
async function sendInvite() {
if (!inviteEmail.trim()) return;
@@ -108,6 +109,25 @@
invites = [...invites, invite as Invite];
inviteEmail = "";
showInviteModal = false;
// Send invite email (fire-and-forget)
const inviteUrl = `${window.location.origin}/invite/${(invite as Invite).token}`;
fetch("/api/send-invite-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email,
orgName,
inviteUrl,
role: inviteRole,
}),
}).then((res) => {
if (res.ok) {
toasts.success(m.invite_email_sent({ email }));
} else if (res.status !== 501) {
toasts.error(m.toast_error_send_invite_email());
}
});
} else if (error) {
toasts.error(m.toast_error_invite({ error: error.message }));
}
@@ -166,6 +186,65 @@
members = members.filter((m) => m.id !== selectedMember!.id);
showMemberModal = false;
}
async function transferOwnership() {
if (!selectedMember) return;
const rp = selectedMember.profiles;
const prof = Array.isArray(rp) ? rp[0] : rp;
const targetName = prof?.full_name || prof?.email || "this member";
if (!confirm(m.settings_transfer_confirm({ name: targetName }))) return;
isTransferring = true;
// Demote current owner to admin
const currentOwner = members.find((m) => m.user_id === userId);
if (!currentOwner) {
isTransferring = false;
return;
}
const { error: demoteError } = await supabase
.from("org_members")
.update({ role: "admin" })
.eq("id", currentOwner.id);
if (demoteError) {
toasts.error(m.toast_error_transfer_ownership());
isTransferring = false;
return;
}
// Promote target to owner
const { error: promoteError } = await supabase
.from("org_members")
.update({ role: "owner" })
.eq("id", selectedMember.id);
if (promoteError) {
// Rollback: re-promote current user
await supabase
.from("org_members")
.update({ role: "owner" })
.eq("id", currentOwner.id);
toasts.error(m.toast_error_transfer_ownership());
isTransferring = false;
return;
}
// Update local state
members = members.map((mb) => {
if (mb.id === currentOwner.id) return { ...mb, role: "admin" };
if (mb.id === selectedMember!.id) return { ...mb, role: "owner" };
return mb;
});
isTransferring = false;
showMemberModal = false;
toasts.success(
m.toast_success_transfer_ownership({ name: targetName }),
);
}
</script>
<div class="space-y-4 max-w-2xl">
@@ -177,14 +256,18 @@
})}
</h2>
</div>
<Button size="sm" icon="person_add" onclick={() => (showInviteModal = true)}>
<Button
size="sm"
icon="person_add"
onclick={() => (showInviteModal = true)}
>
{m.settings_members_invite()}
</Button>
</div>
<!-- Pending Invites -->
{#if invites.length > 0}
<div class="bg-dark/30 border border-light/5 rounded-xl p-4">
<div class="bg-dark/30 border border-light/5 rounded-2xl p-4">
<h3 class="text-body-sm font-heading text-light/60 mb-3">
{m.settings_members_pending()}
</h3>
@@ -194,7 +277,9 @@
class="flex items-center justify-between py-2 px-3 bg-light/5 rounded-lg"
>
<div>
<p class="text-body-sm text-white">{invite.email}</p>
<p class="text-body-sm text-white">
{invite.email}
</p>
<p class="text-[11px] text-light/40">
Invited as {invite.role} • Expires {new Date(
invite.expires_at,
@@ -211,7 +296,11 @@
)}
title={m.settings_members_copy_link()}
>
<span class="material-symbols-rounded" style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;">content_copy</span>
<span
class="material-symbols-rounded"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>content_copy</span
>
</button>
<button
type="button"
@@ -219,7 +308,11 @@
onclick={() => cancelInvite(invite.id)}
title="Cancel invite"
>
<span class="material-symbols-rounded" style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;">close</span>
<span
class="material-symbols-rounded"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>close</span
>
</button>
</div>
</div>
@@ -229,7 +322,7 @@
{/if}
<!-- Members List -->
<div class="bg-dark/30 border border-light/5 rounded-xl overflow-hidden">
<div class="bg-dark/30 border border-light/5 rounded-2xl overflow-hidden">
<div class="divide-y divide-light/5">
{#each members as member}
{@const rawProfile = member.profiles}
@@ -272,7 +365,11 @@
onclick={() => openMemberModal(member)}
title="Edit"
>
<span class="material-symbols-rounded" style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;">edit</span>
<span
class="material-symbols-rounded"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>edit</span
>
</button>
{/if}
</div>
@@ -286,44 +383,54 @@
<Modal
isOpen={showInviteModal}
onClose={() => (showInviteModal = false)}
title="Invite Member"
title={m.settings_invite_title()}
>
<div class="space-y-4">
<div class="flex flex-col gap-4">
<Input
variant="compact"
type="email"
label="Email address"
label={m.settings_invite_email()}
bind:value={inviteEmail}
placeholder="colleague@example.com"
placeholder={m.settings_invite_email_placeholder()}
/>
<Select
label="Role"
variant="compact"
label={m.settings_invite_role()}
bind:value={inviteRole}
placeholder=""
options={[
{ value: "viewer", label: "Viewer - Can view content" },
{ value: "viewer", label: m.settings_invite_role_viewer() },
{
value: "commenter",
label: "Commenter - Can view and comment",
label: m.settings_invite_role_commenter(),
},
{
value: "editor",
label: "Editor - Can create and edit content",
label: m.settings_invite_role_editor(),
},
{
value: "admin",
label: "Admin - Can manage members and settings",
label: m.settings_invite_role_admin(),
},
]}
/>
<div class="flex justify-end gap-2 pt-2">
<Button variant="tertiary" onclick={() => (showInviteModal = false)}
>Cancel</Button
<div
class="flex items-center justify-end gap-3 pt-2 border-t border-light/5"
>
<button
type="button"
class="px-4 py-2 text-body-sm text-light/60 hover:text-white transition-colors"
onclick={() => (showInviteModal = false)}
>{m.btn_cancel()}</button
>
<Button
<button
type="button"
disabled={!inviteEmail.trim() || isSendingInvite}
class="px-4 py-2 bg-primary text-background rounded-xl font-body text-body-sm hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onclick={sendInvite}
loading={isSendingInvite}
disabled={!inviteEmail.trim()}>Send Invite</Button
>
{isSendingInvite ? "..." : m.settings_invite_send()}
</button>
</div>
</div>
</Modal>
@@ -332,51 +439,77 @@
<Modal
isOpen={showMemberModal}
onClose={() => (showMemberModal = false)}
title="Edit Member"
title={m.settings_edit_member()}
>
{#if selectedMember}
{@const rawP = selectedMember.profiles}
{@const memberProfile = Array.isArray(rawP) ? rawP[0] : rawP}
<div class="space-y-4">
<div class="flex items-center gap-3 p-3 bg-light/5 rounded-lg">
<div
class="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium"
>
{(memberProfile?.full_name ||
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3 p-3 bg-dark/50 rounded-xl">
<Avatar
name={memberProfile?.full_name ||
memberProfile?.email ||
"?")[0].toUpperCase()}
</div>
"?"}
src={memberProfile?.avatar_url}
size="sm"
/>
<div>
<p class="text-light font-medium">
{memberProfile?.full_name || "No name"}
<p class="text-body-sm text-white">
{memberProfile?.full_name ||
m.settings_members_no_name()}
</p>
<p class="text-sm text-light/50">
{memberProfile?.email || "No email"}
<p class="text-[11px] text-light/40">
{memberProfile?.email || m.settings_members_no_email()}
</p>
</div>
</div>
<Select
label="Role"
variant="compact"
label={m.settings_invite_role()}
bind:value={selectedMemberRole}
placeholder=""
options={[
{ value: "viewer", label: "Viewer" },
{ value: "commenter", label: "Commenter" },
{ value: "editor", label: "Editor" },
{ value: "admin", label: "Admin" },
{ value: "viewer", label: m.role_viewer() },
{ value: "commenter", label: m.role_commenter() },
{ value: "editor", label: m.role_editor() },
{ value: "admin", label: m.role_admin() },
]}
/>
<div class="flex items-center justify-between pt-2">
<Button variant="danger" onclick={removeMember}
>Remove from Org</Button
<div class="flex items-center justify-between">
<button
type="button"
class="text-[11px] text-error hover:underline"
onclick={removeMember}
>
<div class="flex gap-2">
<Button
variant="tertiary"
onclick={() => (showMemberModal = false)}>Cancel</Button
{m.settings_members_remove()}
</button>
{#if currentUserRole === "owner"}
<button
type="button"
class="text-[11px] text-warning hover:underline"
onclick={transferOwnership}
disabled={isTransferring}
>
<Button onclick={updateMemberRole}>Save</Button>
</div>
{isTransferring
? "..."
: m.settings_transfer_ownership()}
</button>
{/if}
</div>
<div
class="flex items-center justify-end gap-3 pt-2 border-t border-light/5"
>
<button
type="button"
class="px-4 py-2 text-body-sm text-light/60 hover:text-white transition-colors"
onclick={() => (showMemberModal = false)}
>{m.btn_cancel()}</button
>
<button
type="button"
class="px-4 py-2 bg-primary text-background rounded-xl font-body text-body-sm hover:bg-primary-hover transition-colors"
onclick={updateMemberRole}>{m.btn_save()}</button
>
</div>
</div>
{/if}

View File

@@ -81,14 +81,14 @@
];
const roleColors = [
{ value: "#ef4444", label: "Red" },
{ value: "#f59e0b", label: "Amber" },
{ value: "#10b981", label: "Emerald" },
{ value: "#3b82f6", label: "Blue" },
{ value: "#6366f1", label: "Indigo" },
{ value: "#8b5cf6", label: "Violet" },
{ value: "#ec4899", label: "Pink" },
{ value: "#6b7280", label: "Gray" },
{ value: "#ef4444", label: m.role_color_red() },
{ value: "#f59e0b", label: m.role_color_amber() },
{ value: "#10b981", label: m.role_color_emerald() },
{ value: "#3b82f6", label: m.role_color_blue() },
{ value: "#6366f1", label: m.role_color_indigo() },
{ value: "#8b5cf6", label: m.role_color_violet() },
{ value: "#ec4899", label: m.role_color_pink() },
{ value: "#6b7280", label: m.role_color_gray() },
];
function openRoleModal(role?: OrgRole) {
@@ -203,19 +203,29 @@
<div class="flex flex-col gap-2">
{#each roles as role}
<div class="bg-dark/30 border border-light/5 rounded-xl px-4 py-3 hover:border-light/10 transition-colors">
<div
class="bg-dark/30 border border-light/5 rounded-2xl px-4 py-3 hover:border-light/10 transition-colors"
>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<div
class="w-2.5 h-2.5 rounded-full shrink-0"
style="background-color: {role.color}"
></div>
<span class="text-body-sm font-medium text-white">{role.name}</span>
<span class="text-body-sm font-medium text-white"
>{role.name}</span
>
{#if role.is_system}
<span class="text-[10px] text-light/30 bg-light/5 px-1.5 py-0.5 rounded-md">System</span>
<span
class="text-[10px] text-light/30 bg-light/5 px-1.5 py-0.5 rounded-md"
>System</span
>
{/if}
{#if role.is_default}
<span class="text-[10px] text-primary bg-primary/10 px-1.5 py-0.5 rounded-md">Default</span>
<span
class="text-[10px] text-primary bg-primary/10 px-1.5 py-0.5 rounded-md"
>Default</span
>
{/if}
</div>
<div class="flex items-center gap-1.5">
@@ -226,7 +236,11 @@
onclick={() => openRoleModal(role)}
title="Edit"
>
<span class="material-symbols-rounded" style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;">edit</span>
<span
class="material-symbols-rounded"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>edit</span
>
</button>
{/if}
{#if !role.is_system}
@@ -236,20 +250,32 @@
onclick={() => deleteRole(role)}
title="Delete"
>
<span class="material-symbols-rounded" style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;">delete</span>
<span
class="material-symbols-rounded"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>delete</span
>
</button>
{/if}
</div>
</div>
<div class="flex flex-wrap gap-1">
{#if role.permissions.includes("*")}
<span class="text-[10px] bg-light/5 text-light/50 px-1.5 py-0.5 rounded-md">All Permissions</span>
<span
class="text-[10px] bg-light/5 text-light/50 px-1.5 py-0.5 rounded-md"
>All Permissions</span
>
{:else}
{#each role.permissions.slice(0, 6) as perm}
<span class="text-[10px] bg-light/5 text-light/40 px-1.5 py-0.5 rounded-md">{perm}</span>
<span
class="text-[10px] bg-light/5 text-light/40 px-1.5 py-0.5 rounded-md"
>{perm}</span
>
{/each}
{#if role.permissions.length > 6}
<span class="text-[10px] text-light/30">+{role.permissions.length - 6} more</span>
<span class="text-[10px] text-light/30"
>+{role.permissions.length - 6} more</span
>
{/if}
{/if}
</div>
@@ -264,25 +290,30 @@
onClose={() => (showRoleModal = false)}
title={editingRole ? "Edit Role" : "Create Role"}
>
<div class="space-y-4">
<Input
label="Name"
bind:value={newRoleName}
placeholder="e.g., Moderator"
disabled={editingRole?.is_system}
/>
<div>
<label class="block text-sm font-medium text-light mb-2"
>Color</label
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1.5">
<label for="role-name" class="text-body-sm text-light/60 font-body"
>Name</label
>
<div class="flex gap-2">
<input
id="role-name"
type="text"
bind:value={newRoleName}
placeholder="e.g., Moderator"
disabled={editingRole?.is_system}
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary disabled:opacity-50"
/>
</div>
<div class="flex flex-col gap-1.5">
<span class="text-body-sm text-light/60 font-body">Color</span>
<div class="flex items-center gap-2">
{#each roleColors as color}
<button
type="button"
class="w-8 h-8 rounded-full transition-transform {newRoleColor ===
class="w-6 h-6 rounded-full border-2 transition-all {newRoleColor ===
color.value
? 'ring-2 ring-white scale-110'
: ''}"
? 'border-white scale-110'
: 'border-transparent hover:border-light/30'}"
style="background-color: {color.value}"
onclick={() => (newRoleColor = color.value)}
title={color.label}
@@ -290,20 +321,19 @@
{/each}
</div>
</div>
<div>
<label class="block text-sm font-medium text-light mb-2"
>Permissions</label
<div class="flex flex-col gap-1.5">
<span class="text-body-sm text-light/60 font-body">Permissions</span
>
<div class="space-y-3 max-h-64 overflow-y-auto">
<div class="flex flex-col gap-2 max-h-64 overflow-y-auto">
{#each permissionGroups as group}
<div class="p-3 bg-light/5 rounded-lg">
<p class="text-sm font-medium text-light mb-2">
<div class="p-3 bg-dark/50 rounded-xl">
<p class="text-body-sm font-body text-white mb-2">
{group.name}
</p>
<div class="grid grid-cols-2 gap-2">
{#each group.permissions as perm}
<label
class="flex items-center gap-2 text-sm text-light/70 cursor-pointer"
class="flex items-center gap-2 text-[12px] text-light/50 cursor-pointer hover:text-white transition-colors"
>
<input
type="checkbox"
@@ -311,9 +341,11 @@
perm,
)}
onchange={() => togglePermission(perm)}
class="rounded"
class="rounded accent-primary"
/>
{perm.split(".")[1]}
<span class="capitalize"
>{perm.split(".")[1]}</span
>
</label>
{/each}
</div>
@@ -321,16 +353,26 @@
{/each}
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<Button variant="tertiary" onclick={() => (showRoleModal = false)}
>Cancel</Button
<div
class="flex items-center justify-end gap-3 pt-2 border-t border-light/5"
>
<button
type="button"
class="px-4 py-2 text-body-sm text-light/60 hover:text-white transition-colors"
onclick={() => (showRoleModal = false)}>{m.btn_cancel()}</button
>
<Button
<button
type="button"
disabled={!newRoleName.trim() || isSavingRole}
class="px-4 py-2 bg-primary text-background rounded-xl font-body text-body-sm hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onclick={saveRole}
loading={isSavingRole}
disabled={!newRoleName.trim()}
>{editingRole ? "Save" : "Create"}</Button
>
{isSavingRole
? "..."
: editingRole
? m.btn_save()
: m.btn_create()}
</button>
</div>
</div>
</Modal>

View File

@@ -2,3 +2,4 @@ export { default as SettingsGeneral } from './SettingsGeneral.svelte';
export { default as SettingsMembers } from './SettingsMembers.svelte';
export { default as SettingsRoles } from './SettingsRoles.svelte';
export { default as SettingsIntegrations } from './SettingsIntegrations.svelte';
export { default as SettingsActivityLog } from './SettingsActivityLog.svelte';

View File

@@ -77,7 +77,7 @@
const userName =
entry.profiles?.full_name || entry.profiles?.email || "Someone";
const entityType = getEntityTypeLabel(entry.entity_type);
const name = entry.entity_name ?? "";
const name = entry.entity_name ?? "-";
const map: Record<string, () => string> = {
create: () =>
@@ -95,9 +95,7 @@
</script>
{#if entries.length === 0}
<div
class="flex flex-col items-center justify-center text-light/40 py-8"
>
<div class="flex flex-col items-center justify-center text-light/40 py-8">
<span
class="material-symbols-rounded mb-2"
style="font-size: 40px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 40;"

View File

@@ -22,6 +22,8 @@
let { value, members, label, onchange }: Props = $props();
let isOpen = $state(false);
let triggerEl = $state<HTMLButtonElement | null>(null);
let dropdownStyle = $state("");
function getAssignee(id: string | null) {
if (!id) return null;
@@ -30,6 +32,14 @@
const assignee = $derived(getAssignee(value));
function toggle() {
if (!isOpen && triggerEl) {
const rect = triggerEl.getBoundingClientRect();
dropdownStyle = `top: ${rect.bottom + 8}px; left: ${rect.left}px; width: ${rect.width}px;`;
}
isOpen = !isOpen;
}
function select(userId: string | null) {
onchange(userId);
isOpen = false;
@@ -43,66 +53,67 @@
</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>
<button
type="button"
bind:this={triggerEl}
class="w-full p-3 bg-background text-white rounded-[32px]
font-medium font-input text-body
focus:outline-none focus:ring-4 focus:ring-primary
transition-colors text-left flex items-center gap-2 overflow-hidden"
onclick={toggle}
>
{#if assignee}
<Avatar
name={assignee.profiles.full_name || assignee.profiles.email}
src={assignee.profiles.avatar_url}
size="sm"
/>
<span class="truncate">
{assignee.profiles.full_name || assignee.profiles.email}
</span>
{:else}
<Avatar name="?" size="sm" />
<span class="text-white/40 truncate">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"
{#if isOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="fixed inset-0 z-[60]"
onclick={() => (isOpen = false)}
></div>
<div
class="fixed z-[70] bg-night border border-light/10 rounded-2xl shadow-xl max-h-48 overflow-y-auto py-1"
style={dropdownStyle}
>
<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 text-white/60 hover:bg-dark transition-colors flex items-center gap-3"
onclick={() => select(null)}
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="?" size="sm" />
Unassigned
<Avatar
name={member.profiles.full_name ||
member.profiles.email}
src={member.profiles.avatar_url}
size="sm"
/>
<span class="truncate">
{member.profiles.full_name || member.profiles.email}
</span>
</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>
{/each}
</div>
{/if}
</div>

View File

@@ -8,6 +8,14 @@
let { name, src = null, size = "md", status = null }: Props = $props();
let imgFailed = $state(false);
// Reset imgFailed when src changes
$effect(() => {
if (src) imgFailed = false;
});
const showImg = $derived(src && !imgFailed);
const initial = $derived(name ? name[0].toUpperCase() : "?");
const sizes = {
@@ -35,11 +43,12 @@
</script>
<div class="relative inline-block shrink-0">
{#if src}
{#if showImg}
<img
{src}
alt={name}
class="{sizes[size].box} {sizes[size].radius} object-cover shrink-0"
onerror={() => (imgFailed = true)}
/>
{:else}
<div

View File

@@ -0,0 +1,63 @@
<script lang="ts">
interface BreadcrumbItem {
label: string;
href?: string;
icon: string;
}
interface Props {
items: BreadcrumbItem[];
onToggleView?: () => void;
viewMode?: 'grid' | 'list';
}
let { items, onToggleView, viewMode = 'grid' }: Props = $props();
</script>
<div class="flex flex-wrap items-center justify-between gap-y-2 w-full">
<div class="flex items-center">
{#each items as item, i}
{#if i > 0}
<span class="flex items-center p-1 shrink-0">
<span
class="material-symbols-rounded text-white"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>chevron_forward</span>
</span>
{/if}
{#if item.href}
<a href={item.href} class="flex items-center gap-2 shrink-0 hover:opacity-80 transition-opacity">
<span class="flex items-center justify-center p-1">
<span
class="material-symbols-rounded text-white"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>{item.icon}</span>
</span>
<span class="font-heading text-h3 text-white whitespace-nowrap">{item.label}</span>
</a>
{:else}
<div class="flex items-center gap-2 shrink-0">
<span class="flex items-center justify-center p-1">
<span
class="material-symbols-rounded text-white"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>{item.icon}</span>
</span>
<span class="font-heading text-h3 text-white whitespace-nowrap">{item.label}</span>
</div>
{/if}
{/each}
</div>
{#if onToggleView}
<button
type="button"
class="flex items-center justify-center p-1 shrink-0 hover:bg-white/5 rounded-lg transition-colors"
onclick={onToggleView}
>
<span
class="material-symbols-rounded text-white"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>{viewMode === 'grid' ? 'list' : 'grid_view'}</span>
</button>
{/if}
</div>

View File

@@ -2,7 +2,13 @@
import type { Snippet } from "svelte";
interface Props {
variant?: "primary" | "secondary" | "tertiary" | "danger" | "success";
variant?:
| "primary"
| "secondary"
| "outline"
| "danger"
| "success"
| "ghost";
size?: "sm" | "md" | "lg";
disabled?: boolean;
loading?: boolean;
@@ -28,36 +34,26 @@
}: Props = $props();
const baseClasses =
"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";
"inline-flex items-center justify-center gap-2 font-bold font-body rounded-[32px] overflow-clip transition-all cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed";
const variantClasses = {
primary:
"btn-primary bg-primary text-night hover:btn-primary-hover active:btn-primary-active",
secondary:
"bg-transparent text-primary border-solid border-primary hover:bg-primary/10 active:bg-primary/20",
tertiary:
"bg-primary/10 text-primary hover:bg-primary/20 active:bg-primary/30",
danger: "btn-primary bg-error text-white hover:btn-primary-hover active:btn-primary-active",
"bg-primary text-night hover:brightness-110 active:brightness-90",
secondary: "bg-night text-white hover:bg-surface active:bg-dark",
outline:
"bg-transparent text-primary border-2 border-primary hover:bg-primary/10 active:bg-primary/20",
danger: "bg-error text-white hover:brightness-110 active:brightness-90",
success:
"btn-primary bg-success text-night hover:btn-primary-hover active:btn-primary-active",
"bg-success text-night hover:brightness-110 active:brightness-90",
ghost: "bg-transparent text-white hover:bg-white/5 active:bg-white/10",
};
const sizeClasses = {
sm: "min-w-[36px] p-[10px] text-btn-sm",
md: "min-w-[48px] p-[12px] text-btn-md",
lg: "min-w-[56px] p-[16px] text-btn-lg",
sm: "min-w-[36px] px-[12px] py-[8px] text-btn-sm",
md: "min-w-[48px] px-[14px] py-[10px] text-btn-md",
lg: "min-w-[56px] px-[16px] py-[12px] 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>
@@ -65,7 +61,7 @@
{type}
class="{baseClasses} {variantClasses[variant]} {sizeClasses[
size
]} {secondaryBorder} {className ?? ''}"
]} {className ?? ''}"
class:w-full={fullWidth}
disabled={disabled || loading}
{onclick}
@@ -89,30 +85,3 @@
{@render children()}
{/if}
</button>
<style>
.btn-primary:hover:not(:disabled) {
background-image: linear-gradient(
rgba(255, 255, 255, 0.2),
rgba(255, 255, 255, 0.2)
);
}
.btn-primary-hover:not(:disabled) {
background-image: linear-gradient(
rgba(255, 255, 255, 0.2),
rgba(255, 255, 255, 0.2)
);
}
.btn-primary:active:not(:disabled) {
background-image: linear-gradient(
rgba(14, 15, 25, 0.2),
rgba(14, 15, 25, 0.2)
);
}
.btn-primary-active:not(:disabled) {
background-image: linear-gradient(
rgba(14, 15, 25, 0.2),
rgba(14, 15, 25, 0.2)
);
}
</style>

View File

@@ -1,42 +0,0 @@
<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>

View File

@@ -9,45 +9,40 @@
interface Props {
onSelect: (emoji: string) => void;
onClose: () => void;
position?: { x: number; y: number };
position?: { x: number; y: number } | null;
}
let { onSelect, onClose, position = { x: 0, y: 0 } }: Props = $props();
let { onSelect, onClose, position = null }: Props = $props();
let searchQuery = $state("");
let activeCategory = $state("frequent");
let pickerRef: HTMLDivElement | null = $state(null);
let adjustedPosition = $state({ x: 0, y: 0 });
// Initialize position on first render
const isInline = $derived(!position);
// Adjust position to stay within viewport (only for fixed mode)
$effect(() => {
adjustedPosition = { x: position.x, y: position.y };
});
if (!position || !pickerRef) return;
// Adjust position to stay within viewport
$effect(() => {
if (pickerRef) {
const rect = pickerRef.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const rect = pickerRef.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let newX = position.x;
let newY = position.y;
let newX = position.x;
let newY = position.y;
// Adjust horizontal position
if (newX + rect.width > viewportWidth - 10) {
newX = viewportWidth - rect.width - 10;
}
if (newX < 10) newX = 10;
// Adjust vertical position
if (newY + rect.height > viewportHeight - 10) {
newY = position.y - rect.height - 40; // Position above the button
}
if (newY < 10) newY = 10;
adjustedPosition = { x: newX, y: newY };
if (newX + rect.width > viewportWidth - 10) {
newX = viewportWidth - rect.width - 10;
}
if (newX < 10) newX = 10;
if (newY + rect.height > viewportHeight - 10) {
newY = position.y - rect.height - 40;
}
if (newY < 10) newY = 10;
adjustedPosition = { x: newX, y: newY };
});
// Emoji categories
@@ -100,8 +95,12 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={pickerRef}
class="fixed bg-dark border border-light/20 rounded-xl shadow-2xl z-[100] w-[352px] overflow-hidden"
style="left: {adjustedPosition.x}px; top: {adjustedPosition.y}px;"
class="{isInline
? ''
: 'fixed'} bg-dark border border-light/20 rounded-xl shadow-2xl z-[100] w-[352px] overflow-hidden"
style={isInline
? ""
: `left: ${adjustedPosition.x}px; top: ${adjustedPosition.y}px;`}
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"

View File

@@ -54,14 +54,12 @@
{#if startDate}
<span class="text-[11px] text-light/40"
>{formatDate(startDate)}{endDate
? ` ${formatDate(endDate)}`
? ` - ${formatDate(endDate)}`
: ""}</span
>
{/if}
{#if venueName}
<span class="text-[11px] text-light/30"
>· {venueName}</span
>
<span class="text-[11px] text-light/30">· {venueName}</span>
{/if}
</div>
</div>
@@ -97,7 +95,7 @@
>calendar_today</span
>
{formatDate(startDate)}{endDate
? ` ${formatDate(endDate)}`
? ` - ${formatDate(endDate)}`
: ""}
</span>
{/if}

View File

@@ -23,6 +23,7 @@
<svelte:window onkeydown={handleKeyDown} />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="fixed inset-0 z-[200] flex items-center justify-center bg-black/90 backdrop-blur-sm"
onclick={handleBackdropClick}
@@ -31,6 +32,7 @@
<button
class="absolute top-4 right-4 w-10 h-10 flex items-center justify-center rounded-full bg-light/10 hover:bg-light/20 transition-colors text-light"
onclick={onClose}
aria-label="Close preview"
>
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12" />

View File

@@ -9,16 +9,22 @@
| "number"
| "tel"
| "date"
| "time"
| "datetime-local";
value?: string;
placeholder?: string;
label?: string;
description?: string;
error?: string;
hint?: string;
disabled?: boolean;
required?: boolean;
autocomplete?: AutoFill;
icon?: string;
id?: string;
name?: string;
variant?: "default" | "compact";
class?: string;
oninput?: (e: Event) => void;
onchange?: (e: Event) => void;
onkeydown?: (e: KeyboardEvent) => void;
@@ -29,31 +35,47 @@
value = $bindable(""),
placeholder = "",
label,
description,
error,
hint,
disabled = false,
required = false,
autocomplete,
icon,
id,
name,
variant = "default",
class: className,
oninput,
onchange,
onkeydown,
}: Props = $props();
const isCompact = $derived(variant === "compact");
let showPassword = $state(false);
const inputId = `input-${crypto.randomUUID().slice(0, 8)}`;
const autoId = `input-${crypto.randomUUID().slice(0, 8)}`;
const inputId = $derived(id ?? autoId);
const isPassword = $derived(type === "password");
const inputType = $derived(isPassword && showPassword ? "text" : type);
</script>
<div class="flex flex-col gap-3 w-full">
{#if label}
<label
for={inputId}
class="px-3 font-bold font-body text-body text-white"
>
{#if required}<span class="text-error">* </span>{/if}{label}
</label>
<div class="flex flex-col gap-2 {isCompact ? '' : 'px-3'}">
<label
for={inputId}
class={isCompact
? "font-body text-body-sm text-light/60"
: "font-bold font-body text-body text-white"}
>
{#if required}<span class="text-error">*&nbsp;</span
>{/if}{label}
</label>
{#if description}
<p class="text-body-sm text-white/50">{description}</p>
{/if}
</div>
{/if}
<div class="flex items-center gap-3 w-full">
@@ -79,16 +101,18 @@
{disabled}
{required}
{autocomplete}
{name}
{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
w-full {isCompact
? 'px-3 py-2 bg-dark rounded-xl text-body-sm'
: 'px-3 py-2 bg-night rounded-[20px] font-medium font-input text-body'}
text-white placeholder:text-white/50 border-none
focus:outline-none focus:ring-4 focus:ring-primary
disabled:opacity-30 disabled:cursor-not-allowed
transition-colors
transition-colors {className ?? ''}
"
class:ring-1={error}
class:ring-error={error}
@@ -97,7 +121,7 @@
{#if isPassword}
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-colors"
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-full text-white/60 hover:text-white transition-colors"
onclick={() => (showPassword = !showPassword)}
aria-label={showPassword
? "Hide password"
@@ -105,7 +129,7 @@
>
<span
class="material-symbols-rounded"
style="font-size: 22px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
{showPassword ? "visibility_off" : "visibility"}
</span>
@@ -115,8 +139,8 @@
</div>
{#if error}
<p class="text-sm text-error px-3">{error}</p>
<p class="text-sm text-error {isCompact ? '' : 'px-3'}">{error}</p>
{:else if hint}
<p class="text-sm text-white/50 px-3">{hint}</p>
<p class="text-sm text-white/50 {isCompact ? '' : 'px-3'}">{hint}</p>
{/if}
</div>

View File

@@ -19,10 +19,13 @@
<!-- Header -->
<div class="flex items-center gap-2 px-3 py-2.5 border-b border-light/5">
<div class="flex-1 flex items-center gap-2 min-w-0">
<span class="font-heading text-body-sm text-white truncate">{title}</span>
<span class="font-heading text-body-sm text-white truncate"
>{title}</span
>
<span
class="text-[11px] text-light/40 bg-light/5 px-1.5 py-0.5 rounded-md shrink-0"
>{count}</span>
>{count}</span
>
</div>
{#if onMore}
<button
@@ -41,9 +44,7 @@
</div>
<!-- Cards container -->
<div
class="flex-1 flex flex-col gap-1.5 p-2 overflow-y-auto min-h-0"
>
<div class="flex-1 flex flex-col gap-1.5 p-2 overflow-y-auto min-h-0">
{#if children}
{@render children()}
{/if}
@@ -52,7 +53,13 @@
<!-- Add button -->
{#if onAddCard}
<div class="px-2 pb-2">
<Button variant="tertiary" fullWidth size="sm" icon="add" onclick={onAddCard}>
<Button
variant="ghost"
fullWidth
size="sm"
icon="add"
onclick={onAddCard}
>
Add card
</Button>
</div>

View File

@@ -0,0 +1,73 @@
<script lang="ts">
interface Props {
label: string;
type: "folder" | "document" | "kanban";
href?: string;
onclick?: () => void;
ondblclick?: () => void;
onauxclick?: (e: MouseEvent) => void;
selected?: boolean;
}
let {
label,
type,
href,
onclick,
ondblclick,
onauxclick,
selected = false,
}: Props = $props();
const iconMap = {
folder: "folder",
document: "description",
kanban: "view_kanban",
};
</script>
{#if href}
<a
{href}
class="flex flex-col gap-2 items-center justify-center w-[240px] min-w-[240px] h-[155px] min-h-[155px] p-4 rounded-[32px] overflow-clip transition-all cursor-pointer
{selected ? 'bg-surface ring-4 ring-primary' : 'hover:bg-surface/50'}"
{onclick}
{ondblclick}
{onauxclick}
>
<span class="flex items-center justify-center p-3">
<span
class="material-symbols-rounded text-white"
style="font-size: 72px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
>{iconMap[type]}</span
>
</span>
<p
class="font-body text-body text-white text-center truncate w-full min-w-full"
>
{label}
</p>
</a>
{:else}
<button
type="button"
class="flex flex-col gap-2 items-center justify-center w-[240px] min-w-[240px] h-[155px] min-h-[155px] p-4 rounded-[32px] overflow-clip transition-all cursor-pointer
{selected ? 'bg-surface ring-4 ring-primary' : 'hover:bg-surface/50'}"
{onclick}
{ondblclick}
{onauxclick}
>
<span class="flex items-center justify-center p-3">
<span
class="material-symbols-rounded text-white"
style="font-size: 72px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
>{iconMap[type]}</span
>
</span>
<p
class="font-body text-body text-white text-center truncate w-full min-w-full"
>
{label}
</p>
</button>
{/if}

Some files were not shown because too many files have changed in this diff Show More