Compare commits
31 Commits
556955f349
...
ui-redesig
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3a25f56a7 | ||
|
|
2a28d88849 | ||
|
|
7ab206fe96 | ||
|
|
c2d3caaa5a | ||
|
|
046d4bd098 | ||
|
|
bfeb33906e | ||
|
|
71bf7b9057 | ||
|
|
9885f9459d | ||
|
|
a4baa1ad25 | ||
|
|
38a0c2274d | ||
|
|
9cb047c8b6 | ||
|
|
4ee2c0ac07 | ||
|
|
302fc69218 | ||
|
|
ce80dc6d75 | ||
|
|
f2384bceb8 | ||
|
|
75a2aefadb | ||
|
|
23693db9ec | ||
|
|
d22847f555 | ||
|
|
dcee479839 | ||
|
|
f9dc950394 | ||
|
|
202f0fe9a1 | ||
|
|
d304129e5c | ||
|
|
676468d3ec | ||
|
|
1f2484da3d | ||
|
|
edc5f8af85 | ||
|
|
4999836a57 | ||
|
|
9d5e58f858 | ||
|
|
819d5b876a | ||
|
|
2913912cb8 | ||
|
|
fe6ec6e0af | ||
|
|
36496e8cdb |
@@ -11,6 +11,7 @@ README.md
|
||||
node_modules
|
||||
build
|
||||
**/.env
|
||||
**/.env.*
|
||||
**/.env.local
|
||||
**/.env.*.local
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
18
.env.example
18
.env.example
@@ -1,9 +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 (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
2
.gitignore
vendored
@@ -29,7 +29,7 @@ Desktop.ini
|
||||
|
||||
# IDE / Editors
|
||||
.idea
|
||||
.vscode/*
|
||||
.vscode
|
||||
!.vscode/extensions.json
|
||||
!.vscode/settings.json
|
||||
*.swp
|
||||
|
||||
742
AUDIT.md
742
AUDIT.md
@@ -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 v1–v4 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
|
||||
38
Dockerfile
38
Dockerfile
@@ -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
|
||||
|
||||
59
README.md
59
README.md
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
997
docs/TECHNICAL_DESIGN.md
Normal 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
|
||||
818
messages/en.json
818
messages/en.json
@@ -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",
|
||||
@@ -175,6 +247,14 @@
|
||||
"account_display_name": "Display Name",
|
||||
"account_display_name_placeholder": "Your name",
|
||||
"account_email": "Email",
|
||||
"account_phone": "Phone",
|
||||
"account_phone_placeholder": "+372 ...",
|
||||
"account_discord": "Discord",
|
||||
"account_discord_placeholder": "username",
|
||||
"account_contact_info": "Contact & Sizing",
|
||||
"account_shirt_size": "Shirt Size",
|
||||
"account_hoodie_size": "Hoodie Size",
|
||||
"account_size_placeholder": "Select size",
|
||||
"account_save_profile": "Save Profile",
|
||||
"account_appearance": "Appearance",
|
||||
"account_theme": "Theme",
|
||||
@@ -251,5 +331,729 @@
|
||||
"entity_kanban_column": "column",
|
||||
"entity_member": "member",
|
||||
"entity_role": "role",
|
||||
"entity_invite": "invite"
|
||||
"entity_invite": "invite",
|
||||
"entity_event": "event",
|
||||
"nav_events": "Events",
|
||||
"nav_chat": "Chat",
|
||||
"chat_title": "Chat",
|
||||
"chat_subtitle": "Team messaging and communication",
|
||||
"events_title": "Events",
|
||||
"events_subtitle": "Organize and manage your events",
|
||||
"events_new": "New Event",
|
||||
"events_create": "Create Event",
|
||||
"events_empty_title": "No events yet",
|
||||
"events_empty_desc": "Create your first event to get started",
|
||||
"events_no_dates": "No dates set",
|
||||
"events_tab_all": "All Events",
|
||||
"events_tab_planning": "Planning",
|
||||
"events_tab_active": "Active",
|
||||
"events_tab_completed": "Completed",
|
||||
"events_tab_archived": "Archived",
|
||||
"events_status_planning": "Planning",
|
||||
"events_status_active": "Active",
|
||||
"events_status_completed": "Completed",
|
||||
"events_status_archived": "Archived",
|
||||
"events_form_name": "Event Name",
|
||||
"events_form_name_placeholder": "e.g., Summer Conference 2026",
|
||||
"events_form_description": "Description",
|
||||
"events_form_description_placeholder": "Brief description of the event...",
|
||||
"events_form_start_date": "Start Date",
|
||||
"events_form_end_date": "End Date",
|
||||
"events_form_venue": "Venue",
|
||||
"events_form_venue_placeholder": "e.g., Convention Center",
|
||||
"events_form_venue_address_placeholder": "Venue address",
|
||||
"events_form_color": "Color",
|
||||
"events_form_select_color": "Select color {color}",
|
||||
"events_creating": "Creating...",
|
||||
"events_saving": "Saving...",
|
||||
"events_deleting": "Deleting...",
|
||||
"events_updated": "Event updated",
|
||||
"events_created": "Event \"{name}\" created",
|
||||
"events_deleted": "Event deleted",
|
||||
"events_delete_title": "Delete Event?",
|
||||
"events_delete_desc": "This will permanently delete {name} and all its data. This action cannot be undone.",
|
||||
"events_delete_confirm": "Delete Event",
|
||||
"events_days_ago": "{count} days ago",
|
||||
"events_today": "Today!",
|
||||
"events_tomorrow": "Tomorrow",
|
||||
"events_in_days": "In {count} days",
|
||||
"events_overview": "Overview",
|
||||
"events_modules": "Modules",
|
||||
"events_details": "Event Details",
|
||||
"events_start_date": "Start Date",
|
||||
"events_end_date": "End Date",
|
||||
"events_venue": "Venue",
|
||||
"events_not_set": "Not set",
|
||||
"events_all_events": "All Events",
|
||||
"events_team": "Team",
|
||||
"events_team_count": "Team ({count})",
|
||||
"events_team_manage": "Manage",
|
||||
"events_team_empty": "No team members assigned yet",
|
||||
"events_more_members": "+{count} more",
|
||||
"events_mod_tasks": "Tasks",
|
||||
"events_mod_tasks_desc": "Manage tasks, milestones, and progress",
|
||||
"events_mod_files": "Files",
|
||||
"events_mod_files_desc": "Documents, contracts, and media",
|
||||
"events_mod_schedule": "Schedule",
|
||||
"events_mod_schedule_desc": "Event timeline and program",
|
||||
"events_mod_budget": "Budget",
|
||||
"events_mod_budget_desc": "Income, expenses, and tracking",
|
||||
"events_mod_guests": "Guests",
|
||||
"events_mod_guests_desc": "Guest list and registration",
|
||||
"events_mod_team": "Team",
|
||||
"events_mod_team_desc": "Team members and shift scheduling",
|
||||
"events_mod_sponsors": "Sponsors",
|
||||
"events_mod_sponsors_desc": "Sponsors, partners, and deliverables",
|
||||
"module_coming_soon": "Coming Soon",
|
||||
"module_coming_soon_desc": "This module is under development and will be available soon.",
|
||||
"team_title": "Event Team",
|
||||
"team_subtitle": "Manage team members and their roles for this event.",
|
||||
"team_add_member": "Add Member",
|
||||
"team_role_lead": "Lead",
|
||||
"team_role_manager": "Manager",
|
||||
"team_role_member": "Member",
|
||||
"team_empty": "No team members assigned yet. Add members from your organization.",
|
||||
"team_remove_confirm": "Remove {name} from this event's team?",
|
||||
"team_remove_btn": "Remove",
|
||||
"team_added": "{name} added to team",
|
||||
"team_removed": "{name} removed from team",
|
||||
"team_updated": "Role updated",
|
||||
"team_select_member": "Select a member",
|
||||
"team_select_role": "Select role",
|
||||
"team_already_assigned": "Already on team",
|
||||
"team_departments": "Departments",
|
||||
"team_roles": "Roles",
|
||||
"team_all": "All",
|
||||
"team_no_department": "Unassigned",
|
||||
"team_add_department": "Add Department",
|
||||
"team_add_role": "Add Role",
|
||||
"team_edit_department": "Edit Department",
|
||||
"team_edit_role": "Edit Role",
|
||||
"team_dept_name": "Department name",
|
||||
"team_role_name": "Role name",
|
||||
"team_dept_created": "Department created",
|
||||
"team_dept_updated": "Department updated",
|
||||
"team_dept_deleted": "Department deleted",
|
||||
"team_role_created": "Role created",
|
||||
"team_role_updated": "Role updated",
|
||||
"team_role_deleted": "Role deleted",
|
||||
"team_dept_delete_confirm": "Delete department {name}? Members will be unassigned from it.",
|
||||
"team_role_delete_confirm": "Delete role {name}? Members will lose this role assignment.",
|
||||
"team_view_by_dept": "By department",
|
||||
"team_view_list": "List view",
|
||||
"team_member_count": "{count} members",
|
||||
"team_assign_dept": "Assign departments",
|
||||
"team_notes": "Notes",
|
||||
"team_notes_placeholder": "Optional notes about this member...",
|
||||
"overview_subtitle": "Welcome back. Here's what's happening.",
|
||||
"overview_stat_events": "Events",
|
||||
"overview_upcoming_events": "Upcoming Events",
|
||||
"overview_upcoming_empty": "No upcoming events. Create one to get started.",
|
||||
"overview_view_all_events": "View all events",
|
||||
"overview_more_members": "+{count} more",
|
||||
"chat_join_title": "Join Chat",
|
||||
"chat_join_description": "Chat is powered by Matrix, an open standard for secure, decentralized communication.",
|
||||
"chat_join_consent": "By joining, a Matrix account will be created for you using your current profile details (name, email, and avatar).",
|
||||
"chat_join_learn_more": "Learn more about Matrix",
|
||||
"chat_join_button": "Join Chat",
|
||||
"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",
|
||||
"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."
|
||||
}
|
||||
816
messages/et.json
816
messages/et.json
@@ -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",
|
||||
@@ -175,6 +246,14 @@
|
||||
"account_display_name": "Kuvatav nimi",
|
||||
"account_display_name_placeholder": "Sinu nimi",
|
||||
"account_email": "E-post",
|
||||
"account_phone": "Telefon",
|
||||
"account_phone_placeholder": "+372 ...",
|
||||
"account_discord": "Discord",
|
||||
"account_discord_placeholder": "kasutajanimi",
|
||||
"account_contact_info": "Kontakt ja suurused",
|
||||
"account_shirt_size": "Särgi suurus",
|
||||
"account_hoodie_size": "Pusa suurus",
|
||||
"account_size_placeholder": "Vali suurus",
|
||||
"account_save_profile": "Salvesta profiil",
|
||||
"account_appearance": "Välimus",
|
||||
"account_theme": "Teema",
|
||||
@@ -251,5 +330,730 @@
|
||||
"entity_kanban_column": "veeru",
|
||||
"entity_member": "liikme",
|
||||
"entity_role": "rolli",
|
||||
"entity_invite": "kutse"
|
||||
"entity_invite": "kutse",
|
||||
"entity_event": "ürituse",
|
||||
"nav_events": "Üritused",
|
||||
"nav_chat": "Vestlus",
|
||||
"chat_title": "Vestlus",
|
||||
"chat_subtitle": "Meeskonna sõnumid ja suhtlus",
|
||||
"events_title": "Üritused",
|
||||
"events_subtitle": "Korralda ja halda oma üritusi",
|
||||
"events_new": "Uus üritus",
|
||||
"events_create": "Loo üritus",
|
||||
"events_empty_title": "Üritusi pole veel",
|
||||
"events_empty_desc": "Loo oma esimene üritus alustamiseks",
|
||||
"events_no_dates": "Kuupäevad määramata",
|
||||
"events_tab_all": "Kõik üritused",
|
||||
"events_tab_planning": "Planeerimisel",
|
||||
"events_tab_active": "Aktiivne",
|
||||
"events_tab_completed": "Lõpetatud",
|
||||
"events_tab_archived": "Arhiveeritud",
|
||||
"events_status_planning": "Planeerimisel",
|
||||
"events_status_active": "Aktiivne",
|
||||
"events_status_completed": "Lõpetatud",
|
||||
"events_status_archived": "Arhiveeritud",
|
||||
"events_form_name": "Ürituse nimi",
|
||||
"events_form_name_placeholder": "nt Suvekonverents 2026",
|
||||
"events_form_description": "Kirjeldus",
|
||||
"events_form_description_placeholder": "Ürituse lühikirjeldus...",
|
||||
"events_form_start_date": "Alguskuupäev",
|
||||
"events_form_end_date": "Lõppkuupäev",
|
||||
"events_form_venue": "Toimumiskoht",
|
||||
"events_form_venue_placeholder": "nt Konverentsikeskus",
|
||||
"events_form_venue_address_placeholder": "Toimumiskoha aadress",
|
||||
"events_form_color": "Värv",
|
||||
"events_form_select_color": "Vali värv {color}",
|
||||
"events_creating": "Loomine...",
|
||||
"events_saving": "Salvestamine...",
|
||||
"events_deleting": "Kustutamine...",
|
||||
"events_updated": "Üritus uuendatud",
|
||||
"events_created": "Üritus \"{name}\" loodud",
|
||||
"events_deleted": "Üritus kustutatud",
|
||||
"events_delete_title": "Kustuta üritus?",
|
||||
"events_delete_desc": "See kustutab jäädavalt ürituse {name} ja kõik selle andmed. Seda toimingut ei saa tagasi võtta.",
|
||||
"events_delete_confirm": "Kustuta üritus",
|
||||
"events_days_ago": "{count} päeva tagasi",
|
||||
"events_today": "Täna!",
|
||||
"events_tomorrow": "Homme",
|
||||
"events_in_days": "{count} päeva pärast",
|
||||
"events_overview": "Ülevaade",
|
||||
"events_modules": "Moodulid",
|
||||
"events_details": "Ürituse andmed",
|
||||
"events_start_date": "Alguskuupäev",
|
||||
"events_end_date": "Lõppkuupäev",
|
||||
"events_venue": "Toimumiskoht",
|
||||
"events_not_set": "Määramata",
|
||||
"events_all_events": "Kõik üritused",
|
||||
"events_team": "Meeskond",
|
||||
"events_team_count": "Meeskond ({count})",
|
||||
"events_team_manage": "Halda",
|
||||
"events_team_empty": "Meeskonnaliikmeid pole veel määratud",
|
||||
"events_more_members": "+{count} veel",
|
||||
"events_mod_tasks": "Ülesanded",
|
||||
"events_mod_tasks_desc": "Halda ülesandeid, verstaposte ja edenemist",
|
||||
"events_mod_files": "Failid",
|
||||
"events_mod_files_desc": "Dokumendid, lepingud ja meedia",
|
||||
"events_mod_schedule": "Ajakava",
|
||||
"events_mod_schedule_desc": "Ürituse ajakava ja programm",
|
||||
"events_mod_budget": "Eelarve",
|
||||
"events_mod_budget_desc": "Tulud, kulud ja jälgimine",
|
||||
"events_mod_guests": "Külalised",
|
||||
"events_mod_guests_desc": "Külaliste nimekiri ja registreerimine",
|
||||
"events_mod_team": "Meeskond",
|
||||
"events_mod_team_desc": "Meeskonnaliikmed ja vahetuste planeerimine",
|
||||
"events_mod_sponsors": "Sponsorid",
|
||||
"events_mod_sponsors_desc": "Sponsorid, partnerid ja kohustused",
|
||||
"module_coming_soon": "Tulekul",
|
||||
"module_coming_soon_desc": "See moodul on arendamisel ja saab peagi kättesaadavaks.",
|
||||
"team_title": "Ürituse meeskond",
|
||||
"team_subtitle": "Halda meeskonnaliikmeid ja nende rolle selle ürituse jaoks.",
|
||||
"team_add_member": "Lisa liige",
|
||||
"team_role_lead": "Juht",
|
||||
"team_role_manager": "Haldur",
|
||||
"team_role_member": "Liige",
|
||||
"team_empty": "Meeskonnaliikmeid pole veel määratud. Lisa liikmeid oma organisatsioonist.",
|
||||
"team_remove_confirm": "Eemalda {name} selle ürituse meeskonnast?",
|
||||
"team_remove_btn": "Eemalda",
|
||||
"team_added": "{name} lisatud meeskonda",
|
||||
"team_removed": "{name} eemaldatud meeskonnast",
|
||||
"team_updated": "Roll uuendatud",
|
||||
"team_select_member": "Vali liige",
|
||||
"team_select_role": "Vali roll",
|
||||
"team_already_assigned": "Juba meeskonnas",
|
||||
"team_departments": "Valdkonnad",
|
||||
"team_roles": "Rollid",
|
||||
"team_all": "Kõik",
|
||||
"team_no_department": "Määramata",
|
||||
"team_add_department": "Lisa valdkond",
|
||||
"team_add_role": "Lisa roll",
|
||||
"team_edit_department": "Muuda valdkonda",
|
||||
"team_edit_role": "Muuda rolli",
|
||||
"team_dept_name": "Valdkonna nimi",
|
||||
"team_role_name": "Rolli nimi",
|
||||
"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 valdkond {name}? Liikmed eemaldatakse sellest.",
|
||||
"team_role_delete_confirm": "Kustuta roll {name}? Liikmed kaotavad selle rolli.",
|
||||
"team_view_by_dept": "Valdkondade järgi",
|
||||
"team_view_list": "Nimekirja vaade",
|
||||
"team_member_count": "{count} liiget",
|
||||
"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.",
|
||||
"overview_stat_events": "Üritused",
|
||||
"overview_upcoming_events": "Tulevased üritused",
|
||||
"overview_upcoming_empty": "Tulevasi üritusi pole. Loo üks alustamiseks.",
|
||||
"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_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",
|
||||
"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."
|
||||
}
|
||||
430
package-lock.json
generated
430
package-lock.json
generated
@@ -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,10 +32,13 @@
|
||||
"@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",
|
||||
"svelte-check": "^4.3.5",
|
||||
"tailwindcss": "^4.1.18",
|
||||
@@ -44,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",
|
||||
@@ -53,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",
|
||||
@@ -558,6 +622,19 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/fs-minipass": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"minipass": "^7.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -2140,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",
|
||||
@@ -2216,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",
|
||||
@@ -2258,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",
|
||||
@@ -2463,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",
|
||||
@@ -2513,6 +2661,23 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/bin-links": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bin-links/-/bin-links-6.0.0.tgz",
|
||||
"integrity": "sha512-X4CiKlcV2GjnCMwnKAfbVWpHa++65th9TuzAEYtZoATiOE2DQKhSp4CJlyLoTqdhBKlXjpXjCTYPNNFS33Fi6w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cmd-shim": "^8.0.0",
|
||||
"npm-normalize-package-bin": "^5.0.0",
|
||||
"proc-log": "^6.0.0",
|
||||
"read-cmd-shim": "^6.0.0",
|
||||
"write-file-atomic": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
@@ -2563,6 +2728,16 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -2572,6 +2747,16 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cmd-shim": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-8.0.0.tgz",
|
||||
"integrity": "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -2758,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",
|
||||
@@ -3140,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",
|
||||
@@ -3162,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",
|
||||
@@ -3194,6 +3402,16 @@
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/imurmurhash": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
||||
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/is-core-module": {
|
||||
"version": "2.16.1",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
||||
@@ -3254,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",
|
||||
@@ -3286,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",
|
||||
@@ -3371,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",
|
||||
@@ -3681,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",
|
||||
@@ -3791,6 +4089,19 @@
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/minizlib": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
|
||||
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/mri": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||
@@ -3874,6 +4185,16 @@
|
||||
"url": "https://opencollective.com/node-fetch"
|
||||
}
|
||||
},
|
||||
"node_modules/npm-normalize-package-bin": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-5.0.0.tgz",
|
||||
"integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
@@ -4082,6 +4403,16 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/proc-log": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz",
|
||||
"integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-changeset": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz",
|
||||
@@ -4289,6 +4620,16 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cmd-shim": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-6.0.0.tgz",
|
||||
"integrity": "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
@@ -4433,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",
|
||||
@@ -4627,6 +4981,39 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/supabase": {
|
||||
"version": "2.76.1",
|
||||
"resolved": "https://registry.npmjs.org/supabase/-/supabase-2.76.1.tgz",
|
||||
"integrity": "sha512-wWN7trvmcFfI/T4Jr1t4eKSD3JUCMsisssDAoywwMP7HlF4lVrAxQyROah3uBRV05RuEFhO74mFlYr4+koGb0Q==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bin-links": "^6.0.0",
|
||||
"https-proxy-agent": "^7.0.2",
|
||||
"node-fetch": "^3.3.2",
|
||||
"tar": "7.5.7"
|
||||
},
|
||||
"bin": {
|
||||
"supabase": "bin/supabase"
|
||||
},
|
||||
"engines": {
|
||||
"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",
|
||||
@@ -4722,6 +5109,23 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.7",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz",
|
||||
"integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
"chownr": "^3.0.0",
|
||||
"minipass": "^7.1.2",
|
||||
"minizlib": "^3.1.0",
|
||||
"yallist": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -5237,6 +5641,20 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/write-file-atomic": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.0.tgz",
|
||||
"integrity": "sha512-YnlPC6JqnZl6aO4uRc+dx5PHguiR9S6WeoLtpxNT9wIG+BDya7ZNE1q7KOjVgaA73hKhKLpVPgJ5QA9THQ5BRg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"imurmurhash": "^0.1.4",
|
||||
"signal-exit": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
@@ -5258,6 +5676,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/zimmerframe": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||
|
||||
11
package.json
11
package.json
@@ -11,7 +11,10 @@
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test:unit": "vitest",
|
||||
"test": "npm run test:unit -- --run"
|
||||
"test": "npm run test:unit -- --run",
|
||||
"db:push": "npx supabase db push",
|
||||
"db:types": "npx supabase gen types --lang=typescript --project-id zlworzrghsrokdkuckez --schema public > src/lib/supabase/types.ts",
|
||||
"db:migrate": "npm run db:push && npm run db:types"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@inlang/paraglide-js": "^2.10.0",
|
||||
@@ -22,10 +25,13 @@
|
||||
"@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",
|
||||
"svelte-check": "^4.3.5",
|
||||
"tailwindcss": "^4.1.18",
|
||||
@@ -35,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",
|
||||
@@ -43,8 +48,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"
|
||||
|
||||
19
src/app.html
19
src/app.html
@@ -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>
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
|
||||
63
src/lib/api/activity.test.ts
Normal file
63
src/lib/api/activity.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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
190
src/lib/api/budget.test.ts
Normal 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
222
src/lib/api/budget.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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)', () => {
|
||||
|
||||
131
src/lib/api/contacts.test.ts
Normal file
131
src/lib/api/contacts.test.ts
Normal 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
147
src/lib/api/contacts.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
311
src/lib/api/department-dashboard.test.ts
Normal file
311
src/lib/api/department-dashboard.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
354
src/lib/api/department-dashboard.ts
Normal file
354
src/lib/api/department-dashboard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
146
src/lib/api/document-locks.test.ts
Normal file
146
src/lib/api/document-locks.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
206
src/lib/api/event-tasks.test.ts
Normal file
206
src/lib/api/event-tasks.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
266
src/lib/api/event-tasks.ts
Normal file
266
src/lib/api/event-tasks.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
import type { Database, EventTaskColumn, EventTask } from '$lib/supabase/types';
|
||||
import { createLogger } from '$lib/utils/logger';
|
||||
|
||||
const log = createLogger('api.event-tasks');
|
||||
|
||||
export interface TaskColumnWithTasks extends EventTaskColumn {
|
||||
cards: EventTask[];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Columns
|
||||
// ============================================================
|
||||
|
||||
export async function fetchTaskColumns(
|
||||
supabase: SupabaseClient<Database>,
|
||||
eventId: string
|
||||
): Promise<TaskColumnWithTasks[]> {
|
||||
const { data: columns, error: colErr } = await supabase
|
||||
.from('event_task_columns')
|
||||
.select('*')
|
||||
.eq('event_id', eventId)
|
||||
.order('position');
|
||||
|
||||
if (colErr) {
|
||||
log.error('Failed to fetch task columns', { error: colErr, data: { eventId } });
|
||||
throw colErr;
|
||||
}
|
||||
|
||||
const { data: tasks, error: taskErr } = await supabase
|
||||
.from('event_tasks')
|
||||
.select('*')
|
||||
.eq('event_id', eventId)
|
||||
.order('position');
|
||||
|
||||
if (taskErr) {
|
||||
log.error('Failed to fetch tasks', { error: taskErr, data: { eventId } });
|
||||
throw taskErr;
|
||||
}
|
||||
|
||||
const tasksByColumn = new Map<string, EventTask[]>();
|
||||
for (const task of tasks ?? []) {
|
||||
const arr = tasksByColumn.get(task.column_id) ?? [];
|
||||
arr.push(task);
|
||||
tasksByColumn.set(task.column_id, arr);
|
||||
}
|
||||
|
||||
return (columns ?? []).map((col) => ({
|
||||
...col,
|
||||
cards: tasksByColumn.get(col.id) ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createTaskColumn(
|
||||
supabase: SupabaseClient<Database>,
|
||||
eventId: string,
|
||||
name: string,
|
||||
position?: number
|
||||
): Promise<EventTaskColumn> {
|
||||
if (position === undefined) {
|
||||
const { count } = await supabase
|
||||
.from('event_task_columns')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('event_id', eventId);
|
||||
position = count ?? 0;
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('event_task_columns')
|
||||
.insert({ event_id: eventId, name, position })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
log.error('Failed to create task column', { error, data: { eventId, name } });
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function renameTaskColumn(
|
||||
supabase: SupabaseClient<Database>,
|
||||
columnId: string,
|
||||
name: string
|
||||
): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('event_task_columns')
|
||||
.update({ name })
|
||||
.eq('id', columnId);
|
||||
|
||||
if (error) {
|
||||
log.error('Failed to rename task column', { error, data: { columnId, name } });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTaskColumn(
|
||||
supabase: SupabaseClient<Database>,
|
||||
columnId: string
|
||||
): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('event_task_columns')
|
||||
.delete()
|
||||
.eq('id', columnId);
|
||||
|
||||
if (error) {
|
||||
log.error('Failed to delete task column', { error, data: { columnId } });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Tasks
|
||||
// ============================================================
|
||||
|
||||
export async function createTask(
|
||||
supabase: SupabaseClient<Database>,
|
||||
eventId: string,
|
||||
columnId: string,
|
||||
title: string,
|
||||
createdBy?: string
|
||||
): Promise<EventTask> {
|
||||
const { count } = await supabase
|
||||
.from('event_tasks')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('column_id', columnId);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('event_tasks')
|
||||
.insert({
|
||||
event_id: eventId,
|
||||
column_id: columnId,
|
||||
title,
|
||||
position: count ?? 0,
|
||||
created_by: createdBy ?? null,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
log.error('Failed to create task', { error, data: { eventId, columnId, title } });
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateTask(
|
||||
supabase: SupabaseClient<Database>,
|
||||
taskId: string,
|
||||
updates: Partial<Pick<EventTask, 'title' | 'description' | 'priority' | 'due_date' | 'color' | 'assignee_id'>>
|
||||
): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('event_tasks')
|
||||
.update({ ...updates, updated_at: new Date().toISOString() })
|
||||
.eq('id', taskId);
|
||||
|
||||
if (error) {
|
||||
log.error('Failed to update task', { error, data: { taskId, updates } });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTask(
|
||||
supabase: SupabaseClient<Database>,
|
||||
taskId: string
|
||||
): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('event_tasks')
|
||||
.delete()
|
||||
.eq('id', taskId);
|
||||
|
||||
if (error) {
|
||||
log.error('Failed to delete task', { error, data: { taskId } });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function moveTask(
|
||||
supabase: SupabaseClient<Database>,
|
||||
taskId: string,
|
||||
newColumnId: string,
|
||||
newPosition: number
|
||||
): Promise<void> {
|
||||
// Fetch all tasks in the target column
|
||||
const { data: colTasks, error: fetchErr } = await supabase
|
||||
.from('event_tasks')
|
||||
.select('id, position')
|
||||
.eq('column_id', newColumnId)
|
||||
.order('position');
|
||||
|
||||
if (fetchErr) {
|
||||
log.error('Failed to fetch column tasks for reorder', { error: fetchErr });
|
||||
throw fetchErr;
|
||||
}
|
||||
|
||||
// Build the new order
|
||||
const existing = (colTasks ?? []).filter((t) => t.id !== taskId);
|
||||
existing.splice(newPosition, 0, { id: taskId, position: newPosition });
|
||||
|
||||
// Update positions + column for changed tasks
|
||||
const updates = existing
|
||||
.map((t, i) => ({ id: t.id, position: i, column_id: newColumnId }))
|
||||
.filter((t, i) => {
|
||||
const orig = colTasks?.find((c) => c.id === t.id);
|
||||
return !orig || orig.position !== i || t.id === taskId;
|
||||
});
|
||||
|
||||
if (updates.length > 0) {
|
||||
await Promise.all(
|
||||
updates.map((u) =>
|
||||
supabase
|
||||
.from('event_tasks')
|
||||
.update({ column_id: u.column_id, position: u.position, updated_at: new Date().toISOString() })
|
||||
.eq('id', u.id)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Realtime
|
||||
// ============================================================
|
||||
|
||||
export interface RealtimeChangePayload<T = Record<string, unknown>> {
|
||||
event: 'INSERT' | 'UPDATE' | 'DELETE';
|
||||
new: T;
|
||||
old: Partial<T>;
|
||||
}
|
||||
|
||||
export function subscribeToEventTasks(
|
||||
supabase: SupabaseClient<Database>,
|
||||
eventId: string,
|
||||
columnIds: string[],
|
||||
onColumnChange: (payload: RealtimeChangePayload<EventTaskColumn>) => void,
|
||||
onTaskChange: (payload: RealtimeChangePayload<EventTask>) => void
|
||||
) {
|
||||
const channel = supabase.channel(`event-tasks:${eventId}`);
|
||||
const columnIdSet = new Set(columnIds);
|
||||
|
||||
channel
|
||||
.on('postgres_changes', { event: '*', schema: 'public', table: 'event_task_columns', filter: `event_id=eq.${eventId}` },
|
||||
(payload) => onColumnChange({
|
||||
event: payload.eventType as 'INSERT' | 'UPDATE' | 'DELETE',
|
||||
new: payload.new as EventTaskColumn,
|
||||
old: payload.old as Partial<EventTaskColumn>,
|
||||
})
|
||||
)
|
||||
.on('postgres_changes', { event: '*', schema: 'public', table: 'event_tasks', filter: `event_id=eq.${eventId}` },
|
||||
(payload) => {
|
||||
const task = (payload.new ?? payload.old) as Partial<EventTask>;
|
||||
const colId = task.column_id ?? (payload.old as Partial<EventTask>)?.column_id;
|
||||
if (colId && !columnIdSet.has(colId)) return;
|
||||
|
||||
onTaskChange({
|
||||
event: payload.eventType as 'INSERT' | 'UPDATE' | 'DELETE',
|
||||
new: payload.new as EventTask,
|
||||
old: payload.old as Partial<EventTask>,
|
||||
});
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return channel;
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,8 +26,44 @@ export interface EventMember {
|
||||
id: string;
|
||||
event_id: string;
|
||||
user_id: string;
|
||||
role: 'lead' | 'manager' | 'member';
|
||||
assigned_at: string;
|
||||
role: string;
|
||||
role_id: string | null;
|
||||
notes: string | null;
|
||||
assigned_at: string | null;
|
||||
}
|
||||
|
||||
export interface EventRole {
|
||||
id: string;
|
||||
event_id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
sort_order: number;
|
||||
is_default: boolean;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface EventDepartment {
|
||||
id: string;
|
||||
event_id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
description: string | null;
|
||||
planned_budget: number;
|
||||
sort_order: number;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface EventMemberDepartment {
|
||||
id: string;
|
||||
event_member_id: string;
|
||||
department_id: string;
|
||||
assigned_at: string | null;
|
||||
}
|
||||
|
||||
export interface EventMemberWithDetails extends EventMember {
|
||||
profile?: { id: string; email: string; full_name: string | null; avatar_url: string | null; phone: string | null; discord_handle: string | null; shirt_size: string | null; hoodie_size: string | null };
|
||||
event_role?: EventRole;
|
||||
departments: EventDepartment[];
|
||||
}
|
||||
|
||||
export interface EventWithCounts extends Event {
|
||||
@@ -211,7 +247,7 @@ export async function deleteEvent(
|
||||
export async function fetchEventMembers(
|
||||
supabase: SupabaseClient<Database>,
|
||||
eventId: string
|
||||
): Promise<(EventMember & { profile?: { id: string; email: string; full_name: string | null; avatar_url: string | null } })[]> {
|
||||
): Promise<EventMemberWithDetails[]> {
|
||||
const { data: members, error } = await supabase
|
||||
.from('event_members')
|
||||
.select('*')
|
||||
@@ -229,14 +265,47 @@ export async function fetchEventMembers(
|
||||
const userIds = members.map((m: any) => m.user_id);
|
||||
const { data: profiles } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, email, full_name, avatar_url')
|
||||
.select('id, email, full_name, avatar_url, phone, discord_handle, shirt_size, hoodie_size')
|
||||
.in('id', userIds);
|
||||
|
||||
const profileMap = Object.fromEntries((profiles ?? []).map(p => [p.id, p]));
|
||||
|
||||
// Fetch roles for this event
|
||||
const { data: roles } = await supabase
|
||||
.from('event_roles')
|
||||
.select('*')
|
||||
.eq('event_id', eventId);
|
||||
const roleMap = Object.fromEntries((roles ?? []).map(r => [r.id, r]));
|
||||
|
||||
// Fetch member-department assignments
|
||||
const memberIds = members.map((m: any) => m.id);
|
||||
const { data: memberDepts } = await supabase
|
||||
.from('event_member_departments')
|
||||
.select('*')
|
||||
.in('event_member_id', memberIds);
|
||||
|
||||
// Fetch departments for this event
|
||||
const { data: departments } = await supabase
|
||||
.from('event_departments')
|
||||
.select('*')
|
||||
.eq('event_id', eventId);
|
||||
const deptMap = Object.fromEntries((departments ?? []).map(d => [d.id, d]));
|
||||
|
||||
// Build member-to-departments map
|
||||
const memberDeptMap: Record<string, EventDepartment[]> = {};
|
||||
for (const md of (memberDepts ?? [])) {
|
||||
const dept = deptMap[md.department_id];
|
||||
if (dept) {
|
||||
if (!memberDeptMap[md.event_member_id]) memberDeptMap[md.event_member_id] = [];
|
||||
memberDeptMap[md.event_member_id].push(dept as unknown as EventDepartment);
|
||||
}
|
||||
}
|
||||
|
||||
return members.map((m: any) => ({
|
||||
...m,
|
||||
profile: profileMap[m.user_id] ?? undefined,
|
||||
event_role: m.role_id ? (roleMap[m.role_id] as unknown as EventRole) ?? undefined : undefined,
|
||||
departments: memberDeptMap[m.id] ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -244,11 +313,17 @@ export async function addEventMember(
|
||||
supabase: SupabaseClient<Database>,
|
||||
eventId: string,
|
||||
userId: string,
|
||||
role: 'lead' | 'manager' | 'member' = 'member'
|
||||
params: { role?: 'lead' | 'manager' | 'member'; role_id?: string; notes?: string } = {}
|
||||
): Promise<EventMember> {
|
||||
const { data, error } = await supabase
|
||||
.from('event_members')
|
||||
.upsert({ event_id: eventId, user_id: userId, role }, { onConflict: 'event_id,user_id' })
|
||||
.upsert({
|
||||
event_id: eventId,
|
||||
user_id: userId,
|
||||
role: params.role ?? 'member',
|
||||
role_id: params.role_id ?? null,
|
||||
notes: params.notes ?? null,
|
||||
}, { onConflict: 'event_id,user_id' })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
@@ -275,3 +350,221 @@ export async function removeEventMember(
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Event Roles
|
||||
// ============================================================
|
||||
|
||||
export async function fetchEventRoles(
|
||||
supabase: SupabaseClient<Database>,
|
||||
eventId: string
|
||||
): Promise<EventRole[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('event_roles')
|
||||
.select('*')
|
||||
.eq('event_id', eventId)
|
||||
.order('sort_order');
|
||||
|
||||
if (error) {
|
||||
log.error('fetchEventRoles failed', { error, data: { eventId } });
|
||||
throw error;
|
||||
}
|
||||
return (data ?? []) as unknown as EventRole[];
|
||||
}
|
||||
|
||||
export async function createEventRole(
|
||||
supabase: SupabaseClient<Database>,
|
||||
eventId: string,
|
||||
params: { name: string; color?: string; sort_order?: number }
|
||||
): Promise<EventRole> {
|
||||
const { data, error } = await supabase
|
||||
.from('event_roles')
|
||||
.insert({
|
||||
event_id: eventId,
|
||||
name: params.name,
|
||||
color: params.color ?? '#6366f1',
|
||||
sort_order: params.sort_order ?? 0,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
log.error('createEventRole failed', { error, data: { eventId, name: params.name } });
|
||||
throw error;
|
||||
}
|
||||
return data as unknown as EventRole;
|
||||
}
|
||||
|
||||
export async function updateEventRole(
|
||||
supabase: SupabaseClient<Database>,
|
||||
roleId: string,
|
||||
params: Partial<Pick<EventRole, 'name' | 'color' | 'sort_order' | 'is_default'>>
|
||||
): Promise<EventRole> {
|
||||
const { data, error } = await supabase
|
||||
.from('event_roles')
|
||||
.update(params)
|
||||
.eq('id', roleId)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
log.error('updateEventRole failed', { error, data: { roleId } });
|
||||
throw error;
|
||||
}
|
||||
return data as unknown as EventRole;
|
||||
}
|
||||
|
||||
export async function deleteEventRole(
|
||||
supabase: SupabaseClient<Database>,
|
||||
roleId: string
|
||||
): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('event_roles')
|
||||
.delete()
|
||||
.eq('id', roleId);
|
||||
|
||||
if (error) {
|
||||
log.error('deleteEventRole failed', { error, data: { roleId } });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Event Departments
|
||||
// ============================================================
|
||||
|
||||
export async function fetchEventDepartments(
|
||||
supabase: SupabaseClient<Database>,
|
||||
eventId: string
|
||||
): Promise<EventDepartment[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('event_departments')
|
||||
.select('*')
|
||||
.eq('event_id', eventId)
|
||||
.order('sort_order');
|
||||
|
||||
if (error) {
|
||||
log.error('fetchEventDepartments failed', { error, data: { eventId } });
|
||||
throw error;
|
||||
}
|
||||
return (data ?? []) as unknown as EventDepartment[];
|
||||
}
|
||||
|
||||
export async function createEventDepartment(
|
||||
supabase: SupabaseClient<Database>,
|
||||
eventId: string,
|
||||
params: { name: string; color?: string; description?: string; sort_order?: number }
|
||||
): Promise<EventDepartment> {
|
||||
const { data, error } = await supabase
|
||||
.from('event_departments')
|
||||
.insert({
|
||||
event_id: eventId,
|
||||
name: params.name,
|
||||
color: params.color ?? '#00A3E0',
|
||||
description: params.description ?? null,
|
||||
sort_order: params.sort_order ?? 0,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
log.error('createEventDepartment failed', { error, data: { eventId, name: params.name } });
|
||||
throw error;
|
||||
}
|
||||
return data as unknown as EventDepartment;
|
||||
}
|
||||
|
||||
export async function updateEventDepartment(
|
||||
supabase: SupabaseClient<Database>,
|
||||
deptId: string,
|
||||
params: Partial<Pick<EventDepartment, 'name' | 'color' | 'description' | 'sort_order'>>
|
||||
): Promise<EventDepartment> {
|
||||
const { data, error } = await supabase
|
||||
.from('event_departments')
|
||||
.update(params)
|
||||
.eq('id', deptId)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
log.error('updateEventDepartment failed', { error, data: { deptId } });
|
||||
throw error;
|
||||
}
|
||||
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
|
||||
): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('event_departments')
|
||||
.delete()
|
||||
.eq('id', deptId);
|
||||
|
||||
if (error) {
|
||||
log.error('deleteEventDepartment failed', { error, data: { deptId } });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Member-Department Assignments
|
||||
// ============================================================
|
||||
|
||||
export async function assignMemberDepartment(
|
||||
supabase: SupabaseClient<Database>,
|
||||
eventMemberId: string,
|
||||
departmentId: string
|
||||
): Promise<EventMemberDepartment> {
|
||||
const { data, error } = await supabase
|
||||
.from('event_member_departments')
|
||||
.upsert(
|
||||
{ event_member_id: eventMemberId, department_id: departmentId },
|
||||
{ onConflict: 'event_member_id,department_id' }
|
||||
)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
log.error('assignMemberDepartment failed', { error, data: { eventMemberId, departmentId } });
|
||||
throw error;
|
||||
}
|
||||
return data as unknown as EventMemberDepartment;
|
||||
}
|
||||
|
||||
export async function unassignMemberDepartment(
|
||||
supabase: SupabaseClient<Database>,
|
||||
eventMemberId: string,
|
||||
departmentId: string
|
||||
): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('event_member_departments')
|
||||
.delete()
|
||||
.eq('event_member_id', eventMemberId)
|
||||
.eq('department_id', departmentId);
|
||||
|
||||
if (error) {
|
||||
log.error('unassignMemberDepartment failed', { error, data: { eventMemberId, departmentId } });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
31
src/lib/api/google-calendar-push.test.ts
Normal file
31
src/lib/api/google-calendar-push.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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
326
src/lib/api/kanban.test.ts
Normal 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
300
src/lib/api/map.ts
Normal 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;
|
||||
}
|
||||
203
src/lib/api/org-contacts.test.ts
Normal file
203
src/lib/api/org-contacts.test.ts
Normal 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
158
src/lib/api/org-contacts.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
250
src/lib/api/organizations.test.ts
Normal file
250
src/lib/api/organizations.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
171
src/lib/api/schedule.test.ts
Normal file
171
src/lib/api/schedule.test.ts
Normal 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
176
src/lib/api/schedule.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
166
src/lib/api/sponsor-allocations.test.ts
Normal file
166
src/lib/api/sponsor-allocations.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
115
src/lib/api/sponsor-allocations.ts
Normal file
115
src/lib/api/sponsor-allocations.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
116
src/lib/api/sponsors-event.test.ts
Normal file
116
src/lib/api/sponsors-event.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
287
src/lib/api/sponsors.test.ts
Normal file
287
src/lib/api/sponsors.test.ts
Normal 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
365
src/lib/api/sponsors.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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,243 +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 gap-2">
|
||||
<!-- Navigation bar -->
|
||||
<div class="flex items-center justify-between px-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<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 text-light/60 hover:text-light hover:bg-dark rounded-full 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: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>chevron_left</span
|
||||
class="material-symbols-rounded text-white"
|
||||
style={iconStyle}>chevron_backward</span
|
||||
>
|
||||
</button>
|
||||
<span
|
||||
class="font-heading text-h4 text-white min-w-[200px] 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 text-light/60 hover:text-light hover:bg-dark rounded-full 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: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>chevron_right</span
|
||||
class="material-symbols-rounded text-white"
|
||||
style={iconStyle}>chevron_forward</span
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1 text-body-md font-body text-light/60 hover:text-white hover:bg-dark rounded-[32px] transition-colors ml-2"
|
||||
onclick={goToToday}
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex bg-dark rounded-[32px] p-0.5">
|
||||
|
||||
<!-- Right: Day/Week/Month + New -->
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
class="px-3 py-1 text-body-md font-body rounded-[32px] 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-night'
|
||||
: 'text-light/60 hover:text-light'}"
|
||||
onclick={() => (currentView = "day")}>Day</button
|
||||
? 'bg-primary text-background'
|
||||
: 'bg-surface text-white hover:opacity-80'}"
|
||||
onclick={() => (currentView = "day")}
|
||||
>{m.calendar_view_day()}</button
|
||||
>
|
||||
<button
|
||||
class="px-3 py-1 text-body-md font-body rounded-[32px] 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-night'
|
||||
: 'text-light/60 hover:text-light'}"
|
||||
onclick={() => (currentView = "week")}>Week</button
|
||||
? 'bg-primary text-background'
|
||||
: 'bg-surface text-white hover:opacity-80'}"
|
||||
onclick={() => (currentView = "week")}
|
||||
>{m.calendar_view_week()}</button
|
||||
>
|
||||
<button
|
||||
class="px-3 py-1 text-body-md font-body rounded-[32px] 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-night'
|
||||
: 'text-light/60 hover:text-light'}"
|
||||
onclick={() => (currentView = "month")}>Month</button
|
||||
? 'bg-primary text-background'
|
||||
: '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 gap-2 min-h-0 bg-background rounded-xl p-2"
|
||||
>
|
||||
<!-- Day Headers -->
|
||||
<div class="grid grid-cols-7 gap-2">
|
||||
<!-- 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 px-2">
|
||||
<span
|
||||
class="font-heading text-h4 text-white text-center"
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<span class="font-heading text-h4 text-white"
|
||||
>{day}</span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Calendar Grid -->
|
||||
<!-- Month grid: rows of weeks -->
|
||||
<div
|
||||
class="flex-1 flex flex-col gap-2 min-h-0 rounded-lg overflow-hidden"
|
||||
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 gap-2 flex-1">
|
||||
<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)}
|
||||
<div
|
||||
class="bg-night rounded-none flex flex-col items-start px-2 py-2.5 overflow-hidden transition-colors hover:bg-dark/50 min-h-0 cursor-pointer
|
||||
{!inMonth ? 'opacity-50' : ''}"
|
||||
onclick={() => onDateClick?.(day)}
|
||||
<button
|
||||
type="button"
|
||||
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="font-body text-body text-white {isToday
|
||||
? 'text-primary font-bold'
|
||||
<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-1 px-2 py-0.5 rounded-[4px] text-body-sm font-bold font-body text-night truncate text-left"
|
||||
<!-- 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-body-sm text-light/40 mt-0.5"
|
||||
>+{dayEvents.length - 2} more</span
|
||||
<span class="text-body-sm text-white/50"
|
||||
>+{dayEvents.length - 2}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Week View -->
|
||||
{#if currentView === "week"}
|
||||
<div
|
||||
class="flex flex-col flex-1 gap-2 min-h-0 bg-background rounded-xl p-2"
|
||||
>
|
||||
<div
|
||||
class="grid grid-cols-7 gap-2 flex-1 rounded-lg 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">
|
||||
<div class="px-2 py-2 text-center">
|
||||
<div
|
||||
class="font-heading text-h4 {isToday
|
||||
? 'text-primary'
|
||||
: 'text-white'}"
|
||||
>
|
||||
{weekDayHeaders[(day.getDay() + 6) % 7]}
|
||||
</div>
|
||||
<div
|
||||
class="font-body text-body-md {isToday
|
||||
? 'text-primary'
|
||||
: 'text-light/60'}"
|
||||
<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="bg-night flex-1 px-2 pb-2 space-y-1 overflow-y-auto"
|
||||
>
|
||||
{#each dayEvents as event}
|
||||
<button
|
||||
class="w-full px-2 py-1.5 rounded-[4px] text-body-sm font-bold font-body text-night truncate text-left"
|
||||
style="background-color: {event.color ??
|
||||
'#00A3E0'}"
|
||||
onclick={() => onEventClick?.(event)}
|
||||
>
|
||||
{event.title}
|
||||
</button>
|
||||
{/each}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Day View -->
|
||||
{#if currentView === "day"}
|
||||
{@const dayEvents = getEventsForDay(currentDate)}
|
||||
<div class="flex-1 bg-night px-4 py-5 min-h-0 overflow-auto">
|
||||
{#if dayEvents.length === 0}
|
||||
<div class="text-center text-light/40 py-12">
|
||||
<p class="font-body text-body">No events for this day</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each dayEvents as event}
|
||||
<button
|
||||
class="w-full text-left p-3 rounded-[8px] transition-colors hover:opacity-80"
|
||||
style="background-color: {event.color ??
|
||||
'#00A3E0'}20; border-left: 3px solid {event.color ??
|
||||
'#00A3E0'}"
|
||||
onclick={() => onEventClick?.(event)}
|
||||
>
|
||||
<div class="font-heading text-h5 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="font-body text-body-md text-light/60 mt-1"
|
||||
{/each}
|
||||
{#if dayEvents.length === 0}
|
||||
<span class="text-white/20 text-body-sm"
|
||||
>{m.calendar_no_events()}</span
|
||||
>
|
||||
{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="font-body text-body-md text-light/50 mt-2"
|
||||
>
|
||||
{event.description}
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/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>
|
||||
|
||||
@@ -268,8 +268,6 @@
|
||||
{onEdit}
|
||||
{onDelete}
|
||||
{onReply}
|
||||
{onLoadMore}
|
||||
isLoading={isLoadingMore}
|
||||
/>
|
||||
<TypingIndicator userNames={typingUsers} />
|
||||
<MessageInput
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -42,17 +42,15 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="bg-night rounded-[32px] overflow-hidden flex flex-col min-w-0 h-full"
|
||||
>
|
||||
<div class="flex flex-col min-w-0 h-full overflow-hidden">
|
||||
<!-- Lock Banner -->
|
||||
{#if locked}
|
||||
<div
|
||||
class="flex items-center gap-2 px-4 py-2.5 bg-warning/10 border-b border-warning/20"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-warning/10 border-b border-warning/20 shrink-0"
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded text-warning"
|
||||
style="font-size: 20px; font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
|
||||
style="font-size: 18px; font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
|
||||
>
|
||||
lock
|
||||
</span>
|
||||
@@ -64,42 +62,35 @@
|
||||
{/if}
|
||||
|
||||
<!-- Header -->
|
||||
<header class="flex items-center gap-2 px-4 py-5">
|
||||
<h2 class="flex-1 font-heading text-h1 text-white truncate">
|
||||
<div class="flex items-center gap-2 px-5 py-3 border-b border-light/5 shrink-0">
|
||||
<h2 class="flex-1 font-heading text-body-sm text-white truncate">
|
||||
{document.name}
|
||||
</h2>
|
||||
{#if locked}
|
||||
<Button size="md" disabled>
|
||||
<span
|
||||
class="material-symbols-rounded mr-1"
|
||||
style="font-size: 16px; font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
|
||||
>lock</span
|
||||
>
|
||||
Locked
|
||||
</Button>
|
||||
<Button size="sm" disabled>Locked</Button>
|
||||
{:else if mode === "edit"}
|
||||
<Button size="md" onclick={handleEditClick}>
|
||||
<Button size="sm" onclick={handleEditClick}>
|
||||
{isEditing ? "Preview" : "Edit"}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button size="md" onclick={handleEditClick}>Edit</Button>
|
||||
<Button size="sm" onclick={handleEditClick}>Edit</Button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 hover:bg-dark rounded-full transition-colors"
|
||||
class="p-1 hover:bg-dark/50 rounded-lg transition-colors"
|
||||
aria-label="More options"
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded text-light"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
class="material-symbols-rounded text-light/40 hover:text-white"
|
||||
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
|
||||
>
|
||||
more_horiz
|
||||
</span>
|
||||
</button>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<!-- Editor Area -->
|
||||
<div class="flex-1 bg-background rounded-[32px] mx-4 mb-4 overflow-auto">
|
||||
<div class="flex-1 overflow-auto">
|
||||
<Editor {document} {onSave} editable={isEditing} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
Input,
|
||||
Avatar,
|
||||
IconButton,
|
||||
Icon,
|
||||
} 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";
|
||||
@@ -23,6 +16,9 @@
|
||||
deleteDocument,
|
||||
createDocument,
|
||||
copyDocument,
|
||||
uploadFile,
|
||||
getFileMetadata,
|
||||
formatFileSize,
|
||||
} from "$lib/api/documents";
|
||||
|
||||
const log = createLogger("component.file-browser");
|
||||
@@ -34,6 +30,7 @@
|
||||
user: { id: string } | null;
|
||||
/** Page title shown in the header */
|
||||
title?: string;
|
||||
showCreateModal?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -42,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"
|
||||
@@ -77,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(
|
||||
@@ -132,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;
|
||||
@@ -144,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;
|
||||
}
|
||||
@@ -152,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");
|
||||
}
|
||||
@@ -197,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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,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;
|
||||
@@ -306,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(
|
||||
@@ -330,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([
|
||||
@@ -385,7 +492,7 @@
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toasts.error("Failed to create document");
|
||||
toasts.error(m.toast_error_create_document());
|
||||
}
|
||||
|
||||
showCreateModal = false;
|
||||
@@ -401,7 +508,7 @@
|
||||
d.id === selectedDoc!.id ? { ...d, content } : d,
|
||||
);
|
||||
} catch {
|
||||
toasts.error("Failed to save document");
|
||||
toasts.error(m.toast_error_create_document());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,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;
|
||||
@@ -485,104 +592,175 @@
|
||||
selectedDoc = null;
|
||||
}
|
||||
} catch {
|
||||
toasts.error("Failed to delete document");
|
||||
toasts.error(m.toast_error_delete_document());
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full gap-4">
|
||||
<!-- Files Panel -->
|
||||
<div
|
||||
class="bg-night rounded-[32px] flex flex-col gap-4 px-4 py-5 overflow-hidden flex-1 min-w-0 h-full"
|
||||
>
|
||||
<!-- Header -->
|
||||
<header class="flex items-center gap-2 p-1">
|
||||
<Avatar name={title} size="md" />
|
||||
<h1 class="flex-1 font-heading text-h1 text-white">{title}</h1>
|
||||
<Button size="md" onclick={handleAdd}>{m.btn_new()}</Button>
|
||||
<IconButton title={m.files_toggle_view()} onclick={toggleViewMode}>
|
||||
<Icon
|
||||
name={viewMode === "list" ? "grid_view" : "view_list"}
|
||||
size={24}
|
||||
/>
|
||||
</IconButton>
|
||||
</header>
|
||||
<!-- Hidden file input -->
|
||||
<input
|
||||
bind:this={fileUploadInput}
|
||||
type="file"
|
||||
multiple
|
||||
class="hidden"
|
||||
onchange={handleFileInputChange}
|
||||
/>
|
||||
|
||||
<!-- Breadcrumb Path -->
|
||||
<nav class="flex items-center gap-2 text-h3 font-heading">
|
||||
{#each breadcrumbPath as crumb, i}
|
||||
{#if i > 0}
|
||||
<div class="flex h-full gap-0">
|
||||
<!-- Files Panel -->
|
||||
<!-- 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-light/30"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
class="material-symbols-rounded text-primary"
|
||||
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
|
||||
>cloud_upload</span
|
||||
>
|
||||
chevron_right
|
||||
</span>
|
||||
{/if}
|
||||
<a
|
||||
href={getFolderUrl(crumb.id)}
|
||||
class="px-3 py-1 rounded-xl transition-colors
|
||||
{crumb.id === currentFolderId
|
||||
? 'text-white'
|
||||
: 'text-light/60 hover:text-primary'}
|
||||
{dragOverBreadcrumb === (crumb.id ?? '__root__')
|
||||
? 'ring-2 ring-primary bg-primary/10'
|
||||
: ''}"
|
||||
ondragover={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
||||
dragOverBreadcrumb = crumb.id ?? "__root__";
|
||||
}}
|
||||
ondragleave={() => {
|
||||
dragOverBreadcrumb = undefined;
|
||||
}}
|
||||
ondrop={async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragOverBreadcrumb = undefined;
|
||||
if (!draggedItem) return;
|
||||
if (draggedItem.parent_id === crumb.id) {
|
||||
<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 flex-1 min-w-0 overflow-x-auto">
|
||||
{#each breadcrumbPath as crumb, 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}
|
||||
<a
|
||||
href={getFolderUrl(crumb.id)}
|
||||
class="flex items-center gap-2 shrink-0 hover:opacity-80 transition-opacity
|
||||
{dragOverBreadcrumb === (crumb.id ?? '__root__')
|
||||
? 'ring-4 ring-primary bg-primary/10 rounded-lg'
|
||||
: ''}"
|
||||
ondragover={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.dataTransfer)
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
dragOverBreadcrumb = crumb.id ?? "__root__";
|
||||
}}
|
||||
ondragleave={() => {
|
||||
dragOverBreadcrumb = undefined;
|
||||
}}
|
||||
ondrop={async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragOverBreadcrumb = undefined;
|
||||
if (!draggedItem) return;
|
||||
if (draggedItem.parent_id === crumb.id) {
|
||||
resetDragState();
|
||||
return;
|
||||
}
|
||||
const draggedName = draggedItem.name;
|
||||
await handleMove(draggedItem.id, crumb.id);
|
||||
toasts.success(
|
||||
`Moved "${draggedName}" to "${crumb.name}"`,
|
||||
);
|
||||
resetDragState();
|
||||
return;
|
||||
}
|
||||
const draggedName = draggedItem.name;
|
||||
await handleMove(draggedItem.id, crumb.id);
|
||||
toasts.success(
|
||||
`Moved "${draggedName}" to "${crumb.name}"`,
|
||||
);
|
||||
resetDragState();
|
||||
}}
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
<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}
|
||||
>
|
||||
{crumb.name}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
<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">
|
||||
<div class="flex-1 overflow-auto min-h-0 py-4">
|
||||
{#if viewMode === "list"}
|
||||
<!-- List View -->
|
||||
<div
|
||||
class="flex flex-col gap-1"
|
||||
class="flex flex-col"
|
||||
ondragover={handleContainerDragOver}
|
||||
ondrop={handleDropOnEmpty}
|
||||
role="list"
|
||||
>
|
||||
{#if currentFolderItems.length === 0}
|
||||
<div class="text-center text-light/40 py-8 text-sm">
|
||||
<p>
|
||||
No files yet. Drag files here or create a new
|
||||
one.
|
||||
</p>
|
||||
<div
|
||||
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-2 h-10 pl-1 pr-2 py-1 rounded-[32px] w-full text-left transition-colors hover:bg-dark
|
||||
{selectedDoc?.id === item.id ? 'bg-dark' : ''}
|
||||
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,27 +774,31 @@
|
||||
handleContextMenu(e, item)}
|
||||
>
|
||||
<div
|
||||
class="w-8 h-8 flex items-center justify-center p-1"
|
||||
class="flex items-center gap-[10px] min-w-0"
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded text-light"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
class="flex items-center justify-center p-1 shrink-0"
|
||||
>
|
||||
{getDocIcon(item)}
|
||||
<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>
|
||||
<span
|
||||
class="font-body text-body text-white truncate"
|
||||
>{item.name}</span
|
||||
>
|
||||
</div>
|
||||
<span
|
||||
class="font-body text-body text-white truncate flex-1"
|
||||
>{item.name}</span
|
||||
class="flex items-center justify-center p-1 shrink-0"
|
||||
>
|
||||
{#if item.type === "folder"}
|
||||
<span
|
||||
class="material-symbols-rounded text-light/50"
|
||||
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
|
||||
class="material-symbols-rounded text-white"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>chevron_forward</span
|
||||
>
|
||||
chevron_right
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
@@ -624,28 +806,34 @@
|
||||
{:else}
|
||||
<!-- Grid View -->
|
||||
<div
|
||||
class="grid grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4"
|
||||
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 text-center text-light/40 py-8 text-sm"
|
||||
class="w-full flex flex-col items-center justify-center text-white/30 py-16"
|
||||
>
|
||||
<p>
|
||||
No files yet. Drag files here or create a new
|
||||
one.
|
||||
<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-4 rounded-xl transition-colors hover:bg-dark
|
||||
{selectedDoc?.id === item.id ? 'bg-dark' : ''}
|
||||
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}
|
||||
@@ -659,15 +847,19 @@
|
||||
handleContextMenu(e, item)}
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded text-light"
|
||||
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48;"
|
||||
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-body-md 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}
|
||||
@@ -678,7 +870,7 @@
|
||||
|
||||
<!-- Compact Editor Panel (shown when a doc is selected) -->
|
||||
{#if selectedDoc}
|
||||
<div class="flex-1 min-w-0 h-full">
|
||||
<div class="flex-1 min-w-0 h-full border-l border-light/5">
|
||||
<DocumentViewer
|
||||
document={selectedDoc}
|
||||
onSave={handleSave}
|
||||
@@ -752,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()}
|
||||
@@ -883,7 +1075,7 @@
|
||||
/>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
variant="ghost"
|
||||
onclick={() => {
|
||||
showEditModal = false;
|
||||
editingDoc = null;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,6 +86,28 @@
|
||||
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 handleCardDragOver(e: DragEvent, columnId: string, index: number) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -92,6 +117,13 @@
|
||||
const midY = rect.top + rect.height / 2;
|
||||
const dropIndex = e.clientY < midY ? index : index + 1;
|
||||
|
||||
// Skip update if the index hasn't changed (prevents flicker)
|
||||
if (
|
||||
dragOverCardIndex?.columnId === columnId &&
|
||||
dragOverCardIndex?.index === dropIndex
|
||||
)
|
||||
return;
|
||||
|
||||
dragOverColumn = columnId;
|
||||
dragOverCardIndex = { columnId, index: dropIndex };
|
||||
}
|
||||
@@ -135,15 +167,16 @@
|
||||
</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)}
|
||||
ondragleave={handleColumnDragLeave}
|
||||
ondrop={(e) => handleDrop(e, column.id)}
|
||||
@@ -153,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"
|
||||
@@ -238,14 +272,14 @@
|
||||
<!-- Cards -->
|
||||
<div class="flex-1 overflow-y-auto flex flex-col gap-0">
|
||||
{#each column.cards as card, cardIndex}
|
||||
<!-- Drop indicator before card -->
|
||||
{#if draggedCard && dragOverCardIndex?.columnId === column.id && dragOverCardIndex?.index === cardIndex && draggedCard.id !== card.id}
|
||||
<div
|
||||
class="h-1 bg-primary rounded-full mx-2 my-1 transition-all"
|
||||
></div>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="mb-2"
|
||||
class="mb-2 relative {dropIndicatorClass(
|
||||
card,
|
||||
cardIndex,
|
||||
column.id,
|
||||
column.cards.length,
|
||||
)}"
|
||||
ondragover={(e) =>
|
||||
handleCardDragOver(e, column.id, cardIndex)}
|
||||
>
|
||||
@@ -258,26 +292,21 @@
|
||||
ondelete={canEdit
|
||||
? (id) => onDeleteCard?.(id)
|
||||
: undefined}
|
||||
{dateFormat}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
<!-- Drop indicator at end of column -->
|
||||
{#if draggedCard && dragOverCardIndex?.columnId === column.id && dragOverCardIndex?.index === column.cards.length}
|
||||
<div
|
||||
class="h-1 bg-primary rounded-full mx-2 my-1 transition-all"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Add Card Button -->
|
||||
{#if canEdit}
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant="primary"
|
||||
fullWidth
|
||||
icon="add"
|
||||
onclick={() => onAddCard?.(column.id)}
|
||||
>
|
||||
Add card
|
||||
{m.kanban_add_card()}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -286,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}
|
||||
|
||||
@@ -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;
|
||||
@@ -21,6 +23,8 @@
|
||||
ondelete?: (cardId: string) => void;
|
||||
draggable?: boolean;
|
||||
ondragstart?: (e: DragEvent) => void;
|
||||
ondragend?: (e: DragEvent) => void;
|
||||
dateFormat?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -30,22 +34,19 @@
|
||||
ondelete,
|
||||
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(
|
||||
@@ -57,35 +58,38 @@
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="bg-night rounded-[16px] p-2 cursor-pointer hover:ring-1 hover:ring-primary/30 transition-all group w-full text-left overflow-clip flex flex-col gap-2 relative"
|
||||
class="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}
|
||||
{ondragstart}
|
||||
{ondragend}
|
||||
{onclick}
|
||||
>
|
||||
<!-- Delete button (top-right, visible on hover) -->
|
||||
{#if ondelete}
|
||||
<!-- svelte-ignore node_invalid_placement_ssr -->
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-1 right-1 p-1 rounded-full opacity-0 group-hover:opacity-100 hover:bg-error/20 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/40 hover:text-error"
|
||||
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;"
|
||||
>
|
||||
delete
|
||||
close
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Tags / Chips -->
|
||||
{#if card.tags && card.tags.length > 0}
|
||||
<div class="flex gap-[10px] 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 py-[4px] font-body font-bold text-[14px] text-night leading-none overflow-clip"
|
||||
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}
|
||||
@@ -95,50 +99,37 @@
|
||||
{/if}
|
||||
|
||||
<!-- Title -->
|
||||
<p class="font-body text-body text-white w-full leading-none p-1">
|
||||
<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">
|
||||
<div class="flex gap-1 items-center">
|
||||
<!-- Due date -->
|
||||
<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}
|
||||
<div class="flex items-center">
|
||||
<span class="flex items-center gap-1">
|
||||
<span
|
||||
class="material-symbols-rounded text-light p-1"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
class="material-symbols-rounded"
|
||||
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
|
||||
>calendar_today</span
|
||||
>
|
||||
calendar_today
|
||||
</span>
|
||||
<span
|
||||
class="font-body text-[12px] text-light leading-none"
|
||||
>
|
||||
{formatDueDate(card.due_date)}
|
||||
</span>
|
||||
</div>
|
||||
{formatDueDate(card.due_date)}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Checklist -->
|
||||
{#if (card.checklist_total ?? 0) > 0}
|
||||
<div class="flex items-center">
|
||||
<span class="flex items-center gap-1">
|
||||
<span
|
||||
class="material-symbols-rounded text-light p-1"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
class="material-symbols-rounded"
|
||||
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
|
||||
>check_box</span
|
||||
>
|
||||
check_box
|
||||
</span>
|
||||
<span
|
||||
class="font-body text-[12px] text-light leading-none"
|
||||
>
|
||||
{card.checklist_done ?? 0}/{card.checklist_total}
|
||||
</span>
|
||||
</div>
|
||||
{card.checklist_done ?? 0}/{card.checklist_total}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Assignee avatar -->
|
||||
{#if card.assignee_id}
|
||||
<Avatar
|
||||
name={card.assignee_name || "?"}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
743
src/lib/components/modules/BudgetWidget.svelte
Normal file
743
src/lib/components/modules/BudgetWidget.svelte
Normal 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}
|
||||
281
src/lib/components/modules/ChecklistWidget.svelte
Normal file
281
src/lib/components/modules/ChecklistWidget.svelte
Normal 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>
|
||||
497
src/lib/components/modules/ContactsWidget.svelte
Normal file
497
src/lib/components/modules/ContactsWidget.svelte
Normal 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>
|
||||
488
src/lib/components/modules/FilesWidget.svelte
Normal file
488
src/lib/components/modules/FilesWidget.svelte
Normal 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}
|
||||
237
src/lib/components/modules/KanbanWidget.svelte
Normal file
237
src/lib/components/modules/KanbanWidget.svelte
Normal 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}
|
||||
2556
src/lib/components/modules/MapWidget.svelte
Normal file
2556
src/lib/components/modules/MapWidget.svelte
Normal file
File diff suppressed because it is too large
Load Diff
262
src/lib/components/modules/NotesWidget.svelte
Normal file
262
src/lib/components/modules/NotesWidget.svelte
Normal 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>
|
||||
616
src/lib/components/modules/ScheduleWidget.svelte
Normal file
616
src/lib/components/modules/ScheduleWidget.svelte
Normal 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>
|
||||
878
src/lib/components/modules/SponsorsWidget.svelte
Normal file
878
src/lib/components/modules/SponsorsWidget.svelte
Normal 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}
|
||||
446
src/lib/components/settings/SettingsActivityLog.svelte
Normal file
446
src/lib/components/settings/SettingsActivityLog.svelte
Normal 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>
|
||||
@@ -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,107 +245,641 @@
|
||||
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-8">
|
||||
<div class="flex flex-col gap-6 max-w-2xl">
|
||||
<!-- Organization Details -->
|
||||
<h2 class="font-heading text-h2 text-white">Organization details</h2>
|
||||
<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>
|
||||
|
||||
<div class="flex flex-col gap-8">
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Avatar Upload -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-body text-body-sm text-light">Avatar</span>
|
||||
<div class="flex items-center gap-4">
|
||||
<Avatar name={orgName || "?"} src={avatarUrl} size="lg" />
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
bind:this={avatarInput}
|
||||
onchange={handleAvatarUpload}
|
||||
/>
|
||||
<!-- Avatar Upload -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-body text-body-sm text-light/60">Avatar</span>
|
||||
<div class="flex items-center gap-4">
|
||||
<Avatar name={orgName || "?"} src={avatarUrl} size="lg" />
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
bind:this={avatarInput}
|
||||
onchange={handleAvatarUpload}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onclick={() => avatarInput?.click()}
|
||||
loading={isUploading}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
{#if avatarUrl}
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => avatarInput?.click()}
|
||||
loading={isUploading}
|
||||
onclick={removeAvatar}
|
||||
>
|
||||
Upload
|
||||
Remove
|
||||
</Button>
|
||||
{#if avatarUrl}
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onclick={removeAvatar}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
label="Name"
|
||||
bind:value={orgName}
|
||||
placeholder="Organization name"
|
||||
/>
|
||||
<Input
|
||||
label="URL slug (yoursite.com/...)"
|
||||
bind:value={orgSlug}
|
||||
placeholder="my-org"
|
||||
/>
|
||||
<div>
|
||||
<Button onclick={saveGeneralSettings} loading={isSaving}
|
||||
>Save Changes</Button
|
||||
</div>
|
||||
<Input
|
||||
label="Name"
|
||||
bind:value={orgName}
|
||||
placeholder="Organization name"
|
||||
/>
|
||||
<Input
|
||||
label="URL slug (yoursite.com/...)"
|
||||
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
|
||||
>
|
||||
</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>
|
||||
|
||||
<!-- Danger Zone -->
|
||||
{#if isOwner}
|
||||
<div class="flex flex-col gap-4">
|
||||
<h4 class="font-heading text-h4 text-white">Danger Zone</h4>
|
||||
<p class="font-body text-body text-white">
|
||||
Permanently delete this organization and all its data.
|
||||
</p>
|
||||
<div>
|
||||
<Button variant="danger" onclick={onDelete}
|
||||
>Delete Organization</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Default Event Status -->
|
||||
<Select
|
||||
variant="compact"
|
||||
label="Default event status"
|
||||
bind:value={defaultEventStatus}
|
||||
placeholder=""
|
||||
options={EVENT_STATUS_OPTIONS}
|
||||
/>
|
||||
|
||||
<!-- Leave Organization (non-owners) -->
|
||||
{#if !isOwner}
|
||||
<div class="flex flex-col gap-4">
|
||||
<h4 class="font-heading text-h4 text-white">
|
||||
Leave Organization
|
||||
</h4>
|
||||
<p class="font-body text-body text-white">
|
||||
Leave this organization. You will need to be re-invited to
|
||||
rejoin.
|
||||
</p>
|
||||
<div>
|
||||
<Button variant="secondary" onclick={onLeave}
|
||||
>Leave {org.name}</Button
|
||||
<!-- 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)}
|
||||
>
|
||||
</div>
|
||||
<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>
|
||||
{/if}
|
||||
</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"
|
||||
>
|
||||
<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.
|
||||
</p>
|
||||
<div>
|
||||
<Button variant="danger" size="sm" onclick={onDelete}
|
||||
>Delete Organization</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 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>
|
||||
<p class="font-body text-[11px] text-light/40">
|
||||
Leave this organization. You will need to be re-invited to
|
||||
rejoin.
|
||||
</p>
|
||||
<div>
|
||||
<Button variant="secondary" size="sm" onclick={onLeave}
|
||||
>Leave {org.name}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Button, Modal, Card, Input } from "$lib/components/ui";
|
||||
import { Button, Modal, Input } from "$lib/components/ui";
|
||||
import { toasts } from "$lib/stores/toast.svelte";
|
||||
import {
|
||||
extractCalendarId,
|
||||
@@ -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()
|
||||
@@ -108,184 +109,158 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6 max-w-2xl">
|
||||
<Card>
|
||||
<div class="p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="w-12 h-12 bg-white rounded-lg flex items-center justify-center"
|
||||
>
|
||||
<svg class="w-8 h-8" 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"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-light">
|
||||
Google Calendar
|
||||
</h3>
|
||||
<p class="text-sm text-light/50 mt-1">
|
||||
Sync events between your organization and Google
|
||||
Calendar.
|
||||
</p>
|
||||
<div class="space-y-3 max-w-2xl">
|
||||
<!-- 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"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<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}
|
||||
{#if orgCalendar}
|
||||
<div
|
||||
class="mt-3 p-3 bg-green-500/10 border border-green-500/20 rounded-lg"
|
||||
>
|
||||
<div
|
||||
class="mt-4 p-3 bg-green-500/10 border border-green-500/20 rounded-lg"
|
||||
class="flex flex-col sm:flex-row sm:items-center justify-between gap-3"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-3 bg-green-500/10 rounded-lg"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p
|
||||
class="text-sm font-medium text-green-400"
|
||||
>
|
||||
Connected
|
||||
</p>
|
||||
<p class="text-light font-medium">
|
||||
{orgCalendar.calendar_name ||
|
||||
"Google Calendar"}
|
||||
</p>
|
||||
<p
|
||||
class="text-xs text-light/50 truncate"
|
||||
title={orgCalendar.calendar_id}
|
||||
>
|
||||
{orgCalendar.calendar_id}
|
||||
</p>
|
||||
<p class="text-xs text-light/40 mt-1">
|
||||
Events sync both ways — create here or
|
||||
in Google Calendar.
|
||||
</p>
|
||||
<a
|
||||
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.5 text-xs text-blue-400 hover:text-blue-300 mt-2"
|
||||
>
|
||||
<svg
|
||||
class="w-3.5 h-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"
|
||||
/>
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<line
|
||||
x1="10"
|
||||
y1="14"
|
||||
x2="21"
|
||||
y2="3"
|
||||
/>
|
||||
</svg>
|
||||
Open in Google Calendar
|
||||
</a>
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onclick={disconnectOrgCalendar}
|
||||
>Disconnect</Button
|
||||
<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>
|
||||
<a
|
||||
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
|
||||
>
|
||||
Open in Google Calendar
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !serviceAccountEmail}
|
||||
<div
|
||||
class="mt-4 p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg"
|
||||
>
|
||||
<p class="text-sm text-yellow-400 font-medium">
|
||||
Setup required
|
||||
</p>
|
||||
<p class="text-xs text-light/50 mt-1">
|
||||
A server administrator needs to configure the <code
|
||||
class="bg-light/10 px-1 rounded"
|
||||
>GOOGLE_SERVICE_ACCOUNT_KEY</code
|
||||
> environment variable before calendars can be connected.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-4">
|
||||
<Button onclick={() => (showConnectModal = true)}
|
||||
>Connect Google Calendar</Button
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onclick={disconnectOrgCalendar}
|
||||
>Disconnect</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div class="p-6 opacity-50">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="w-12 h-12 bg-[#7289da] rounded-lg flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
class="w-7 h-7 text-white"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
</div>
|
||||
{:else if !serviceAccountEmail}
|
||||
<div
|
||||
class="mt-3 p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg"
|
||||
>
|
||||
<path
|
||||
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-light">Discord</h3>
|
||||
<p class="text-sm text-light/50 mt-1">
|
||||
Get notifications in your Discord server.
|
||||
</p>
|
||||
<p class="text-xs text-light/40 mt-2">Coming soon</p>
|
||||
</div>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={() => (showConnectModal = true)}
|
||||
>Connect Google Calendar</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div class="p-6 opacity-50">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="w-12 h-12 bg-[#4A154B] rounded-lg flex items-center justify-center"
|
||||
<!-- 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
|
||||
>
|
||||
<svg
|
||||
class="w-7 h-7 text-white"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52a2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521a2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521a2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523a2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-light">Slack</h3>
|
||||
<p class="text-sm text-light/50 mt-1">
|
||||
Get notifications in your Slack workspace.
|
||||
</p>
|
||||
<p class="text-xs text-light/40 mt-2">Coming soon</p>
|
||||
</div>
|
||||
</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-[10px] text-light/30 mt-1">Coming soon</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<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-[10px] text-light/30 mt-1">Coming soon</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connect Calendar Modal -->
|
||||
@@ -323,7 +298,7 @@
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
variant="ghost"
|
||||
onclick={copyServiceEmail}
|
||||
>
|
||||
{emailCopied ? "Copied!" : "Copy"}
|
||||
@@ -355,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}
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
Card,
|
||||
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";
|
||||
@@ -54,6 +47,7 @@
|
||||
interface Props {
|
||||
supabase: SupabaseClient<Database>;
|
||||
orgId: string;
|
||||
orgName: string;
|
||||
userId: string;
|
||||
members: Member[];
|
||||
roles: OrgRole[];
|
||||
@@ -63,6 +57,7 @@
|
||||
let {
|
||||
supabase,
|
||||
orgId,
|
||||
orgName,
|
||||
userId,
|
||||
members = $bindable(),
|
||||
roles,
|
||||
@@ -76,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;
|
||||
@@ -109,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 }));
|
||||
}
|
||||
@@ -167,115 +186,172 @@
|
||||
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-6">
|
||||
<div class="space-y-4 max-w-2xl">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-light">
|
||||
{m.settings_members_title({
|
||||
count: String(members.length),
|
||||
})}
|
||||
</h2>
|
||||
<Button onclick={() => (showInviteModal = true)}>
|
||||
<svg
|
||||
class="w-4 h-4 mr-2"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle
|
||||
cx="9"
|
||||
cy="7"
|
||||
r="4"
|
||||
/><line x1="19" y1="8" x2="19" y2="14" /><line
|
||||
x1="22"
|
||||
y1="11"
|
||||
x2="16"
|
||||
y2="11"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<h2 class="font-heading text-body text-white">
|
||||
{m.settings_members_title({
|
||||
count: String(members.length),
|
||||
})}
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
icon="person_add"
|
||||
onclick={() => (showInviteModal = true)}
|
||||
>
|
||||
{m.settings_members_invite()}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Pending Invites -->
|
||||
{#if invites.length > 0}
|
||||
<Card>
|
||||
<div class="p-4">
|
||||
<h3 class="text-sm font-medium text-light/70 mb-3">
|
||||
{m.settings_members_pending()}
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
{#each invites as invite}
|
||||
<div
|
||||
class="flex items-center justify-between py-2 px-3 bg-light/5 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<p class="text-light">{invite.email}</p>
|
||||
<p class="text-xs text-light/40">
|
||||
Invited as {invite.role} • Expires {new Date(
|
||||
invite.expires_at,
|
||||
).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onclick={() =>
|
||||
navigator.clipboard.writeText(
|
||||
`${window.location.origin}/invite/${invite.token}`,
|
||||
)}
|
||||
>{m.settings_members_copy_link()}</Button
|
||||
>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onclick={() => cancelInvite(invite.id)}
|
||||
>Cancel</Button
|
||||
>
|
||||
</div>
|
||||
<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>
|
||||
<div class="space-y-2">
|
||||
{#each invites as invite}
|
||||
<div
|
||||
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-[11px] text-light/40">
|
||||
Invited as {invite.role} • Expires {new Date(
|
||||
invite.expires_at,
|
||||
).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
|
||||
onclick={() =>
|
||||
navigator.clipboard.writeText(
|
||||
`${window.location.origin}/invite/${invite.token}`,
|
||||
)}
|
||||
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
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 text-light/40 hover:text-error hover:bg-error/10 rounded-lg transition-colors"
|
||||
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
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Members List -->
|
||||
<Card>
|
||||
<div class="divide-y divide-light/10">
|
||||
<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}
|
||||
{@const profile = Array.isArray(rawProfile)
|
||||
? rawProfile[0]
|
||||
: rawProfile}
|
||||
<div
|
||||
class="flex items-center justify-between p-4 hover:bg-light/5 transition-colors"
|
||||
class="flex items-center justify-between px-4 py-3 hover:bg-light/5 transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium"
|
||||
>
|
||||
{(profile?.full_name ||
|
||||
profile?.email ||
|
||||
"?")[0].toUpperCase()}
|
||||
</div>
|
||||
<Avatar
|
||||
name={profile?.full_name || profile?.email || "?"}
|
||||
src={profile?.avatar_url}
|
||||
size="sm"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-light font-medium">
|
||||
<p class="text-body-sm text-white">
|
||||
{profile?.full_name ||
|
||||
profile?.email ||
|
||||
"Unknown User"}
|
||||
</p>
|
||||
<p class="text-sm text-light/50">
|
||||
<p class="text-[11px] text-light/40">
|
||||
{profile?.email || "No email"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="px-2 py-1 text-xs rounded-full capitalize"
|
||||
class="px-2 py-0.5 text-[10px] rounded-md capitalize font-body"
|
||||
style="background-color: {roles.find(
|
||||
(r) => r.name.toLowerCase() === member.role,
|
||||
)?.color ?? '#6366f1'}20; color: {roles.find(
|
||||
@@ -283,62 +359,78 @@
|
||||
)?.color ?? '#6366f1'}">{member.role}</span
|
||||
>
|
||||
{#if member.user_id !== userId && member.role !== "owner"}
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
|
||||
onclick={() => openMemberModal(member)}
|
||||
>Edit</Button
|
||||
title="Edit"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invite Member Modal -->
|
||||
<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>
|
||||
@@ -347,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}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Button, Modal, Card, Input } from "$lib/components/ui";
|
||||
import { Button, Modal, Input } 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";
|
||||
@@ -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) {
|
||||
@@ -188,86 +188,98 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-4 max-w-2xl">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-light">Roles</h2>
|
||||
<p class="text-sm text-light/50">
|
||||
<h2 class="font-heading text-body text-white">Roles</h2>
|
||||
<p class="text-body-sm text-light/40 mt-0.5">
|
||||
Create custom roles with specific permissions.
|
||||
</p>
|
||||
</div>
|
||||
<Button onclick={() => openRoleModal()} icon="add">
|
||||
<Button size="sm" onclick={() => openRoleModal()} icon="add">
|
||||
Create Role
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each roles as role}
|
||||
<Card>
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
style="background-color: {role.color}"
|
||||
></div>
|
||||
<span class="font-medium text-light"
|
||||
>{role.name}</span
|
||||
>
|
||||
{#if role.is_system}
|
||||
<span
|
||||
class="text-xs text-light/40 bg-light/10 px-2 py-0.5 rounded"
|
||||
>System</span
|
||||
>
|
||||
{/if}
|
||||
{#if role.is_default}
|
||||
<span
|
||||
class="text-xs text-primary bg-primary/10 px-2 py-0.5 rounded"
|
||||
>Default</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if !role.is_system || role.name !== "Owner"}
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onclick={() => openRoleModal(role)}
|
||||
>Edit</Button
|
||||
>
|
||||
{/if}
|
||||
{#if !role.is_system}
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onclick={() => deleteRole(role)}
|
||||
>Delete</Button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#if role.permissions.includes("*")}
|
||||
<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
|
||||
>
|
||||
{#if role.is_system}
|
||||
<span
|
||||
class="text-xs bg-light/10 text-light/70 px-2 py-1 rounded"
|
||||
>All Permissions</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
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
{#if !role.is_system || role.name !== "Owner"}
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
|
||||
onclick={() => openRoleModal(role)}
|
||||
title="Edit"
|
||||
>
|
||||
{:else}
|
||||
{#each role.permissions.slice(0, 6) as perm}
|
||||
<span
|
||||
class="text-xs bg-light/10 text-light/50 px-2 py-1 rounded"
|
||||
>{perm}</span
|
||||
class="material-symbols-rounded"
|
||||
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
|
||||
>edit</span
|
||||
>
|
||||
{/each}
|
||||
{#if role.permissions.length > 6}
|
||||
<span class="text-xs text-light/40"
|
||||
>+{role.permissions.length - 6} more</span
|
||||
</button>
|
||||
{/if}
|
||||
{#if !role.is_system}
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 text-light/40 hover:text-error hover:bg-error/10 rounded-lg transition-colors"
|
||||
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
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<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
|
||||
>
|
||||
{: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
|
||||
>
|
||||
{/each}
|
||||
{#if role.permissions.length > 6}
|
||||
<span class="text-[10px] text-light/30"
|
||||
>+{role.permissions.length - 6} more</span
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -278,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}
|
||||
@@ -304,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"
|
||||
@@ -325,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>
|
||||
@@ -335,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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
130
src/lib/components/ui/ActivityFeed.svelte
Normal file
130
src/lib/components/ui/ActivityFeed.svelte
Normal file
@@ -0,0 +1,130 @@
|
||||
<script lang="ts">
|
||||
import * as m from "$lib/paraglide/messages";
|
||||
|
||||
interface ActivityEntry {
|
||||
id: string;
|
||||
action: string;
|
||||
entity_type: string;
|
||||
entity_id: string | null;
|
||||
entity_name: string | null;
|
||||
created_at: string | null;
|
||||
profiles: {
|
||||
full_name: string | null;
|
||||
email: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
entries: ActivityEntry[];
|
||||
emptyLabel?: string;
|
||||
}
|
||||
|
||||
let { entries, emptyLabel }: Props = $props();
|
||||
|
||||
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 formatTimeAgo(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 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"]!)();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if entries.length === 0}
|
||||
<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;"
|
||||
>history</span
|
||||
>
|
||||
<p class="text-body-sm">{emptyLabel ?? m.activity_empty()}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#each entries as entry}
|
||||
<div
|
||||
class="flex items-start gap-3 px-3 py-2 rounded-xl hover:bg-dark/50 transition-colors"
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded {getActivityColor(
|
||||
entry.action,
|
||||
)} mt-0.5 shrink-0"
|
||||
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
|
||||
>{getActivityIcon(entry.action)}</span
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-body-sm text-light/70 leading-relaxed">
|
||||
{getDescription(entry)}
|
||||
</p>
|
||||
</div>
|
||||
<span class="text-[11px] text-light/30 shrink-0 mt-0.5"
|
||||
>{formatTimeAgo(entry.created_at)}</span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
63
src/lib/components/ui/Breadcrumb.svelte
Normal file
63
src/lib/components/ui/Breadcrumb.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
109
src/lib/components/ui/ContentSkeleton.svelte
Normal file
109
src/lib/components/ui/ContentSkeleton.svelte
Normal file
@@ -0,0 +1,109 @@
|
||||
<script lang="ts">
|
||||
import Skeleton from "./Skeleton.svelte";
|
||||
|
||||
interface Props {
|
||||
variant?: "default" | "kanban" | "files" | "calendar" | "settings" | "list" | "detail";
|
||||
}
|
||||
|
||||
let { variant = "default" }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex-1 p-6 animate-in">
|
||||
{#if variant === "kanban"}
|
||||
<div class="flex gap-3 h-full overflow-hidden">
|
||||
{#each Array(3) as _}
|
||||
<div class="flex-shrink-0 w-[256px] bg-dark/20 rounded-xl p-4 flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Skeleton variant="text" width="120px" height="1.25rem" />
|
||||
<Skeleton variant="rectangular" width="24px" height="20px" class="rounded-lg" />
|
||||
</div>
|
||||
{#each Array(3) as __}
|
||||
<Skeleton variant="card" height="72px" class="rounded-xl" />
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if variant === "files"}
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<Skeleton variant="text" width="300px" height="2.5rem" class="rounded-xl" />
|
||||
<div class="flex-1"></div>
|
||||
<Skeleton variant="circular" width="36px" height="36px" />
|
||||
<Skeleton variant="circular" width="36px" height="36px" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
{#each Array(12) as _}
|
||||
<Skeleton variant="card" height="100px" class="rounded-xl" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if variant === "calendar"}
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<Skeleton variant="circular" width="32px" height="32px" />
|
||||
<Skeleton variant="text" width="200px" height="1.5rem" />
|
||||
<Skeleton variant="circular" width="32px" height="32px" />
|
||||
<div class="flex-1"></div>
|
||||
<Skeleton variant="rectangular" width="200px" height="32px" class="rounded-xl" />
|
||||
</div>
|
||||
<div class="grid grid-cols-7 gap-1">
|
||||
{#each Array(7) as _}
|
||||
<Skeleton variant="text" width="100%" height="2rem" />
|
||||
{/each}
|
||||
{#each Array(35) as _}
|
||||
<Skeleton variant="rectangular" width="100%" height="72px" class="rounded-none" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if variant === "settings"}
|
||||
<div class="flex flex-col gap-4">
|
||||
<Skeleton variant="text" width="160px" height="1.5rem" />
|
||||
<Skeleton variant="text" lines={3} />
|
||||
<Skeleton variant="rectangular" width="100%" height="48px" class="rounded-xl" />
|
||||
<Skeleton variant="rectangular" width="100%" height="48px" class="rounded-xl" />
|
||||
</div>
|
||||
{:else if variant === "list"}
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
|
||||
{#each Array(4) as _}
|
||||
<Skeleton variant="card" height="72px" class="rounded-xl" />
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each Array(5) as _}
|
||||
<Skeleton variant="rectangular" height="64px" class="rounded-xl" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if variant === "detail"}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="lg:col-span-2 flex flex-col gap-4">
|
||||
<Skeleton variant="card" height="200px" class="rounded-xl" />
|
||||
<Skeleton variant="card" height="300px" class="rounded-xl" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Skeleton variant="card" height="180px" class="rounded-xl" />
|
||||
<Skeleton variant="card" height="120px" class="rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
|
||||
{#each Array(4) as _}
|
||||
<Skeleton variant="card" height="72px" class="rounded-xl" />
|
||||
{/each}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="lg:col-span-2">
|
||||
<Skeleton variant="card" height="300px" class="rounded-xl" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Skeleton variant="card" height="140px" class="rounded-xl" />
|
||||
<Skeleton variant="card" height="200px" class="rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.animate-in {
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
@@ -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"
|
||||
|
||||
114
src/lib/components/ui/EventCard.svelte
Normal file
114
src/lib/components/ui/EventCard.svelte
Normal file
@@ -0,0 +1,114 @@
|
||||
<script lang="ts">
|
||||
import StatusBadge from "./StatusBadge.svelte";
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
slug: string;
|
||||
status: string;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
color: string | null;
|
||||
venueName: string | null;
|
||||
href: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
name,
|
||||
slug,
|
||||
status,
|
||||
startDate,
|
||||
endDate,
|
||||
color,
|
||||
venueName,
|
||||
href,
|
||||
compact = false,
|
||||
}: Props = $props();
|
||||
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return "";
|
||||
return new Date(dateStr).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if compact}
|
||||
<!-- Compact variant: single row for lists/sidebars -->
|
||||
<a
|
||||
{href}
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-dark/50 transition-colors group"
|
||||
>
|
||||
<div
|
||||
class="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style="background-color: {color || '#00A3E0'}"
|
||||
></div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="text-body-sm text-white group-hover:text-primary transition-colors truncate"
|
||||
>
|
||||
{name}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 mt-0.5">
|
||||
{#if startDate}
|
||||
<span class="text-[11px] text-light/40"
|
||||
>{formatDate(startDate)}{endDate
|
||||
? ` - ${formatDate(endDate)}`
|
||||
: ""}</span
|
||||
>
|
||||
{/if}
|
||||
{#if venueName}
|
||||
<span class="text-[11px] text-light/30">· {venueName}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge {status} />
|
||||
</a>
|
||||
{:else}
|
||||
<!-- Full card variant: for grid layouts -->
|
||||
<a
|
||||
{href}
|
||||
class="group bg-dark/30 hover:bg-dark/60 border border-light/5 hover:border-light/10 rounded-2xl p-5 flex flex-col gap-3 transition-all"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
style="background-color: {color || '#00A3E0'}"
|
||||
></div>
|
||||
<h3
|
||||
class="text-body font-heading text-white group-hover:text-primary transition-colors truncate"
|
||||
>
|
||||
{name}
|
||||
</h3>
|
||||
</div>
|
||||
<StatusBadge {status} />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 text-[12px] text-light/40">
|
||||
{#if startDate}
|
||||
<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
|
||||
>
|
||||
{formatDate(startDate)}{endDate
|
||||
? ` - ${formatDate(endDate)}`
|
||||
: ""}
|
||||
</span>
|
||||
{/if}
|
||||
{#if venueName}
|
||||
<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;"
|
||||
>location_on</span
|
||||
>
|
||||
{venueName}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
||||
@@ -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" />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user