Mega push vol 5, working on messaging now

This commit is contained in:
AlacrisDevs
2026-02-07 01:31:55 +02:00
parent d8bbfd9dc3
commit e55881b38b
77 changed files with 8478 additions and 1554 deletions

View File

@@ -2,3 +2,8 @@ PUBLIC_SUPABASE_URL=your_supabase_url
PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
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=

31
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Lint (svelte-check)
run: npm run check
- name: Unit tests
run: npx vitest run --project server
- name: Build
run: npm run build

5
.gitignore vendored
View File

@@ -64,6 +64,8 @@ lerna-debug.log*
coverage
.nyc_output
*.lcov
tests/e2e/.auth/
test-results/
# Typescript
*.tsbuildinfo
@@ -89,3 +91,6 @@ supabase/.temp
# Sentry
.sentryclirc
# Paraglide
src/lib/paraglide
project.inlang/cache/

View File

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

255
messages/en.json Normal file
View File

@@ -0,0 +1,255 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"app_name": "Root",
"nav_files": "Files",
"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_create": "Create",
"btn_cancel": "Cancel",
"btn_save": "Save",
"btn_delete": "Delete",
"btn_edit": "Edit",
"btn_close": "Close",
"btn_upload": "Upload",
"btn_remove": "Remove",
"login_title": "Welcome to Root",
"login_subtitle": "Your team's workspace for docs, boards, and calendars.",
"login_tab_login": "Log In",
"login_tab_signup": "Sign Up",
"login_email_label": "Email",
"login_email_placeholder": "you@example.com",
"login_password_label": "Password",
"login_password_placeholder": "••••••••",
"login_btn_login": "Log In",
"login_btn_signup": "Sign Up",
"login_or_continue": "or continue with",
"login_google": "Continue with Google",
"login_signup_prompt": "Don't have an account?",
"login_login_prompt": "Already have an account?",
"login_signup_success_title": "Check your email",
"login_signup_success_text": "We sent a confirmation link to {email}. Click it to activate your account.",
"org_selector_title": "Your Organizations",
"org_selector_create": "Create Organization",
"org_selector_create_title": "Create Organization",
"org_selector_name_label": "Organization Name",
"org_selector_name_placeholder": "My Team",
"org_selector_slug_label": "URL Slug",
"org_selector_slug_placeholder": "my-team",
"org_overview": "Organization Overview",
"files_title": "Files",
"files_breadcrumb_home": "Home",
"files_create_title": "Create New",
"files_type_document": "Document",
"files_type_folder": "Folder",
"files_type_kanban": "Kanban",
"files_name_label": "Name",
"files_doc_placeholder": "Document name",
"files_folder_placeholder": "Folder name",
"files_kanban_placeholder": "Kanban board name",
"files_rename_title": "Rename",
"files_context_rename": "Rename",
"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_toggle_view": "Toggle view",
"kanban_title": "Kanban",
"kanban_create_board": "Create Board",
"kanban_board_name_label": "Board Name",
"kanban_board_name_placeholder": "e.g. Sprint 1",
"kanban_edit_board": "Edit Board",
"kanban_rename_board": "Rename Board",
"kanban_delete_board": "Delete Board",
"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_card_details": "Card Details",
"kanban_card_title_label": "Title",
"kanban_card_title_placeholder": "Card title",
"kanban_card_desc_label": "Description",
"kanban_card_desc_placeholder": "Add a more detailed description...",
"kanban_tags": "Tags",
"kanban_tag_placeholder": "Tag name",
"kanban_tag_add": "Add",
"kanban_empty": "Kanban boards are now managed in Files",
"kanban_go_to_files": "Go to Files",
"kanban_select_board": "Select a board above",
"calendar_title": "Calendar",
"calendar_subscribe": "Subscribe to Calendar",
"calendar_refresh": "Refresh Events",
"calendar_settings": "Calendar Settings",
"calendar_create_event": "Create Event",
"calendar_edit_event": "Edit Event",
"calendar_event_title": "Title",
"calendar_event_title_placeholder": "Event title",
"calendar_event_date": "Date",
"calendar_event_time": "Time",
"calendar_event_desc": "Description",
"settings_title": "Settings",
"settings_tab_general": "General",
"settings_tab_members": "Members",
"settings_tab_roles": "Roles",
"settings_tab_tags": "Tags",
"settings_tab_integrations": "Integrations",
"settings_general_title": "Organization details",
"settings_general_avatar": "Avatar",
"settings_general_name": "Name",
"settings_general_name_placeholder": "Organization name",
"settings_general_slug": "URL slug (yoursite.com/...)",
"settings_general_slug_placeholder": "my-org",
"settings_general_save": "Save Changes",
"settings_general_danger_zone": "Danger Zone",
"settings_general_delete_org": "Delete Organization",
"settings_general_delete_org_desc": "Permanently delete this organization and all its data.",
"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_tags_title": "Organization Tags",
"settings_tags_desc": "Manage tags that can be used across all Kanban boards.",
"settings_tags_create": "Create Tag",
"settings_tags_empty": "No tags yet. Create your first tag to organize Kanban cards.",
"settings_tags_name_placeholder": "Tag name",
"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_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_invite_title": "Invite Member",
"settings_invite_email": "Email address",
"settings_invite_email_placeholder": "colleague@example.com",
"settings_invite_role": "Role",
"settings_invite_send": "Send Invite",
"settings_invite_role_viewer": "Viewer - Can view content",
"settings_invite_role_commenter": "Commenter - Can view and comment",
"settings_invite_role_editor": "Editor - Can create and edit content",
"settings_invite_role_admin": "Admin - Can manage members and settings",
"settings_edit_member": "Edit Member",
"settings_roles_title": "Roles",
"settings_roles_desc": "Create custom roles with specific permissions.",
"settings_roles_create": "Create Role",
"settings_roles_edit": "Edit Role",
"settings_roles_system": "System",
"settings_roles_default": "Default",
"settings_roles_all_perms": "All Permissions",
"settings_roles_more": "+{count} more",
"settings_roles_name_label": "Name",
"settings_roles_name_placeholder": "e.g., Moderator",
"settings_roles_color": "Color",
"settings_roles_permissions": "Permissions",
"settings_integrations_google_cal": "Google Calendar",
"settings_integrations_google_cal_desc": "Share a Google Calendar with all organization members.",
"settings_integrations_connected": "Connected",
"settings_integrations_disconnect": "Disconnect",
"settings_integrations_connect": "Connect Google Calendar",
"settings_integrations_discord": "Discord",
"settings_integrations_discord_desc": "Get notifications in your Discord server.",
"settings_integrations_slack": "Slack",
"settings_integrations_slack_desc": "Get notifications in your Slack workspace.",
"settings_integrations_coming_soon": "Coming soon",
"settings_connect_cal_title": "Connect Public Google Calendar",
"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_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",
"settings_connect_cal_input_placeholder": "Paste calendar URL or ID (e.g., abc123@group.calendar.google.com)",
"settings_connect_cal_btn": "Connect",
"account_title": "Account Settings",
"account_subtitle": "Manage your personal profile and preferences.",
"account_profile": "Profile",
"account_photo": "Photo",
"account_sync_google": "Sync Google",
"account_remove_photo": "Remove photo",
"account_display_name": "Display Name",
"account_display_name_placeholder": "Your name",
"account_email": "Email",
"account_save_profile": "Save Profile",
"account_appearance": "Appearance",
"account_theme": "Theme",
"account_theme_dark": "Dark",
"account_theme_light": "Light (Coming Soon)",
"account_theme_system": "System (Coming Soon)",
"account_accent_color": "Accent Color",
"account_use_org_theme": "Use Organization Theme",
"account_use_org_theme_desc": "Override your personal theme with the org's settings.",
"account_language": "Language",
"account_language_desc": "Choose your preferred language for the interface.",
"account_save_preferences": "Save Preferences",
"account_security": "Security & Sessions",
"account_password": "Password",
"account_password_desc": "If you signed in with Google, your password is managed by Google. Otherwise, you can reset your password via email.",
"account_send_reset": "Send Reset Email",
"account_active_sessions": "Active Sessions",
"account_sessions_desc": "Sign out of all other sessions if you suspect unauthorized access.",
"account_signout_others": "Sign Out Other Sessions",
"editor_save": "Save",
"editor_saving": "Saving...",
"editor_saved": "Saved",
"editor_error": "Error",
"editor_placeholder": "Start writing...",
"editor_bold": "Bold (Ctrl+B)",
"editor_italic": "Italic (Ctrl+I)",
"editor_strikethrough": "Strikethrough",
"editor_bullet_list": "Bullet List",
"editor_numbered_list": "Numbered List",
"editor_quote": "Quote",
"editor_code_block": "Code Block",
"toast_error_delete_org": "Failed to delete organization.",
"toast_error_leave_org": "Failed to leave organization.",
"toast_error_invite": "Failed to send invite: {error}",
"toast_error_update_role": "Failed to update role.",
"toast_error_remove_member": "Failed to remove member.",
"toast_error_delete_role": "Failed to delete role.",
"toast_error_disconnect_cal": "Failed to disconnect calendar.",
"toast_error_reset_email": "Failed to send reset email.",
"toast_success_reset_email": "Password reset email sent.",
"toast_success_signout_others": "Other sessions signed out.",
"confirm_delete_role": "Delete role \"{name}\"? Members with this role will need to be reassigned.",
"confirm_leave_org": "Are you sure you want to leave {orgName}?",
"confirm_delete_org": "Type \"{orgName}\" to confirm deletion:",
"confirm_remove_member": "Remove {name} from the organization?",
"confirm_disconnect_cal": "Disconnect Google Calendar?",
"role_viewer": "Viewer",
"role_commenter": "Commenter",
"role_editor": "Editor",
"role_admin": "Admin",
"role_owner": "Owner",
"error_owner_cant_leave": "Owners cannot leave. Transfer ownership first or delete the organization.",
"overview_title": "Organization Overview",
"overview_stat_members": "Members",
"overview_stat_documents": "Documents",
"overview_stat_folders": "Folders",
"overview_stat_boards": "Boards",
"overview_quick_links": "Quick Links",
"activity_title": "Recent Activity",
"activity_empty": "No recent activity yet.",
"activity_created": "{user} created {entityType} \"{name}\"",
"activity_updated": "{user} updated {entityType} \"{name}\"",
"activity_deleted": "{user} deleted {entityType} \"{name}\"",
"activity_moved": "{user} moved {entityType} \"{name}\"",
"activity_renamed": "{user} renamed {entityType} \"{name}\"",
"activity_just_now": "Just now",
"activity_minutes_ago": "{count}m ago",
"activity_hours_ago": "{count}h ago",
"activity_days_ago": "{count}d ago",
"entity_document": "document",
"entity_folder": "folder",
"entity_kanban_board": "board",
"entity_kanban_card": "card",
"entity_kanban_column": "column",
"entity_member": "member",
"entity_role": "role",
"entity_invite": "invite"
}

255
messages/et.json Normal file
View File

@@ -0,0 +1,255 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"app_name": "Root",
"nav_files": "Failid",
"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_create": "Loo",
"btn_cancel": "Tühista",
"btn_save": "Salvesta",
"btn_delete": "Kustuta",
"btn_edit": "Muuda",
"btn_close": "Sulge",
"btn_upload": "Laadi üles",
"btn_remove": "Eemalda",
"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",
"login_email_label": "E-post",
"login_email_placeholder": "sina@näide.ee",
"login_password_label": "Parool",
"login_password_placeholder": "••••••••",
"login_btn_login": "Logi sisse",
"login_btn_signup": "Registreeru",
"login_or_continue": "või jätka",
"login_google": "Jätka Google'iga",
"login_signup_prompt": "Pole kontot?",
"login_login_prompt": "On juba konto?",
"login_signup_success_title": "Kontrolli oma e-posti",
"login_signup_success_text": "Saatsime kinnituslingi aadressile {email}. Kliki sellel, et oma konto aktiveerida.",
"org_selector_title": "Sinu organisatsioonid",
"org_selector_create": "Loo organisatsioon",
"org_selector_create_title": "Loo organisatsioon",
"org_selector_name_label": "Organisatsiooni nimi",
"org_selector_name_placeholder": "Minu meeskond",
"org_selector_slug_label": "URL-i lühend",
"org_selector_slug_placeholder": "minu-meeskond",
"org_overview": "Organisatsiooni ülevaade",
"files_title": "Failid",
"files_breadcrumb_home": "Avaleht",
"files_create_title": "Loo uus",
"files_type_document": "Dokument",
"files_type_folder": "Kaust",
"files_type_kanban": "Kanban",
"files_name_label": "Nimi",
"files_doc_placeholder": "Dokumendi nimi",
"files_folder_placeholder": "Kausta nimi",
"files_kanban_placeholder": "Kanban tahvli nimi",
"files_rename_title": "Nimeta ümber",
"files_context_rename": "Nimeta ümber",
"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_toggle_view": "Vaheta vaadet",
"kanban_title": "Kanban",
"kanban_create_board": "Loo tahvel",
"kanban_board_name_label": "Tahvli nimi",
"kanban_board_name_placeholder": "nt Sprint 1",
"kanban_edit_board": "Muuda tahvlit",
"kanban_rename_board": "Nimeta tahvel ümber",
"kanban_delete_board": "Kustuta tahvel",
"kanban_add_column": "Lisa veerg",
"kanban_column_name_label": "Veeru nimi",
"kanban_column_name_placeholder": "nt Teha, Töös, Valmis",
"kanban_add_card": "Lisa kaart",
"kanban_card_details": "Kaardi üksikasjad",
"kanban_card_title_label": "Pealkiri",
"kanban_card_title_placeholder": "Kaardi pealkiri",
"kanban_card_desc_label": "Kirjeldus",
"kanban_card_desc_placeholder": "Lisa üksikasjalikum kirjeldus...",
"kanban_tags": "Sildid",
"kanban_tag_placeholder": "Sildi nimi",
"kanban_tag_add": "Lisa",
"kanban_empty": "Kanban tahvleid hallatakse nüüd Failides",
"kanban_go_to_files": "Mine Failidesse",
"kanban_select_board": "Vali tahvel ülalt",
"calendar_title": "Kalender",
"calendar_subscribe": "Telli kalender",
"calendar_refresh": "Värskenda sündmusi",
"calendar_settings": "Kalendri seaded",
"calendar_create_event": "Loo sündmus",
"calendar_edit_event": "Muuda sündmust",
"calendar_event_title": "Pealkiri",
"calendar_event_title_placeholder": "Sündmuse pealkiri",
"calendar_event_date": "Kuupäev",
"calendar_event_time": "Kellaaeg",
"calendar_event_desc": "Kirjeldus",
"settings_title": "Seaded",
"settings_tab_general": "Üldine",
"settings_tab_members": "Liikmed",
"settings_tab_roles": "Rollid",
"settings_tab_tags": "Sildid",
"settings_tab_integrations": "Integratsioonid",
"settings_general_title": "Organisatsiooni andmed",
"settings_general_avatar": "Avatar",
"settings_general_name": "Nimi",
"settings_general_name_placeholder": "Organisatsiooni nimi",
"settings_general_slug": "URL-i lühend (sait.ee/...)",
"settings_general_slug_placeholder": "minu-org",
"settings_general_save": "Salvesta muudatused",
"settings_general_danger_zone": "Ohutsoon",
"settings_general_delete_org": "Kustuta organisatsioon",
"settings_general_delete_org_desc": "Kustuta see organisatsioon ja kõik selle andmed jäädavalt.",
"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_tags_title": "Organisatsiooni sildid",
"settings_tags_desc": "Halda silte, mida saab kasutada kõigil Kanban tahvlitel.",
"settings_tags_create": "Loo silt",
"settings_tags_empty": "Silte pole veel. Loo oma esimene silt Kanban kaartide korraldamiseks.",
"settings_tags_name_placeholder": "Sildi nimi",
"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_copy_link": "Kopeeri link",
"settings_members_unknown": "Tundmatu kasutaja",
"settings_members_no_name": "Nimi puudub",
"settings_members_no_email": "E-post puudub",
"settings_members_remove": "Eemalda organisatsioonist",
"settings_invite_title": "Kutsu liige",
"settings_invite_email": "E-posti aadress",
"settings_invite_email_placeholder": "kolleeg@näide.ee",
"settings_invite_role": "Roll",
"settings_invite_send": "Saada kutse",
"settings_invite_role_viewer": "Vaataja - Saab sisu vaadata",
"settings_invite_role_commenter": "Kommenteerija - Saab vaadata ja kommenteerida",
"settings_invite_role_editor": "Toimetaja - Saab sisu luua ja muuta",
"settings_invite_role_admin": "Admin - Saab hallata liikmeid ja seadeid",
"settings_edit_member": "Muuda liiget",
"settings_roles_title": "Rollid",
"settings_roles_desc": "Loo kohandatud rolle kindlate õigustega.",
"settings_roles_create": "Loo roll",
"settings_roles_edit": "Muuda rolli",
"settings_roles_system": "Süsteemne",
"settings_roles_default": "Vaikimisi",
"settings_roles_all_perms": "Kõik õigused",
"settings_roles_more": "+{count} veel",
"settings_roles_name_label": "Nimi",
"settings_roles_name_placeholder": "nt Moderaator",
"settings_roles_color": "Värv",
"settings_roles_permissions": "Õigused",
"settings_integrations_google_cal": "Google'i kalender",
"settings_integrations_google_cal_desc": "Jaga Google'i kalendrit kõigi organisatsiooni liikmetega.",
"settings_integrations_connected": "Ühendatud",
"settings_integrations_disconnect": "Katkesta ühendus",
"settings_integrations_connect": "Ühenda Google'i kalender",
"settings_integrations_discord": "Discord",
"settings_integrations_discord_desc": "Saa teavitusi oma Discordi serveris.",
"settings_integrations_slack": "Slack",
"settings_integrations_slack_desc": "Saa teavitusi oma Slacki tööruumis.",
"settings_integrations_coming_soon": "Tulekul",
"settings_connect_cal_title": "Ühenda avalik Google'i kalender",
"settings_connect_cal_desc": "Kleebi oma Google'i kalendri jagamislink või kalendri ID. Kalender peab olema Google'i kalendri seadetes avalikuks seatud.",
"settings_connect_cal_how": "Kuidas saada kalendri linki:",
"settings_connect_cal_step1": "Ava Google'i kalender",
"settings_connect_cal_step2": "Kliki kalendri kõrval 3 punkti → Seaded",
"settings_connect_cal_step3": "\"Juurdepääsuõiguste\" all märgi \"Tee avalikuks\"",
"settings_connect_cal_step4": "Keri alla \"Kalendri integreerimine\" ja kopeeri kalendri ID või avalik URL",
"settings_connect_cal_input_label": "Kalendri URL või ID",
"settings_connect_cal_input_placeholder": "Kleebi kalendri URL või ID (nt abc123@group.calendar.google.com)",
"settings_connect_cal_btn": "Ühenda",
"account_title": "Konto seaded",
"account_subtitle": "Halda oma isiklikku profiili ja eelistusi.",
"account_profile": "Profiil",
"account_photo": "Foto",
"account_sync_google": "Sünkrooni Google",
"account_remove_photo": "Eemalda foto",
"account_display_name": "Kuvatav nimi",
"account_display_name_placeholder": "Sinu nimi",
"account_email": "E-post",
"account_save_profile": "Salvesta profiil",
"account_appearance": "Välimus",
"account_theme": "Teema",
"account_theme_dark": "Tume",
"account_theme_light": "Hele (tulekul)",
"account_theme_system": "Süsteemne (tulekul)",
"account_accent_color": "Aktsentvärv",
"account_use_org_theme": "Kasuta organisatsiooni teemat",
"account_use_org_theme_desc": "Asenda oma isiklik teema organisatsiooni seadetega.",
"account_language": "Keel",
"account_language_desc": "Vali eelistatud liidese keel.",
"account_save_preferences": "Salvesta eelistused",
"account_security": "Turvalisus ja seansid",
"account_password": "Parool",
"account_password_desc": "Kui logisid sisse Google'iga, haldab sinu parooli Google. Muul juhul saad parooli e-posti teel lähtestada.",
"account_send_reset": "Saada lähtestamise e-kiri",
"account_active_sessions": "Aktiivsed seansid",
"account_sessions_desc": "Logi välja kõigist teistest seanssidest, kui kahtlustad volitamata juurdepääsu.",
"account_signout_others": "Logi teised seansid välja",
"editor_save": "Salvesta",
"editor_saving": "Salvestamine...",
"editor_saved": "Salvestatud",
"editor_error": "Viga",
"editor_placeholder": "Alusta kirjutamist...",
"editor_bold": "Paks (Ctrl+B)",
"editor_italic": "Kursiiv (Ctrl+I)",
"editor_strikethrough": "Läbikriipsutus",
"editor_bullet_list": "Täpploend",
"editor_numbered_list": "Nummerdatud loend",
"editor_quote": "Tsitaat",
"editor_code_block": "Koodiplokk",
"toast_error_delete_org": "Organisatsiooni kustutamine ebaõnnestus.",
"toast_error_leave_org": "Organisatsioonist lahkumine ebaõnnestus.",
"toast_error_invite": "Kutse saatmine ebaõnnestus: {error}",
"toast_error_update_role": "Rolli uuendamine ebaõnnestus.",
"toast_error_remove_member": "Liikme eemaldamine ebaõnnestus.",
"toast_error_delete_role": "Rolli kustutamine ebaõnnestus.",
"toast_error_disconnect_cal": "Kalendri lahtiühendamine ebaõnnestus.",
"toast_error_reset_email": "Lähtestamise e-kirja saatmine ebaõnnestus.",
"toast_success_reset_email": "Parooli lähtestamise e-kiri saadetud.",
"toast_success_signout_others": "Teised seansid välja logitud.",
"confirm_delete_role": "Kustuta roll \"{name}\"? Selle rolliga liikmed tuleb ümber määrata.",
"confirm_leave_org": "Kas oled kindel, et soovid lahkuda {orgName}?",
"confirm_delete_org": "Sisesta \"{orgName}\" kustutamise kinnitamiseks:",
"confirm_remove_member": "Eemalda {name} organisatsioonist?",
"confirm_disconnect_cal": "Katkesta Google'i kalendri ühendus?",
"role_viewer": "Vaataja",
"role_commenter": "Kommenteerija",
"role_editor": "Toimetaja",
"role_admin": "Admin",
"role_owner": "Omanik",
"error_owner_cant_leave": "Omanikud ei saa lahkuda. Kõigepealt anna omandiõigus üle või kustuta organisatsioon.",
"overview_title": "Organisatsiooni ülevaade",
"overview_stat_members": "Liikmed",
"overview_stat_documents": "Dokumendid",
"overview_stat_folders": "Kaustad",
"overview_stat_boards": "Tahvlid",
"overview_quick_links": "Kiirlingid",
"activity_title": "Viimane tegevus",
"activity_empty": "Viimast tegevust pole veel.",
"activity_created": "{user} lõi {entityType} \"{name}\"",
"activity_updated": "{user} uuendas {entityType} \"{name}\"",
"activity_deleted": "{user} kustutas {entityType} \"{name}\"",
"activity_moved": "{user} teisaldas {entityType} \"{name}\"",
"activity_renamed": "{user} nimetas ümber {entityType} \"{name}\"",
"activity_just_now": "Just praegu",
"activity_minutes_ago": "{count} min tagasi",
"activity_hours_ago": "{count}t tagasi",
"activity_days_ago": "{count}p tagasi",
"entity_document": "dokumendi",
"entity_folder": "kausta",
"entity_kanban_board": "tahvli",
"entity_kanban_card": "kaardi",
"entity_kanban_column": "veeru",
"entity_member": "liikme",
"entity_role": "rolli",
"entity_invite": "kutse"
}

1093
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,8 @@
"test": "npm run test:unit -- --run"
},
"devDependencies": {
"@inlang/paraglide-js": "^2.10.0",
"@playwright/test": "^1.58.1",
"@sveltejs/adapter-node": "^5.5.2",
"@sveltejs/kit": "^2.50.1",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
@@ -31,11 +33,13 @@
"vitest-browser-svelte": "^2.0.2"
},
"dependencies": {
"@inlang/paraglide-js": "^2.10.0",
"@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.94.0",
"@tiptap/core": "^3.19.0",
"@tiptap/extension-placeholder": "^3.19.0",
"@tiptap/pm": "^3.19.0",
"@tiptap/starter-kit": "^3.19.0"
"@tiptap/starter-kit": "^3.19.0",
"google-auth-library": "^10.5.0"
}
}

32
playwright.config.ts Normal file
View File

@@ -0,0 +1,32 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
globalTeardown: './tests/e2e/cleanup.ts',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: 0,
workers: 1,
reporter: 'list',
timeout: 60000,
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
navigationTimeout: 30000,
},
projects: [
{
name: 'setup',
testMatch: /auth\.setup\.ts/,
},
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'tests/e2e/.auth/user.json',
},
dependencies: ['setup'],
},
],
});

View File

@@ -0,0 +1,15 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json"
},
"baseLocale": "en",
"locales": [
"en",
"et"
]
}

View File

@@ -1,11 +1,18 @@
<!doctype html>
<html lang="en">
<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"
/>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<body data-sveltekit-preload-data="tap">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -1,15 +1,42 @@
import { sequence } from '@sveltejs/kit/hooks';
import { paraglideMiddleware } from '$lib/paraglide/server';
import { createServerClient } from '@supabase/ssr';
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
import type { Handle, HandleServerError } from '@sveltejs/kit';
import type { Database } from '$lib/supabase/types';
import { createLogger } from '$lib/utils/logger';
export const handle: Handle = async ({ event, resolve }) => {
const serverLog = createLogger('server.error');
export const handleError: HandleServerError = async ({ error, event, status, message }) => {
const errorId = crypto.randomUUID().slice(0, 8);
serverLog.error(`Unhandled server error [${errorId}]`, {
error,
data: {
errorId,
status,
message,
url: event.url.pathname,
method: event.request.method
}
});
return {
message: message || 'An unexpected error occurred',
errorId,
context: `${event.request.method} ${event.url.pathname}`,
code: String(status)
};
};
const originalHandle: Handle = async ({ event, resolve }) => {
event.locals.supabase = createServerClient<Database>(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
cookies: {
getAll() {
return event.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
event.cookies.set(name, value, { ...options, path: '/' });
@@ -19,18 +46,13 @@ export const handle: Handle = async ({ event, resolve }) => {
});
event.locals.safeGetSession = async () => {
const {
data: { session }
} = await event.locals.supabase.auth.getSession();
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();
const { data: { user }, error } = await event.locals.supabase.auth.getUser();
if (error) {
return { session: null, user: null };
@@ -46,26 +68,12 @@ export const handle: Handle = async ({ event, resolve }) => {
});
};
const serverLog = createLogger('server.error');
const handleParaglide: Handle = ({ event, resolve }) => paraglideMiddleware(event.request, ({ request, locale }) => {
event.request = request;
export const handleError: HandleServerError = async ({ error, event, status, message }) => {
const errorId = crypto.randomUUID().slice(0, 8);
serverLog.error(`Unhandled server error [${errorId}]`, {
error,
data: {
errorId,
status,
message,
url: event.url.pathname,
method: event.request.method,
},
return resolve(event, {
transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale)
});
});
return {
message: message || 'An unexpected error occurred',
errorId,
context: `${event.request.method} ${event.url.pathname}`,
code: String(status),
};
};
export const handle = sequence(originalHandle, handleParaglide);

3
src/hooks.ts Normal file
View File

@@ -0,0 +1,3 @@
import { deLocalizeUrl } from '$lib/paraglide/runtime';
export const reroute = (request) => deLocalizeUrl(request.url).pathname;

38
src/lib/api/activity.ts Normal file
View File

@@ -0,0 +1,38 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database, Json } from '$lib/supabase/types';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api.activity');
export type ActivityAction = 'create' | 'update' | 'delete' | 'move' | 'rename';
export type EntityType = 'document' | 'folder' | 'kanban_board' | 'kanban_card' | 'kanban_column' | 'member' | 'role' | 'invite';
interface LogActivityParams {
orgId: string;
userId: string;
action: ActivityAction;
entityType: EntityType;
entityId?: string;
entityName?: string;
metadata?: Record<string, unknown>;
}
export async function logActivity(
supabase: SupabaseClient<Database>,
params: LogActivityParams
): Promise<void> {
const { error } = await supabase.from('activity_log').insert({
org_id: params.orgId,
user_id: params.userId,
action: params.action,
entity_type: params.entityType,
entity_id: params.entityId ?? null,
entity_name: params.entityName ?? null,
metadata: (params.metadata ?? {}) as Json,
});
if (error) {
// Activity logging should never block the main action — just warn
log.warn('Failed to log activity', { error: { message: error.message } });
}
}

View File

@@ -0,0 +1,90 @@
import { describe, it, expect } from 'vitest';
import { getMonthDays, isSameDay, formatTime } from './calendar';
describe('getMonthDays', () => {
it('returns exactly 42 days (6 weeks grid)', () => {
const days = getMonthDays(2024, 0); // January 2024
expect(days).toHaveLength(42);
});
it('first day of grid is a Monday', () => {
const days = getMonthDays(2024, 0); // January 2024
// getDay() returns 0=Sun, 1=Mon
expect(days[0].getDay()).toBe(1);
});
it('contains all days of the target month', () => {
const days = getMonthDays(2024, 1); // February 2024 (leap year, 29 days)
const febDays = days.filter(
(d) => d.getMonth() === 1 && d.getFullYear() === 2024,
);
expect(febDays).toHaveLength(29);
});
it('contains all days of a 31-day month', () => {
const days = getMonthDays(2024, 2); // March 2024
const marchDays = days.filter(
(d) => d.getMonth() === 2 && d.getFullYear() === 2024,
);
expect(marchDays).toHaveLength(31);
});
it('pads with previous month days at the start', () => {
// January 2024 starts on Monday, so no padding needed from December
const days = getMonthDays(2024, 0);
expect(days[0].getDate()).toBe(1);
expect(days[0].getMonth()).toBe(0);
});
it('pads with next month days at the end', () => {
const days = getMonthDays(2024, 0); // January 2024
const lastDay = days[days.length - 1];
// Last day should be in February
expect(lastDay.getMonth()).toBe(1);
});
it('handles December correctly (month 11)', () => {
const days = getMonthDays(2024, 11);
expect(days).toHaveLength(42);
const decDays = days.filter(
(d) => d.getMonth() === 11 && d.getFullYear() === 2024,
);
expect(decDays).toHaveLength(31);
});
});
describe('isSameDay', () => {
it('returns true for same date', () => {
const a = new Date(2024, 5, 15, 10, 30);
const b = new Date(2024, 5, 15, 22, 0);
expect(isSameDay(a, b)).toBe(true);
});
it('returns false for different days', () => {
const a = new Date(2024, 5, 15);
const b = new Date(2024, 5, 16);
expect(isSameDay(a, b)).toBe(false);
});
it('returns false for different months', () => {
const a = new Date(2024, 5, 15);
const b = new Date(2024, 6, 15);
expect(isSameDay(a, b)).toBe(false);
});
it('returns false for different years', () => {
const a = new Date(2024, 5, 15);
const b = new Date(2025, 5, 15);
expect(isSameDay(a, b)).toBe(false);
});
});
describe('formatTime', () => {
it('returns a string with hours and minutes', () => {
const date = new Date(2024, 0, 1, 14, 30);
const result = formatTime(date);
// Format varies by locale, but should contain "30" for minutes
expect(result).toContain('30');
expect(result.length).toBeGreaterThan(0);
});
});

View File

@@ -27,14 +27,7 @@ export async function getLockInfo(
const { data: lock } = await supabase
.from('document_locks')
.select(`
id,
document_id,
user_id,
locked_at,
last_heartbeat,
profiles:user_id (full_name, email)
`)
.select('id, document_id, user_id, locked_at, last_heartbeat')
.eq('document_id', documentId)
.gt('last_heartbeat', cutoff)
.single();
@@ -43,11 +36,23 @@ export async function getLockInfo(
return { isLocked: false, lockedBy: null, lockedByName: null, isOwnLock: false };
}
const profile = (lock as any).profiles; // join type not inferred by Supabase
// 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
.from('profiles')
.select('full_name, email')
.eq('id', lock.user_id)
.single();
if (profile) {
lockedByName = profile.full_name || profile.email || 'Someone';
}
}
return {
isLocked: true,
lockedBy: lock.user_id,
lockedByName: profile?.full_name || profile?.email || 'Someone',
lockedByName,
isOwnLock: lock.user_id === currentUserId,
};
}

View File

@@ -0,0 +1,132 @@
import { describe, it, expect, vi } from 'vitest';
import { createDocument, updateDocument, deleteDocument, moveDocument, copyDocument, fetchDocuments } from './documents';
// Lightweight Supabase mock builder
function mockSupabase(response: { data?: unknown; error?: unknown }) {
const chain: Record<string, unknown> = {};
const methods = ['from', 'select', 'insert', 'update', 'delete', 'eq', 'in', 'order', 'single'];
for (const m of methods) {
chain[m] = vi.fn().mockReturnValue(chain);
}
// Terminal calls resolve the response
chain['single'] = vi.fn().mockResolvedValue(response);
chain['order'] = vi.fn().mockReturnValue({ ...chain, order: vi.fn().mockResolvedValue(response) });
// For delete → eq chain (no .select().single())
const eqAfterDelete = vi.fn().mockResolvedValue(response);
const originalDelete = chain['delete'];
chain['delete'] = vi.fn().mockReturnValue({ eq: eqAfterDelete, in: vi.fn().mockResolvedValue(response) });
// For update → eq (moveDocument has no .select().single())
chain['update'] = vi.fn().mockReturnValue({ ...chain, eq: vi.fn().mockReturnValue({ select: vi.fn().mockReturnValue({ single: vi.fn().mockResolvedValue(response) }), ...response }) });
return chain as any;
}
function mockSupabaseSuccess(data: unknown) {
return mockSupabase({ data, error: null });
}
function mockSupabaseError(message: string) {
return mockSupabase({ data: null, error: { message, code: 'ERROR' } });
}
const fakeDoc = {
id: 'doc-1',
org_id: 'org-1',
name: 'Test Doc',
type: 'document' as const,
parent_id: null,
path: null,
position: 0,
content: { type: 'doc', content: [] },
created_by: 'user-1',
created_at: '2024-01-01',
updated_at: '2024-01-01',
};
describe('createDocument', () => {
it('creates a document with default content for type "document"', async () => {
const sb = mockSupabaseSuccess(fakeDoc);
const result = await createDocument(sb, 'org-1', 'Test Doc', 'document', null, 'user-1');
expect(result).toEqual(fakeDoc);
expect(sb.from).toHaveBeenCalledWith('documents');
});
it('creates a folder with null content', async () => {
const folderDoc = { ...fakeDoc, type: 'folder', content: null };
const sb = mockSupabaseSuccess(folderDoc);
const result = await createDocument(sb, 'org-1', 'Folder', 'folder', null, 'user-1');
expect(result.type).toBe('folder');
});
it('creates a kanban document with custom id and content', async () => {
const kanbanDoc = { ...fakeDoc, id: 'board-1', type: 'kanban', content: { type: 'kanban', board_id: 'board-1' } };
const sb = mockSupabaseSuccess(kanbanDoc);
const result = await createDocument(
sb, 'org-1', 'Board', 'kanban', null, 'user-1',
{ id: 'board-1', content: { type: 'kanban', board_id: 'board-1' } },
);
expect(result.id).toBe('board-1');
});
it('throws on Supabase error', async () => {
const sb = mockSupabaseError('insert failed');
await expect(createDocument(sb, 'org-1', 'Fail', 'document', null, 'user-1'))
.rejects.toEqual({ message: 'insert failed', code: 'ERROR' });
});
});
describe('copyDocument', () => {
it('appends " (copy)" to the document name', async () => {
const copiedDoc = { ...fakeDoc, id: 'doc-2', name: 'Test Doc (copy)' };
const sb = mockSupabaseSuccess(copiedDoc);
const result = await copyDocument(sb, fakeDoc, 'org-1', 'user-1');
expect(result.name).toBe('Test Doc (copy)');
});
it('throws on Supabase error', async () => {
const sb = mockSupabaseError('copy failed');
await expect(copyDocument(sb, fakeDoc, 'org-1', 'user-1'))
.rejects.toEqual({ message: 'copy failed', code: 'ERROR' });
});
});
describe('deleteDocument', () => {
it('calls delete with correct id', async () => {
const sb = mockSupabase({ data: null, error: null });
await deleteDocument(sb, 'doc-1');
expect(sb.from).toHaveBeenCalledWith('documents');
expect(sb.delete).toHaveBeenCalled();
});
it('throws on Supabase error', async () => {
const sb = mockSupabase({ data: null, error: { message: 'delete failed', code: 'ERROR' } });
await expect(deleteDocument(sb, 'doc-1'))
.rejects.toEqual({ message: 'delete failed', code: 'ERROR' });
});
});
describe('fetchDocuments', () => {
it('returns documents array on success', async () => {
const docs = [fakeDoc];
// 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 });
const selectFn = vi.fn().mockReturnValue({ eq: eqFn });
const sb = { from: vi.fn().mockReturnValue({ select: selectFn }) } as any;
const result = await fetchDocuments(sb, 'org-1');
expect(result).toEqual(docs);
expect(sb.from).toHaveBeenCalledWith('documents');
});
it('throws on Supabase error', async () => {
const orderFn2 = vi.fn().mockResolvedValue({ data: null, error: { message: 'fetch failed' } });
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(fetchDocuments(sb, 'org-1')).rejects.toEqual({ message: 'fetch failed' });
});
});

View File

@@ -27,19 +27,26 @@ export async function createDocument(
supabase: SupabaseClient<Database>,
orgId: string,
name: string,
type: 'folder' | 'document',
type: 'folder' | 'document' | 'kanban',
parentId: string | null = null,
userId: string
userId: string,
options?: { id?: string; content?: import('$lib/supabase/types').Json }
): Promise<Document> {
let content: import('$lib/supabase/types').Json | null = options?.content ?? null;
if (!content && type === 'document') {
content = { type: 'doc', content: [] };
}
const { data, error } = await supabase
.from('documents')
.insert({
...(options?.id ? { id: options.id } : {}),
org_id: orgId,
name,
type,
parent_id: parentId,
created_by: userId,
content: type === 'document' ? { type: 'doc', content: [] } : null
content,
})
.select()
.single();
@@ -99,6 +106,33 @@ export async function moveDocument(
}
export async function copyDocument(
supabase: SupabaseClient<Database>,
doc: Pick<Document, 'name' | 'type' | 'parent_id' | 'content'>,
orgId: string,
userId: string
): Promise<Document> {
const { data, error } = await supabase
.from('documents')
.insert({
org_id: orgId,
name: `${doc.name} (copy)`,
type: doc.type,
parent_id: doc.parent_id,
created_by: userId,
content: doc.content,
})
.select()
.single();
if (error) {
log.error('copyDocument failed', { error, data: { orgId, name: doc.name } });
throw error;
}
log.info('copyDocument ok', { data: { id: data.id, name: data.name } });
return data;
}
export function subscribeToDocuments(
supabase: SupabaseClient<Database>,
orgId: string,

View File

@@ -0,0 +1,218 @@
import { GoogleAuth } from 'google-auth-library';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api.google-calendar-push');
const CALENDAR_API_BASE = 'https://www.googleapis.com/calendar/v3';
const SCOPES = ['https://www.googleapis.com/auth/calendar.events'];
/**
* Google Calendar push integration via Service Account.
*
* Setup:
* 1. Create a service account in Google Cloud Console
* 2. Download the JSON key file
* 3. Set GOOGLE_SERVICE_ACCOUNT_KEY env var to the JSON string (or base64-encoded)
* 4. Share the Google Calendar with the service account email (give "Make changes to events" permission)
*/
interface ServiceAccountCredentials {
client_email: string;
private_key: string;
project_id?: string;
}
let cachedAuth: GoogleAuth | null = null;
function getServiceAccountCredentials(keyJson: string): ServiceAccountCredentials {
try {
// Try parsing directly as JSON
const parsed = JSON.parse(keyJson);
return parsed;
} catch {
// Try base64 decode first
try {
const decoded = Buffer.from(keyJson, 'base64').toString('utf-8');
return JSON.parse(decoded);
} catch {
throw new Error('GOOGLE_SERVICE_ACCOUNT_KEY must be valid JSON or base64-encoded JSON');
}
}
}
function getAuth(keyJson: string): GoogleAuth {
if (cachedAuth) return cachedAuth;
const credentials = getServiceAccountCredentials(keyJson);
cachedAuth = new GoogleAuth({
credentials: {
client_email: credentials.client_email,
private_key: credentials.private_key,
},
scopes: SCOPES,
});
return cachedAuth;
}
async function getAccessToken(keyJson: string): Promise<string> {
const auth = getAuth(keyJson);
const client = await auth.getClient();
const tokenResponse = await client.getAccessToken();
const token = typeof tokenResponse === 'string' ? tokenResponse : tokenResponse?.token;
if (!token) throw new Error('Failed to get access token from service account');
return token;
}
export function getServiceAccountEmail(keyJson: string): string | null {
try {
const creds = getServiceAccountCredentials(keyJson);
return creds.client_email;
} catch {
return 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.
*/
export async function fetchCalendarEventsViaServiceAccount(
keyJson: string,
calendarId: string,
timeMin: Date,
timeMax: Date
): Promise<unknown[]> {
const token = await getAccessToken(keyJson);
const params = new URLSearchParams({
timeMin: timeMin.toISOString(),
timeMax: timeMax.toISOString(),
singleEvents: 'true',
orderBy: 'startTime',
maxResults: '250',
});
const response = await fetch(
`${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events?${params}`,
{
headers: { Authorization: `Bearer ${token}` },
}
);
if (!response.ok) {
const errorText = await response.text();
log.error('Failed to fetch calendar events via service account', {
error: errorText,
data: { calendarId },
});
throw new Error(`Google Calendar API error (${response.status}): ${errorText}`);
}
const data = await response.json();
return data.items ?? [];
}
export interface GoogleEventPayload {
summary: string;
description?: string | null;
start: { dateTime?: string; date?: string; timeZone?: string };
end: { dateTime?: string; date?: string; timeZone?: string };
colorId?: string;
}
/**
* Create an event in Google Calendar.
* Returns the Google event ID.
*/
export async function pushEventToGoogle(
keyJson: string,
calendarId: string,
event: GoogleEventPayload
): Promise<string> {
const token = await getAccessToken(keyJson);
const response = await fetch(
`${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(event),
}
);
if (!response.ok) {
const errorText = await response.text();
log.error('Failed to create Google Calendar event', { error: errorText, data: { calendarId } });
throw new Error(`Google Calendar API error (${response.status}): ${errorText}`);
}
const data = await response.json();
log.info('Created Google Calendar event', { data: { googleEventId: data.id, calendarId } });
return data.id;
}
/**
* Update an existing event in Google Calendar.
*/
export async function updateGoogleEvent(
keyJson: string,
calendarId: string,
googleEventId: string,
event: GoogleEventPayload
): Promise<void> {
const token = await getAccessToken(keyJson);
const response = await fetch(
`${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(googleEventId)}`,
{
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(event),
}
);
if (!response.ok) {
const errorText = await response.text();
log.error('Failed to update Google Calendar event', { error: errorText, data: { calendarId, googleEventId } });
throw new Error(`Google Calendar API error (${response.status}): ${errorText}`);
}
log.info('Updated Google Calendar event', { data: { googleEventId, calendarId } });
}
/**
* Delete an event from Google Calendar.
*/
export async function deleteGoogleEvent(
keyJson: string,
calendarId: string,
googleEventId: string
): Promise<void> {
const token = await getAccessToken(keyJson);
const response = await fetch(
`${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(googleEventId)}`,
{
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
}
);
// 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 } });
throw new Error(`Google Calendar API error (${response.status}): ${errorText}`);
}
log.info('Deleted Google Calendar event', { data: { googleEventId, calendarId } });
}

View File

@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest';
import { extractCalendarId, getCalendarSubscribeUrl } from './google-calendar';
describe('extractCalendarId', () => {
it('returns null for empty input', () => {
expect(extractCalendarId('')).toBeNull();
});
it('returns email-style calendar ID as-is', () => {
expect(extractCalendarId('user@gmail.com')).toBe('user@gmail.com');
});
it('trims whitespace from email-style IDs', () => {
expect(extractCalendarId(' user@gmail.com ')).toBe('user@gmail.com');
});
it('returns group calendar ID as-is', () => {
const id = 'abc123@group.calendar.google.com';
expect(extractCalendarId(id)).toBe(id);
});
it('extracts calendar ID from cid parameter (base64)', () => {
const calId = 'user@gmail.com';
const encoded = btoa(calId);
const url = `https://calendar.google.com/calendar/u/0?cid=${encoded}`;
expect(extractCalendarId(url)).toBe(calId);
});
it('extracts calendar ID from src parameter', () => {
const url = 'https://calendar.google.com/calendar/embed?src=user@gmail.com';
expect(extractCalendarId(url)).toBe('user@gmail.com');
});
it('extracts calendar ID from ical path', () => {
const url = 'https://calendar.google.com/calendar/ical/user%40gmail.com/public/basic.ics';
expect(extractCalendarId(url)).toBe('user@gmail.com');
});
it('returns null for non-URL non-email input', () => {
expect(extractCalendarId('random-string')).toBeNull();
});
it('handles URL without recognized parameters', () => {
expect(extractCalendarId('https://example.com/page')).toBeNull();
});
});
describe('getCalendarSubscribeUrl', () => {
it('generates a subscribe URL with base64-encoded calendar ID', () => {
const calId = 'user@gmail.com';
const url = getCalendarSubscribeUrl(calId);
expect(url).toContain('https://calendar.google.com/calendar/u/0?cid=');
expect(url).toContain(btoa(calId));
});
it('roundtrips with extractCalendarId', () => {
const calId = 'test@group.calendar.google.com';
const url = getCalendarSubscribeUrl(calId);
expect(extractCalendarId(url)).toBe(calId);
});
});

View File

@@ -90,9 +90,8 @@ export async function fetchPublicCalendarEvents(
);
if (!response.ok) {
const error = await response.text();
console.error('Google Calendar API error:', error);
throw new Error('Failed to fetch calendar events. Make sure the calendar is set to public.');
const errorText = await response.text();
throw new Error(`Failed to fetch calendar events (${response.status}): ${errorText}`);
}
const data = await response.json();

View File

@@ -34,30 +34,30 @@ export async function fetchBoardWithColumns(
supabase: SupabaseClient<Database>,
boardId: string
): Promise<BoardWithColumns | null> {
const { data: board, error: boardError } = await supabase
.from('kanban_boards')
.select('*')
.eq('id', boardId)
.single();
// Fetch board and columns in parallel
const [boardResult, columnsResult] = await Promise.all([
supabase.from('kanban_boards').select('*').eq('id', boardId).single(),
supabase.from('kanban_columns').select('*').eq('board_id', boardId).order('position'),
]);
if (boardError) {
log.error('fetchBoardWithColumns failed (board)', { error: boardError, data: { boardId } });
throw boardError;
if (boardResult.error) {
log.error('fetchBoardWithColumns failed (board)', { error: boardResult.error, data: { boardId } });
throw boardResult.error;
}
if (!board) return null;
if (!boardResult.data) return null;
const { data: columns, error: colError } = await supabase
.from('kanban_columns')
.select('*')
.eq('board_id', boardId)
.order('position');
if (colError) {
log.error('fetchBoardWithColumns failed (columns)', { error: colError, data: { boardId } });
throw colError;
if (columnsResult.error) {
log.error('fetchBoardWithColumns failed (columns)', { error: columnsResult.error, data: { boardId } });
throw columnsResult.error;
}
const columnIds = (columns ?? []).map((c) => c.id);
const board = boardResult.data;
const columns = columnsResult.data ?? [];
const columnIds = columns.map((c) => c.id);
if (columnIds.length === 0) {
return { ...board, columns: columns.map((col) => ({ ...col, cards: [] })) };
}
const { data: cards, error: cardError } = await supabase
.from('kanban_cards')
@@ -70,42 +70,71 @@ export async function fetchBoardWithColumns(
throw cardError;
}
// Fetch tags for all cards in one query
const cardIds = (cards ?? []).map((c) => c.id);
let cardTagsMap = new Map<string, { id: string; name: string; color: string | null }[]>();
const cardTagsMap = new Map<string, { id: string; name: string; color: string | null }[]>();
const checklistMap = new Map<string, { total: number; done: number }>();
const assigneeMap = new Map<string, { name: string | null; avatar: string | null }>();
if (cardIds.length > 0) {
const { data: cardTags } = await supabase
.from('card_tags')
.select('card_id, tags:tag_id (id, name, color)')
.in('card_id', cardIds);
const assigneeIds = [...new Set((cards ?? []).map((c) => c.assignee_id).filter(Boolean))] as string[];
(cardTags ?? []).forEach((ct: any) => {
const tag = Array.isArray(ct.tags) ? ct.tags[0] : ct.tags;
// Fetch tags, checklists, and assignee profiles in parallel
const [cardTagsResult, checklistResult, profilesResult] = await Promise.all([
supabase.from('card_tags').select('card_id, tags:tag_id (id, name, color)').in('card_id', cardIds),
supabase.from('kanban_checklist_items').select('card_id, completed').in('card_id', cardIds),
assigneeIds.length > 0
? supabase.from('profiles').select('id, full_name, avatar_url').in('id', assigneeIds)
: Promise.resolve({ data: null }),
]);
(cardTagsResult.data ?? []).forEach((ct: Record<string, unknown>) => {
const rawTags = ct.tags;
const tag = Array.isArray(rawTags) ? rawTags[0] : rawTags;
if (!tag) return;
if (!cardTagsMap.has(ct.card_id)) {
cardTagsMap.set(ct.card_id, []);
const cardId = ct.card_id as string;
if (!cardTagsMap.has(cardId)) {
cardTagsMap.set(cardId, []);
}
cardTagsMap.get(ct.card_id)!.push(tag);
cardTagsMap.get(cardId)!.push(tag as { id: string; name: string; color: string | null });
});
(checklistResult.data ?? []).forEach((item: Record<string, unknown>) => {
const cardId = item.card_id as string;
if (!checklistMap.has(cardId)) {
checklistMap.set(cardId, { total: 0, done: 0 });
}
const entry = checklistMap.get(cardId)!;
entry.total++;
if (item.completed) entry.done++;
});
(profilesResult.data ?? []).forEach((p: Record<string, unknown>) => {
assigneeMap.set(p.id as string, { name: p.full_name as string | null, avatar: p.avatar_url as string | null });
});
}
const cardsByColumn = new Map<string, (KanbanCard & { tags?: { id: string; name: string; color: string | null }[] })[]>();
const cardsByColumn = new Map<string, (KanbanCard & { tags?: { id: string; name: string; color: string | null }[]; checklist_total?: number; checklist_done?: number; assignee_name?: string | null; assignee_avatar?: string | null })[]>();
(cards ?? []).forEach((card) => {
const colId = card.column_id;
if (!colId) return;
if (!cardsByColumn.has(colId)) {
cardsByColumn.set(colId, []);
}
const cl = checklistMap.get(card.id);
const assignee = card.assignee_id ? assigneeMap.get(card.assignee_id) : null;
cardsByColumn.get(colId)!.push({
...card,
tags: cardTagsMap.get(card.id) ?? []
tags: cardTagsMap.get(card.id) ?? [],
checklist_total: cl?.total ?? 0,
checklist_done: cl?.done ?? 0,
assignee_name: assignee?.name ?? null,
assignee_avatar: assignee?.avatar ?? null,
});
});
return {
...board,
columns: (columns ?? []).map((col) => ({
columns: columns.map((col) => ({
...col,
cards: cardsByColumn.get(col.id) ?? []
}))
@@ -283,39 +312,76 @@ export async function moveCard(
...otherCards.slice(newPosition),
];
// Batch update: move card to column + set position, then update siblings
const updates = reordered.map((c, i) => {
// Build a map of old positions to detect what actually changed
const oldPositionMap = new Map((targetCards ?? []).map((c) => [c.id, c.position]));
// Only update cards whose position or column actually changed
const updates = reordered
.map((c, i) => {
if (c.id === cardId) {
// The moved card always needs updating (column + position)
return supabase
.from('kanban_cards')
.update({ column_id: newColumnId, position: i })
.eq('id', c.id);
}
// Skip siblings whose position hasn't changed
if (oldPositionMap.get(c.id) === i) return null;
return supabase
.from('kanban_cards')
.update({ position: i })
.eq('id', c.id);
});
})
.filter(Boolean);
if (updates.length === 0) return;
const results = await Promise.all(updates);
const failed = results.find((r) => r.error);
const failed = results.find((r) => r && r.error);
if (failed?.error) {
log.error('moveCard failed', { error: failed.error, data: { cardId, newColumnId, newPosition } });
throw failed.error;
}
}
export interface RealtimeChangePayload<T = Record<string, unknown>> {
event: 'INSERT' | 'UPDATE' | 'DELETE';
new: T;
old: Partial<T>;
}
export function subscribeToBoard(
supabase: SupabaseClient<Database>,
boardId: string,
onColumnChange: () => void,
onCardChange: () => void
columnIds: string[],
onColumnChange: (payload: RealtimeChangePayload<KanbanColumn>) => void,
onCardChange: (payload: RealtimeChangePayload<KanbanCard>) => void
) {
const channel = supabase.channel(`kanban:${boardId}`);
const columnIdSet = new Set(columnIds);
channel
.on('postgres_changes', { event: '*', schema: 'public', table: 'kanban_columns', filter: `board_id=eq.${boardId}` }, onColumnChange)
.on('postgres_changes', { event: '*', schema: 'public', table: 'kanban_cards' }, onCardChange)
.on('postgres_changes', { event: '*', schema: 'public', table: 'kanban_columns', filter: `board_id=eq.${boardId}` },
(payload) => onColumnChange({
event: payload.eventType as 'INSERT' | 'UPDATE' | 'DELETE',
new: payload.new as KanbanColumn,
old: payload.old as Partial<KanbanColumn>,
})
)
.on('postgres_changes', { event: '*', schema: 'public', table: 'kanban_cards' },
(payload) => {
// Client-side filter: only process cards belonging to this board's columns
const card = (payload.new ?? payload.old) as Partial<KanbanCard>;
const colId = card.column_id ?? (payload.old as Partial<KanbanCard>)?.column_id;
if (colId && !columnIdSet.has(colId)) return;
onCardChange({
event: payload.eventType as 'INSERT' | 'UPDATE' | 'DELETE',
new: payload.new as KanbanCard,
old: payload.old as Partial<KanbanCard>,
});
}
)
.subscribe();
return channel;

View File

@@ -128,7 +128,7 @@
<div class="flex items-center justify-between px-2">
<div class="flex items-center gap-2">
<button
class="p-1 text-light/60 hover:text-light hover:bg-dark rounded-lg transition-colors"
class="p-1 text-light/60 hover:text-light hover:bg-dark rounded-full transition-colors"
onclick={prev}
aria-label="Previous"
>
@@ -143,7 +143,7 @@
>{headerTitle}</span
>
<button
class="p-1 text-light/60 hover:text-light hover:bg-dark rounded-lg transition-colors"
class="p-1 text-light/60 hover:text-light hover:bg-dark rounded-full transition-colors"
onclick={next}
aria-label="Next"
>
@@ -203,7 +203,9 @@
</div>
<!-- Calendar Grid -->
<div class="flex-1 flex flex-col gap-2 min-h-0">
<div
class="flex-1 flex flex-col gap-2 min-h-0 rounded-lg overflow-hidden"
>
{#each weeks as week}
<div class="grid grid-cols-7 gap-2 flex-1">
{#each week as day}
@@ -211,7 +213,7 @@
{@const isToday = isSameDay(day, today)}
{@const inMonth = isCurrentMonth(day)}
<div
class="bg-night rounded-none flex flex-col items-start px-4 py-5 overflow-hidden transition-colors hover:bg-dark/50 min-h-0 cursor-pointer
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)}
>
@@ -254,12 +256,14 @@
<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">
<div
class="grid grid-cols-7 gap-2 flex-1 rounded-lg overflow-hidden"
>
{#each weekDates as day}
{@const dayEvents = getEventsForDay(day)}
{@const isToday = isSameDay(day, today)}
<div class="flex flex-col overflow-hidden">
<div class="px-4 py-3 text-center">
<div class="px-2 py-2 text-center">
<div
class="font-heading text-h4 {isToday
? 'text-primary'
@@ -275,7 +279,9 @@
{day.getDate()}
</div>
</div>
<div class="flex-1 px-2 pb-2 space-y-1 overflow-y-auto">
<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"

View File

@@ -86,7 +86,7 @@
{/if}
<button
type="button"
class="p-1 hover:bg-dark rounded-lg transition-colors"
class="p-1 hover:bg-dark rounded-full transition-colors"
aria-label="More options"
>
<span

View File

@@ -15,6 +15,15 @@
import type { Document } from "$lib/supabase/types";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
import * as m from "$lib/paraglide/messages";
import { logActivity } from "$lib/api/activity";
import {
moveDocument,
updateDocument,
deleteDocument,
createDocument,
copyDocument,
} from "$lib/api/documents";
const log = createLogger("component.file-browser");
@@ -32,7 +41,7 @@
documents = $bindable(),
currentFolderId,
user,
title = "Files",
title = m.files_title(),
}: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
@@ -43,7 +52,19 @@
let editingDoc = $state<Document | null>(null);
let newDocName = $state("");
let newDocType = $state<"folder" | "document" | "kanban">("document");
let viewMode = $state<"list" | "grid">("grid");
let viewMode = $state<"list" | "grid">(
typeof localStorage !== "undefined" &&
localStorage.getItem("root:viewMode") === "list"
? "list"
: "grid",
);
function toggleViewMode() {
viewMode = viewMode === "list" ? "grid" : "list";
if (typeof localStorage !== "undefined") {
localStorage.setItem("root:viewMode", viewMode);
}
}
// Context menu state
let contextMenu = $state<{ x: number; y: number; doc: Document } | null>(
@@ -171,23 +192,11 @@
if (!contextMenu || !user) return;
const doc = contextMenu.doc;
closeContextMenu();
const { data: newDoc, error } = await supabase
.from("documents")
.insert({
org_id: org.id,
name: `${doc.name} (copy)`,
type: doc.type,
parent_id: doc.parent_id,
created_by: user.id,
content: doc.content,
})
.select()
.single();
if (!error && newDoc) {
documents = [...documents, newDoc as Document];
try {
const newDoc = await copyDocument(supabase, doc, org.id, user.id);
documents = [...documents, newDoc];
toasts.success(`Copied "${doc.name}"`);
} else if (error) {
log.error("Failed to copy document", { error });
} catch {
toasts.error("Failed to copy document");
}
}
@@ -294,22 +303,15 @@
documents = documents.map((d) =>
d.id === docId ? { ...d, parent_id: newParentId } : d,
);
const { error } = await supabase
.from("documents")
.update({
parent_id: newParentId,
updated_at: new Date().toISOString(),
})
.eq("id", docId);
if (error) {
log.error("Failed to move document", {
error,
data: { docId, newParentId },
});
try {
await moveDocument(supabase, docId, newParentId);
} catch {
toasts.error("Failed to move file");
const { data: freshDocs } = await supabase
.from("documents")
.select("*")
.select(
"id, name, type, parent_id, path, position, created_at, updated_at, created_by, org_id",
)
.eq("org_id", org.id)
.order("name");
if (freshDocs) documents = freshDocs as Document[];
@@ -319,7 +321,9 @@
async function handleCreate() {
if (!newDocName.trim() || !user) return;
try {
if (newDocType === "kanban") {
// Create kanban board first, then link as document
const { data: newBoard, error: boardError } = await supabase
.from("kanban_boards")
.insert({ org_id: org.id, name: newDocName })
@@ -334,53 +338,55 @@
{ board_id: newBoard.id, name: "In Progress", position: 1 },
{ board_id: newBoard.id, name: "Done", position: 2 },
]);
const { data: newDoc, error } = await supabase
.from("documents")
.insert({
const newDoc = await createDocument(
supabase,
org.id,
newDocName,
"kanban",
currentFolderId,
user.id,
{
id: newBoard.id,
org_id: org.id,
name: newDocName,
type: "kanban",
parent_id: currentFolderId,
created_by: user.id,
content: {
type: "kanban",
board_id: newBoard.id,
} as import("$lib/supabase/types").Json,
})
.select()
.single();
if (!error && newDoc) {
goto(getFileUrl(newDoc as Document));
} else if (error) {
toasts.error("Failed to create kanban document");
}
},
);
logActivity(supabase, {
orgId: org.id,
userId: user.id,
action: "create",
entityType: "kanban_board",
entityId: newDoc.id,
entityName: newDocName,
});
goto(getFileUrl(newDoc));
} else {
let content: any = null;
const newDoc = await createDocument(
supabase,
org.id,
newDocName,
newDocType,
currentFolderId,
user.id,
);
documents = [...documents, newDoc];
logActivity(supabase, {
orgId: org.id,
userId: user.id,
action: "create",
entityType: newDocType === "folder" ? "folder" : "document",
entityId: newDoc.id,
entityName: newDocName,
});
if (newDocType === "document") {
content = { type: "doc", content: [] };
goto(getFileUrl(newDoc));
}
const { data: newDoc, error } = await supabase
.from("documents")
.insert({
org_id: org.id,
name: newDocName,
type: newDocType as "folder" | "document",
parent_id: currentFolderId,
created_by: user.id,
content,
})
.select()
.single();
if (!error && newDoc) {
documents = [...documents, newDoc as Document];
if (newDocType === "document") {
goto(getFileUrl(newDoc as Document));
}
} else if (error) {
} catch {
toasts.error("Failed to create document");
}
}
showCreateModal = false;
newDocName = "";
@@ -389,28 +395,39 @@
async function handleSave(content: import("$lib/supabase/types").Json) {
if (!selectedDoc) return;
await supabase
.from("documents")
.update({ content, updated_at: new Date().toISOString() })
.eq("id", selectedDoc.id);
try {
await updateDocument(supabase, selectedDoc.id, { content });
documents = documents.map((d) =>
d.id === selectedDoc!.id ? { ...d, content } : d,
);
} catch {
toasts.error("Failed to save document");
}
}
async function handleRename() {
if (!editingDoc || !newDocName.trim()) return;
const { error } = await supabase
.from("documents")
.update({ name: newDocName, updated_at: new Date().toISOString() })
.eq("id", editingDoc.id);
if (!error) {
try {
await updateDocument(supabase, editingDoc.id, { name: newDocName });
if (user) {
logActivity(supabase, {
orgId: org.id,
userId: user.id,
action: "rename",
entityType:
editingDoc.type === "folder" ? "folder" : "document",
entityId: editingDoc.id,
entityName: newDocName,
});
}
documents = documents.map((d) =>
d.id === editingDoc!.id ? { ...d, name: newDocName } : d,
);
if (selectedDoc?.id === editingDoc.id) {
selectedDoc = { ...selectedDoc, name: newDocName };
}
} catch {
toasts.error("Failed to rename document");
}
showEditModal = false;
editingDoc = null;
@@ -435,21 +452,30 @@
return ids;
}
try {
if (doc.type === "folder") {
const descendantIds = collectDescendantIds(doc.id);
if (descendantIds.length > 0) {
await supabase
.from("documents")
.delete()
.in("id", descendantIds);
for (const id of descendantIds) {
await deleteDocument(supabase, id);
}
}
await deleteDocument(supabase, doc.id);
const { error } = await supabase
.from("documents")
.delete()
.eq("id", doc.id);
if (!error) {
if (user) {
logActivity(supabase, {
orgId: org.id,
userId: user.id,
action: "delete",
entityType:
doc.type === "folder"
? "folder"
: doc.type === "kanban"
? "kanban_board"
: "document",
entityId: doc.id,
entityName: doc.name,
});
}
const deletedIds = new Set([
doc.id,
...(doc.type === "folder" ? collectDescendantIds(doc.id) : []),
@@ -458,6 +484,8 @@
if (selectedDoc?.id === doc.id) {
selectedDoc = null;
}
} catch {
toasts.error("Failed to delete document");
}
}
</script>
@@ -471,12 +499,8 @@
<header class="flex items-center gap-2 p-1">
<Avatar name={title} size="md" />
<h1 class="flex-1 font-heading text-h1 text-white">{title}</h1>
<Button size="md" onclick={handleAdd}>+ New</Button>
<IconButton
title="Toggle view"
onclick={() =>
(viewMode = viewMode === "list" ? "grid" : "list")}
>
<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}
@@ -668,7 +692,7 @@
<Modal
isOpen={showCreateModal}
onClose={() => (showCreateModal = false)}
title="Create New"
title={m.files_create_title()}
>
<div class="space-y-4">
<div class="flex gap-2">
@@ -685,7 +709,7 @@
style="font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>description</span
>
Document
{m.files_type_document()}
</button>
<button
type="button"
@@ -700,7 +724,7 @@
style="font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>folder</span
>
Folder
{m.files_type_folder()}
</button>
<button
type="button"
@@ -715,24 +739,24 @@
style="font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>view_kanban</span
>
Kanban
{m.files_type_kanban()}
</button>
</div>
<Input
label="Name"
label={m.files_name_label()}
bind:value={newDocName}
placeholder={newDocType === "folder"
? "Folder name"
? m.files_folder_placeholder()
: newDocType === "kanban"
? "Kanban board name"
: "Document name"}
? m.files_kanban_placeholder()
: m.files_doc_placeholder()}
/>
<div class="flex justify-end gap-2 pt-2">
<Button variant="tertiary" onclick={() => (showCreateModal = false)}
>Cancel</Button
>{m.btn_cancel()}</Button
>
<Button onclick={handleCreate} disabled={!newDocName.trim()}
>Create</Button
>{m.btn_create()}</Button
>
</div>
</div>
@@ -757,7 +781,7 @@
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>edit</span
>
Rename
{m.files_context_rename()}
</button>
<button
type="button"
@@ -837,7 +861,7 @@
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>delete</span
>
Delete
{m.files_context_delete()}
</button>
</div>
{/if}
@@ -849,13 +873,13 @@
editingDoc = null;
newDocName = "";
}}
title="Rename"
title={m.files_rename_title()}
>
<div class="space-y-4">
<Input
label="Name"
label={m.files_name_label()}
bind:value={newDocName}
placeholder="Enter new name"
placeholder={m.files_name_label()}
/>
<div class="flex justify-end gap-2 pt-2">
<Button
@@ -864,10 +888,10 @@
showEditModal = false;
editingDoc = null;
newDocName = "";
}}>Cancel</Button
}}>{m.btn_cancel()}</Button
>
<Button onclick={handleRename} disabled={!newDocName.trim()}
>Save</Button
>{m.btn_save()}</Button
>
</div>
</div>

View File

@@ -100,6 +100,10 @@
let cardTagIds = $state<Set<string>>(new Set());
let newTagName = $state("");
let showTagInput = $state(false);
let editingTagId = $state<string | null>(null);
let editTagName = $state("");
let editTagColor = $state("");
let showTagManager = $state(false);
const TAG_COLORS = [
"#00A3E0",
@@ -238,6 +242,38 @@
showTagInput = false;
}
function startEditTag(tag: OrgTag) {
editingTagId = tag.id;
editTagName = tag.name;
editTagColor = tag.color || TAG_COLORS[0];
}
async function saveEditTag() {
if (!editingTagId || !editTagName.trim()) return;
const { error } = await supabase
.from("tags")
.update({ name: editTagName.trim(), color: editTagColor })
.eq("id", editingTagId);
if (!error) {
orgTags = orgTags.map((t) =>
t.id === editingTagId
? { ...t, name: editTagName.trim(), color: editTagColor }
: t,
);
}
editingTagId = null;
}
async function deleteTag(tagId: string) {
if (!confirm("Delete this tag from the organization?")) return;
const { error } = await supabase.from("tags").delete().eq("id", tagId);
if (!error) {
orgTags = orgTags.filter((t) => t.id !== tagId);
cardTagIds.delete(tagId);
cardTagIds = new Set(cardTagIds);
}
}
async function handleSave() {
if (!isMounted) return;
if (mode === "create") {
@@ -282,7 +318,10 @@
.eq("id", columnId)
.single();
const position = (column as any)?.cards?.[0]?.count ?? 0; // join aggregation not typed
const cards = (column as Record<string, unknown> | null)?.cards as
| { count: number }[]
| undefined;
const position = cards?.[0]?.count ?? 0;
const { data: newCard, error } = await supabase
.from("kanban_cards")
@@ -300,6 +339,26 @@
.single();
if (!error && newCard) {
// Persist checklist items added during creation
if (checklist.length > 0) {
await supabase.from("kanban_checklist_items").insert(
checklist.map((item, i) => ({
card_id: newCard.id,
title: item.title,
position: i,
completed: false,
})),
);
}
// Persist tags assigned during creation
if (cardTagIds.size > 0) {
await supabase.from("card_tags").insert(
[...cardTagIds].map((tagId) => ({
card_id: newCard.id,
tag_id: tagId,
})),
);
}
onCreate?.(newCard as KanbanCard);
onClose();
}
@@ -307,7 +366,25 @@
}
async function handleAddItem() {
if (!card || !newItemTitle.trim()) return;
if (!newItemTitle.trim()) return;
if (mode === "create") {
// In create mode, add items locally (no card ID yet)
checklist = [
...checklist,
{
id: `temp-${Date.now()}`,
card_id: "",
title: newItemTitle.trim(),
completed: false,
position: checklist.length,
},
];
newItemTitle = "";
return;
}
if (!card) return;
const position = checklist.length;
const { data, error } = await supabase
@@ -429,10 +506,118 @@
<!-- Tags -->
<div>
<span
class="px-3 font-bold font-body text-body text-white mb-2 block"
<div class="flex items-center justify-between mb-2">
<span class="px-3 font-bold font-body text-body text-white"
>Tags</span
>
<button
type="button"
class="text-xs text-light/40 hover:text-light transition-colors"
onclick={() => (showTagManager = !showTagManager)}
>
{showTagManager ? "Done" : "Manage"}
</button>
</div>
{#if showTagManager}
<!-- Tag Manager: edit/delete/create tags -->
<div class="space-y-2 mb-3 p-3 bg-background rounded-2xl">
{#each orgTags as tag}
<div class="flex items-center gap-2 group">
{#if editingTagId === tag.id}
<div class="flex items-center gap-2 flex-1">
<input
type="color"
class="w-6 h-6 rounded cursor-pointer border-0 bg-transparent"
bind:value={editTagColor}
/>
<input
type="text"
class="bg-dark border border-primary rounded-lg px-2 py-1 text-sm text-white flex-1 focus:outline-none"
bind:value={editTagName}
onkeydown={(e) => {
if (e.key === "Enter")
saveEditTag();
if (e.key === "Escape") {
editingTagId = null;
}
}}
/>
<button
type="button"
class="text-primary text-xs font-bold"
onclick={saveEditTag}>Save</button
>
<button
type="button"
class="text-light/40 text-xs"
onclick={() =>
(editingTagId = null)}
>Cancel</button
>
</div>
{:else}
<span
class="w-3 h-3 rounded-sm shrink-0"
style="background-color: {tag.color ||
'#00A3E0'}"
></span>
<span
class="text-sm text-light flex-1 truncate"
>{tag.name}</span
>
<button
type="button"
class="opacity-0 group-hover:opacity-100 p-0.5 text-light/40 hover:text-light transition-all"
onclick={() => startEditTag(tag)}
aria-label="Edit tag"
>
<span
class="material-symbols-rounded"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>edit</span
>
</button>
<button
type="button"
class="opacity-0 group-hover:opacity-100 p-0.5 text-light/40 hover:text-error transition-all"
onclick={() => deleteTag(tag.id)}
aria-label="Delete tag"
>
<span
class="material-symbols-rounded"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>delete</span
>
</button>
{/if}
</div>
{/each}
<!-- Inline create new tag -->
<div
class="flex items-center gap-2 pt-1 border-t border-light/10"
>
<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..."
bind:value={newTagName}
onkeydown={(e) =>
e.key === "Enter" && createTag()}
/>
<button
type="button"
class="text-primary text-xs font-bold hover:text-primary/80 whitespace-nowrap"
onclick={createTag}
disabled={!newTagName.trim()}
>
+ Add
</button>
</div>
</div>
{/if}
<!-- Tag toggle chips -->
<div class="flex flex-wrap gap-2 items-center">
{#each orgTags as tag}
<button
@@ -450,6 +635,7 @@
{tag.name}
</button>
{/each}
{#if !showTagManager}
{#if showTagInput}
<div class="flex gap-1 items-center">
<input
@@ -487,6 +673,7 @@
+ New tag
</button>
{/if}
{/if}
</div>
</div>

View File

@@ -2,6 +2,7 @@
import type { ColumnWithCards } from "$lib/api/kanban";
import type { KanbanCard } from "$lib/supabase/types";
import KanbanCardComponent from "./KanbanCard.svelte";
import { Button } from "$lib/components/ui";
interface Props {
columns: ColumnWithCards[];
@@ -15,6 +16,7 @@
onAddColumn?: () => void;
onDeleteCard?: (cardId: string) => void;
onDeleteColumn?: (columnId: string) => void;
onRenameColumn?: (columnId: string, newName: string) => void;
canEdit?: boolean;
}
@@ -26,9 +28,37 @@
onAddColumn,
onDeleteCard,
onDeleteColumn,
onRenameColumn,
canEdit = true,
}: Props = $props();
let columnMenuId = $state<string | null>(null);
let renamingColumnId = $state<string | null>(null);
let renameValue = $state("");
function openColumnMenu(columnId: string) {
columnMenuId = columnMenuId === columnId ? null : columnId;
}
function startRename(column: ColumnWithCards) {
renameValue = column.name;
renamingColumnId = column.id;
columnMenuId = null;
}
function confirmRename() {
if (renamingColumnId && renameValue.trim()) {
onRenameColumn?.(renamingColumnId, renameValue.trim());
}
renamingColumnId = null;
renameValue = "";
}
function cancelRename() {
renamingColumnId = null;
renameValue = "";
}
let draggedCard = $state<KanbanCard | null>(null);
let dragOverColumn = $state<string | null>(null);
let dragOverCardIndex = $state<{ columnId: string; index: number } | null>(
@@ -58,7 +88,6 @@
e.stopPropagation();
if (!draggedCard) return;
// Determine if we're in the top or bottom half of the card
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const dropIndex = e.clientY < midY ? index : index + 1;
@@ -84,7 +113,6 @@
let newPosition: number;
if (targetIndex && targetIndex.columnId === columnId) {
newPosition = targetIndex.index;
// If moving within the same column and the card is above the target, adjust
if (draggedCard.column_id === columnId) {
const currentIndex = column.cards.findIndex(
(c) => c.id === draggedCard!.id,
@@ -92,7 +120,6 @@
if (currentIndex !== -1 && currentIndex < newPosition) {
newPosition = Math.max(0, newPosition - 1);
}
// No-op if dropping in the same position
if (currentIndex === newPosition) {
draggedCard = null;
return;
@@ -107,10 +134,13 @@
}
</script>
<div class="flex gap-2 overflow-x-auto pb-4 h-full kanban-scroll">
{#each columns as column}
<div
class="flex-shrink-0 w-[256px] bg-background rounded-[32px] px-4 py-5 flex flex-col gap-4 max-h-full {dragOverColumn ===
class="flex gap-2 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'
: ''}"
@@ -120,11 +150,25 @@
role="list"
>
<!-- Column Header -->
<div class="flex items-center gap-2 p-1 rounded-[32px]">
<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}
<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"
bind:value={renameValue}
onkeydown={(e) => {
if (e.key === "Enter") confirmRename();
if (e.key === "Escape") cancelRename();
}}
onblur={confirmRename}
autofocus
/>
{:else}
<h3 class="font-heading text-h4 text-white truncate">
{column.name}
</h3>
{/if}
<div
class="bg-dark flex items-center justify-center px-1.5 py-0.5 rounded-[8px] shrink-0"
>
@@ -133,10 +177,12 @@
>
</div>
</div>
{#if canEdit}
<div class="relative shrink-0">
<button
type="button"
class="p-1 hover:bg-night rounded-lg transition-colors shrink-0"
onclick={() => onDeleteColumn?.(column.id)}
class="p-1 hover:bg-night rounded-full transition-colors"
onclick={() => openColumnMenu(column.id)}
aria-label="Column options"
>
<span
@@ -146,6 +192,47 @@
more_horiz
</span>
</button>
{#if columnMenuId === column.id}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="fixed inset-0 z-40"
onclick={() => (columnMenuId = null)}
></div>
<div
class="absolute right-0 top-full mt-1 bg-night border border-light/10 rounded-2xl shadow-xl z-50 py-1 min-w-[160px]"
>
<button
type="button"
class="w-full px-4 py-2.5 text-left text-sm text-white hover:bg-dark transition-colors flex items-center gap-3"
onclick={() => startRename(column)}
>
<span
class="material-symbols-rounded text-light/50"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>edit</span
>
Rename
</button>
<button
type="button"
class="w-full px-4 py-2.5 text-left text-sm text-error hover:bg-dark transition-colors flex items-center gap-3"
onclick={() => {
columnMenuId = null;
onDeleteColumn?.(column.id);
}}
>
<span
class="material-symbols-rounded"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>delete</span
>
Delete
</button>
</div>
{/if}
</div>
{/if}
</div>
<!-- Cards -->
@@ -182,34 +269,31 @@
{/if}
</div>
<!-- Add Card Button (secondary style) -->
<!-- Add Card Button -->
{#if canEdit}
<button
type="button"
class="w-full py-3 border-[3px] border-primary text-primary font-heading text-h5 rounded-[32px] hover:bg-primary/10 transition-colors"
<Button
variant="secondary"
fullWidth
icon="add"
onclick={() => onAddCard?.(column.id)}
>
Add card
</button>
</Button>
{/if}
</div>
{/each}
<!-- Add Column Button -->
{#if canEdit}
<button
type="button"
class="flex-shrink-0 w-[256px] h-12 border-[3px] border-primary/30 hover:border-primary rounded-[32px] flex items-center justify-center gap-2 text-primary/50 hover:text-primary transition-colors"
<div class="flex-shrink-0 w-[256px]">
<Button
variant="secondary"
fullWidth
icon="add"
onclick={() => onAddColumn?.()}
>
<span
class="material-symbols-rounded"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>
add
</span>
Add column
</button>
</Button>
</div>
{/if}
</div>

View File

@@ -67,7 +67,7 @@
{#if ondelete}
<button
type="button"
class="absolute top-1 right-1 p-1 rounded-lg opacity-0 group-hover:opacity-100 hover:bg-error/20 transition-all z-10"
class="absolute top-1 right-1 p-1 rounded-full opacity-0 group-hover:opacity-100 hover:bg-error/20 transition-all z-10"
onclick={handleDelete}
aria-label="Delete card"
>
@@ -95,7 +95,7 @@
{/if}
<!-- Title -->
<p class="font-body text-body text-white w-full leading-none">
<p class="font-body text-body text-white w-full leading-none p-1">
{card.title}
</p>

View File

@@ -0,0 +1,369 @@
<script lang="ts">
import { Button, Modal, Card, Input } from "$lib/components/ui";
import { toasts } from "$lib/stores/toast.svelte";
import {
extractCalendarId,
getCalendarSubscribeUrl,
} from "$lib/api/google-calendar";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
import * as m from "$lib/paraglide/messages";
interface OrgCalendar {
id: string;
org_id: string;
calendar_id: string;
calendar_name: string | null;
}
interface Props {
supabase: SupabaseClient<Database>;
orgId: string;
userId: string;
orgCalendar: OrgCalendar | null;
initialShowConnect?: boolean;
serviceAccountEmail?: string | null;
}
let {
supabase,
orgId,
userId,
orgCalendar = $bindable(),
initialShowConnect = false,
serviceAccountEmail = null,
}: Props = $props();
let emailCopied = $state(false);
async function copyServiceEmail() {
if (!serviceAccountEmail) return;
await navigator.clipboard.writeText(serviceAccountEmail);
emailCopied = true;
setTimeout(() => (emailCopied = false), 2000);
}
let showConnectModal = $state(initialShowConnect);
let isLoading = $state(false);
let calendarUrlInput = $state("");
let calendarError = $state<string | null>(null);
async function handleSaveOrgCalendar() {
if (!calendarUrlInput.trim()) return;
isLoading = true;
calendarError = null;
const calendarId = extractCalendarId(calendarUrlInput.trim());
if (!calendarId) {
calendarError =
"Invalid calendar URL or ID. Please paste a Google Calendar share URL or calendar ID.";
isLoading = false;
return;
}
let calendarName = "Google Calendar";
if (calendarId.includes("@group.calendar.google.com")) {
calendarName = "Shared Calendar";
} else if (calendarId.includes("@gmail.com")) {
calendarName = calendarId.split("@")[0] + "'s Calendar";
}
const { data: newCal, error } = await supabase
.from("org_google_calendars")
.upsert(
{
org_id: orgId,
calendar_id: calendarId,
calendar_name: calendarName,
connected_by: userId,
},
{ onConflict: "org_id" },
)
.select()
.single();
if (error) {
calendarError = "Failed to save calendar.";
} else if (newCal) {
orgCalendar = newCal as OrgCalendar;
calendarUrlInput = "";
}
showConnectModal = false;
isLoading = false;
}
async function disconnectOrgCalendar() {
if (!confirm("Disconnect Google Calendar?")) return;
const { error } = await supabase
.from("org_google_calendars")
.delete()
.eq("org_id", orgId);
if (error) {
toasts.error(m.toast_error_disconnect_cal());
return;
}
orgCalendar = null;
}
</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>
{#if orgCalendar}
<div
class="mt-4 p-3 bg-green-500/10 border border-green-500/20 rounded-lg"
>
<div
class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 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>
</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
>
</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"
>
<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>
</div>
</div>
</Card>
<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"
>
<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>
</Card>
</div>
<!-- Connect Calendar Modal -->
<Modal
isOpen={showConnectModal}
onClose={() => (showConnectModal = false)}
title="Connect Google Calendar"
>
<div class="space-y-4">
<p class="text-sm text-light/70">
Connect any Google Calendar to your organization. Events you create
here will automatically appear in Google Calendar and vice versa.
</p>
<!-- Step 1: Share with service account -->
{#if serviceAccountEmail}
<div
class="p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg"
>
<p class="text-blue-400 font-medium text-sm mb-2">
Step 1: Share your calendar
</p>
<p class="text-xs text-light/60 mb-2">
In Google Calendar, go to your calendar's settings → "Share
with specific people" → add this email with <strong
>"Make changes to events"</strong
> permission:
</p>
<div class="flex items-center gap-2">
<code
class="flex-1 text-xs bg-light/10 px-3 py-2 rounded-lg text-light/80 truncate"
title={serviceAccountEmail}
>
{serviceAccountEmail}
</code>
<Button
size="sm"
variant="tertiary"
onclick={copyServiceEmail}
>
{emailCopied ? "Copied!" : "Copy"}
</Button>
</div>
</div>
{/if}
<!-- Step 2: Paste calendar ID -->
<div class="p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
<p class="text-blue-400 font-medium text-sm mb-2">
{serviceAccountEmail ? "Step 2" : "Step 1"}: Paste your Calendar
ID
</p>
<p class="text-xs text-light/60 mb-2">
In your calendar settings, scroll to "Integrate calendar" and
copy the <strong>Calendar ID</strong>.
</p>
</div>
<Input
label="Calendar ID"
bind:value={calendarUrlInput}
placeholder="e.g. abc123@group.calendar.google.com"
/>
{#if calendarError}
<p class="text-red-400 text-sm">{calendarError}</p>
{/if}
<div class="flex justify-end gap-2 pt-2">
<Button
variant="tertiary"
onclick={() => (showConnectModal = false)}>Cancel</Button
>
<Button
onclick={handleSaveOrgCalendar}
loading={isLoading}
disabled={!calendarUrlInput.trim()}>Connect</Button
>
</div>
</div>
</Modal>

View File

@@ -0,0 +1,398 @@
<script lang="ts">
import {
Button,
Modal,
Card,
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";
import * as m from "$lib/paraglide/messages";
const INVITE_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
interface ProfileData {
id: string;
email: string;
full_name: string | null;
avatar_url: string | null;
}
interface Member {
id: string;
user_id: string;
role: string;
role_id: string | null;
invited_at: string;
profiles: ProfileData | ProfileData[] | null;
}
interface OrgRole {
id: string;
org_id: string;
name: string;
color: string;
permissions: string[];
is_default: boolean;
is_system: boolean;
position: number;
}
interface Invite {
id: string;
email: string;
role: string;
role_id: string | null;
token: string;
expires_at: string;
created_at: string;
}
interface Props {
supabase: SupabaseClient<Database>;
orgId: string;
userId: string;
members: Member[];
roles: OrgRole[];
invites: Invite[];
}
let {
supabase,
orgId,
userId,
members = $bindable(),
roles,
invites = $bindable(),
}: Props = $props();
let showInviteModal = $state(false);
let inviteEmail = $state("");
let inviteRole = $state("editor");
let isSendingInvite = $state(false);
let showMemberModal = $state(false);
let selectedMember = $state<Member | null>(null);
let selectedMemberRole = $state("");
async function sendInvite() {
if (!inviteEmail.trim()) return;
isSendingInvite = true;
const email = inviteEmail.toLowerCase().trim();
// Delete any existing invite for this email first (handles 409 conflict)
await supabase
.from("org_invites")
.delete()
.eq("org_id", orgId)
.eq("email", email);
const { data: invite, error } = await supabase
.from("org_invites")
.insert({
org_id: orgId,
email,
role: inviteRole,
invited_by: userId,
expires_at: new Date(
Date.now() + INVITE_EXPIRY_MS,
).toISOString(),
})
.select()
.single();
if (!error && invite) {
invites = invites.filter((i) => i.email !== email);
invites = [...invites, invite as Invite];
inviteEmail = "";
showInviteModal = false;
} else if (error) {
toasts.error(m.toast_error_invite({ error: error.message }));
}
isSendingInvite = false;
}
async function cancelInvite(inviteId: string) {
await supabase.from("org_invites").delete().eq("id", inviteId);
invites = invites.filter((i) => i.id !== inviteId);
}
function openMemberModal(member: Member) {
selectedMember = member;
selectedMemberRole = member.role;
showMemberModal = true;
}
async function updateMemberRole() {
if (!selectedMember) return;
const { error } = await supabase
.from("org_members")
.update({ role: selectedMemberRole })
.eq("id", selectedMember.id);
if (error) {
toasts.error(m.toast_error_update_role());
return;
}
members = members.map((m) =>
m.id === selectedMember!.id
? { ...m, role: selectedMemberRole }
: m,
);
showMemberModal = false;
}
async function removeMember() {
if (!selectedMember) return;
const rp = selectedMember.profiles;
const prof = Array.isArray(rp) ? rp[0] : rp;
if (
!confirm(
`Remove ${prof?.full_name || prof?.email || "this member"} from the organization?`,
)
)
return;
const { error } = await supabase
.from("org_members")
.delete()
.eq("id", selectedMember.id);
if (error) {
toasts.error(m.toast_error_remove_member());
return;
}
members = members.filter((m) => m.id !== selectedMember!.id);
showMemberModal = false;
}
</script>
<div class="space-y-6">
<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>
{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>
{/each}
</div>
</div>
</Card>
{/if}
<!-- Members List -->
<Card>
<div class="divide-y divide-light/10">
{#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"
>
<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>
<div>
<p class="text-light font-medium">
{profile?.full_name ||
profile?.email ||
"Unknown User"}
</p>
<p class="text-sm text-light/50">
{profile?.email || "No email"}
</p>
</div>
</div>
<div class="flex items-center gap-3">
<span
class="px-2 py-1 text-xs rounded-full capitalize"
style="background-color: {roles.find(
(r) => r.name.toLowerCase() === member.role,
)?.color ?? '#6366f1'}20; color: {roles.find(
(r) => r.name.toLowerCase() === member.role,
)?.color ?? '#6366f1'}">{member.role}</span
>
{#if member.user_id !== userId && member.role !== "owner"}
<Button
variant="tertiary"
size="sm"
onclick={() => openMemberModal(member)}
>Edit</Button
>
{/if}
</div>
</div>
{/each}
</div>
</Card>
</div>
<!-- Invite Member Modal -->
<Modal
isOpen={showInviteModal}
onClose={() => (showInviteModal = false)}
title="Invite Member"
>
<div class="space-y-4">
<Input
type="email"
label="Email address"
bind:value={inviteEmail}
placeholder="colleague@example.com"
/>
<Select
label="Role"
bind:value={inviteRole}
placeholder=""
options={[
{ value: "viewer", label: "Viewer - Can view content" },
{
value: "commenter",
label: "Commenter - Can view and comment",
},
{
value: "editor",
label: "Editor - Can create and edit content",
},
{
value: "admin",
label: "Admin - Can manage members and settings",
},
]}
/>
<div class="flex justify-end gap-2 pt-2">
<Button variant="tertiary" onclick={() => (showInviteModal = false)}
>Cancel</Button
>
<Button
onclick={sendInvite}
loading={isSendingInvite}
disabled={!inviteEmail.trim()}>Send Invite</Button
>
</div>
</div>
</Modal>
<!-- Edit Member Modal -->
<Modal
isOpen={showMemberModal}
onClose={() => (showMemberModal = false)}
title="Edit Member"
>
{#if selectedMember}
{@const rawP = selectedMember.profiles}
{@const memberProfile = Array.isArray(rawP) ? rawP[0] : rawP}
<div class="space-y-4">
<div class="flex items-center gap-3 p-3 bg-light/5 rounded-lg">
<div
class="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium"
>
{(memberProfile?.full_name ||
memberProfile?.email ||
"?")[0].toUpperCase()}
</div>
<div>
<p class="text-light font-medium">
{memberProfile?.full_name || "No name"}
</p>
<p class="text-sm text-light/50">
{memberProfile?.email || "No email"}
</p>
</div>
</div>
<Select
label="Role"
bind:value={selectedMemberRole}
placeholder=""
options={[
{ value: "viewer", label: "Viewer" },
{ value: "commenter", label: "Commenter" },
{ value: "editor", label: "Editor" },
{ value: "admin", label: "Admin" },
]}
/>
<div class="flex items-center justify-between pt-2">
<Button variant="danger" onclick={removeMember}
>Remove from Org</Button
>
<div class="flex gap-2">
<Button
variant="tertiary"
onclick={() => (showMemberModal = false)}>Cancel</Button
>
<Button onclick={updateMemberRole}>Save</Button>
</div>
</div>
</div>
{/if}
</Modal>

View File

@@ -0,0 +1,350 @@
<script lang="ts">
import { Button, Modal, Card, 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";
import * as m from "$lib/paraglide/messages";
interface OrgRole {
id: string;
org_id: string;
name: string;
color: string;
permissions: string[];
is_default: boolean;
is_system: boolean;
position: number;
}
interface Props {
supabase: SupabaseClient<Database>;
orgId: string;
roles: OrgRole[];
}
let { supabase, orgId, roles = $bindable() }: Props = $props();
let showRoleModal = $state(false);
let editingRole = $state<OrgRole | null>(null);
let newRoleName = $state("");
let newRoleColor = $state("#6366f1");
let newRolePermissions = $state<string[]>([]);
let isSavingRole = $state(false);
const permissionGroups = [
{
name: "Documents",
permissions: [
"documents.view",
"documents.create",
"documents.edit",
"documents.delete",
],
},
{
name: "Kanban",
permissions: [
"kanban.view",
"kanban.create",
"kanban.edit",
"kanban.delete",
],
},
{
name: "Calendar",
permissions: [
"calendar.view",
"calendar.create",
"calendar.edit",
"calendar.delete",
],
},
{
name: "Members",
permissions: [
"members.view",
"members.invite",
"members.manage",
"members.remove",
],
},
{
name: "Roles",
permissions: [
"roles.view",
"roles.create",
"roles.edit",
"roles.delete",
],
},
{ name: "Settings", permissions: ["settings.view", "settings.edit"] },
];
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" },
];
function openRoleModal(role?: OrgRole) {
if (role) {
editingRole = role;
newRoleName = role.name;
newRoleColor = role.color;
newRolePermissions = [...role.permissions];
} else {
editingRole = null;
newRoleName = "";
newRoleColor = "#6366f1";
newRolePermissions = [
"documents.view",
"kanban.view",
"calendar.view",
"members.view",
];
}
showRoleModal = true;
}
async function saveRole() {
if (!newRoleName.trim()) return;
isSavingRole = true;
if (editingRole) {
const { error } = await supabase
.from("org_roles")
.update({
name: newRoleName,
color: newRoleColor,
permissions: newRolePermissions,
})
.eq("id", editingRole.id);
if (!error) {
roles = roles.map((r) =>
r.id === editingRole!.id
? {
...r,
name: newRoleName,
color: newRoleColor,
permissions: newRolePermissions,
}
: r,
);
}
} else {
const { data: role, error } = await supabase
.from("org_roles")
.insert({
org_id: orgId,
name: newRoleName,
color: newRoleColor,
permissions: newRolePermissions,
position: roles.length,
})
.select()
.single();
if (!error && role) {
roles = [...roles, role as OrgRole];
}
}
showRoleModal = false;
isSavingRole = false;
}
async function deleteRole(role: OrgRole) {
if (role.is_system) return;
if (
!confirm(
`Delete role "${role.name}"? Members with this role will need to be reassigned.`,
)
)
return;
const { error } = await supabase
.from("org_roles")
.delete()
.eq("id", role.id);
if (error) {
toasts.error(m.toast_error_delete_role());
return;
}
roles = roles.filter((r) => r.id !== role.id);
}
function togglePermission(perm: string) {
if (newRolePermissions.includes(perm)) {
newRolePermissions = newRolePermissions.filter((p) => p !== perm);
} else {
newRolePermissions = [...newRolePermissions, perm];
}
}
</script>
<div class="space-y-6">
<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">
Create custom roles with specific permissions.
</p>
</div>
<Button onclick={() => openRoleModal()} icon="add">
Create Role
</Button>
</div>
<div class="grid gap-4">
{#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("*")}
<span
class="text-xs bg-light/10 text-light/70 px-2 py-1 rounded"
>All Permissions</span
>
{: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
>
{/each}
{#if role.permissions.length > 6}
<span class="text-xs text-light/40"
>+{role.permissions.length - 6} more</span
>
{/if}
{/if}
</div>
</div>
</Card>
{/each}
</div>
</div>
<!-- Edit/Create Role Modal -->
<Modal
isOpen={showRoleModal}
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 gap-2">
{#each roleColors as color}
<button
type="button"
class="w-8 h-8 rounded-full transition-transform {newRoleColor ===
color.value
? 'ring-2 ring-white scale-110'
: ''}"
style="background-color: {color.value}"
onclick={() => (newRoleColor = color.value)}
title={color.label}
></button>
{/each}
</div>
</div>
<div>
<label class="block text-sm font-medium text-light mb-2"
>Permissions</label
>
<div class="space-y-3 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">
{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"
>
<input
type="checkbox"
checked={newRolePermissions.includes(
perm,
)}
onchange={() => togglePermission(perm)}
class="rounded"
/>
{perm.split(".")[1]}
</label>
{/each}
</div>
</div>
{/each}
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<Button variant="tertiary" onclick={() => (showRoleModal = false)}
>Cancel</Button
>
<Button
onclick={saveRole}
loading={isSavingRole}
disabled={!newRoleName.trim()}
>{editingRole ? "Save" : "Create"}</Button
>
</div>
</div>
</Modal>

View File

@@ -1 +1,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';

View File

@@ -0,0 +1,104 @@
<script lang="ts">
import { on } from "svelte/events";
interface MenuItem {
label: string;
icon?: string;
onclick: () => void;
danger?: boolean;
divider?: boolean;
}
interface Props {
items: MenuItem[];
align?: "left" | "right";
}
let { items, align = "right" }: Props = $props();
let isOpen = $state(false);
let containerEl = $state<HTMLElement | null>(null);
// Attach click-outside and Escape listeners only while menu is open
$effect(() => {
if (!isOpen) return;
let cleanupClick: (() => void) | undefined;
const timer = setTimeout(() => {
cleanupClick = on(document, "click", (e: MouseEvent) => {
if (containerEl && !containerEl.contains(e.target as Node)) {
isOpen = false;
}
});
}, 0);
const cleanupKey = on(document, "keydown", (e: Event) => {
if ((e as KeyboardEvent).key === "Escape") isOpen = false;
});
return () => {
clearTimeout(timer);
cleanupClick?.();
cleanupKey();
};
});
function handleItemClick(item: MenuItem) {
item.onclick();
isOpen = false;
}
</script>
<div class="relative context-menu-container" bind:this={containerEl}>
<button
type="button"
class="w-8 h-8 flex items-center justify-center rounded-full hover:bg-light/10 transition-colors"
onclick={() => (isOpen = !isOpen)}
aria-expanded={isOpen}
aria-haspopup="true"
aria-label="More options"
>
<span
class="material-symbols-rounded text-light/60 hover:text-light"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>
more_horiz
</span>
</button>
{#if isOpen}
<div
class="
absolute z-50 mt-1 py-1 bg-dark border border-light/10 rounded-xl shadow-xl min-w-[180px]
animate-in fade-in slide-in-from-top-2 duration-150
{align === 'right' ? 'right-0' : 'left-0'}
"
>
{#each items as item}
{#if item.divider}
<div class="border-t border-light/10 my-1"></div>
{/if}
<button
type="button"
class="
w-full flex items-center gap-3 px-3 py-2 text-sm text-left transition-colors
{item.danger ? 'text-error hover:bg-error/10' : 'text-light hover:bg-light/5'}
"
onclick={() => handleItemClick(item)}
>
{#if item.icon}
<span
class="material-symbols-rounded shrink-0 {item.danger
? 'text-error/60'
: 'text-light/50'}"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>
{item.icon}
</span>
{/if}
<span class="flex-1">{item.label}</span>
</button>
{/each}
</div>
{/if}
</div>

View File

@@ -1,11 +1,11 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
onclick?: () => void;
variant?: 'ghost' | 'subtle' | 'solid';
size?: 'sm' | 'md' | 'lg';
variant?: "ghost" | "subtle" | "solid";
size?: "sm" | "md" | "lg";
disabled?: boolean;
title?: string;
class?: string;
@@ -14,29 +14,29 @@
let {
children,
onclick,
variant = 'ghost',
size = 'md',
variant = "ghost",
size = "md",
disabled = false,
title,
class: className = '',
class: className = "",
}: Props = $props();
const variantClasses = {
ghost: 'hover:bg-light/10 text-light/60 hover:text-light',
subtle: 'bg-light/5 hover:bg-light/10 text-light/60 hover:text-light',
solid: 'bg-primary/20 hover:bg-primary/30 text-primary',
ghost: "hover:bg-light/10 text-light/60 hover:text-light",
subtle: "bg-light/5 hover:bg-light/10 text-light/60 hover:text-light",
solid: "bg-primary/20 hover:bg-primary/30 text-primary",
};
const sizeClasses = {
sm: 'w-7 h-7',
md: 'w-9 h-9',
lg: 'w-11 h-11',
sm: "w-7 h-7",
md: "w-9 h-9",
lg: "w-11 h-11",
};
const iconSizeClasses = {
sm: '[&>svg]:w-4 [&>svg]:h-4',
md: '[&>svg]:w-5 [&>svg]:h-5',
lg: '[&>svg]:w-6 [&>svg]:h-6',
sm: "[&>svg]:w-4 [&>svg]:h-4",
md: "[&>svg]:w-5 [&>svg]:h-5",
lg: "[&>svg]:w-6 [&>svg]:h-6",
};
</script>
@@ -47,7 +47,7 @@
{title}
aria-label={title}
class="
inline-flex items-center justify-center rounded-lg transition-colors
inline-flex items-center justify-center rounded-full transition-colors cursor-pointer
disabled:opacity-50 disabled:cursor-not-allowed
{variantClasses[variant]}
{sizeClasses[size]}

View File

@@ -54,7 +54,7 @@
<!-- Add button -->
{#if onAddCard}
<Button variant="secondary" fullWidth onclick={onAddCard}>
<Button variant="secondary" fullWidth icon="add" onclick={onAddCard}>
Add card
</Button>
{/if}

View File

@@ -1,34 +1,39 @@
<script lang="ts">
interface Props {
size?: "sm" | "md";
size?: "sm" | "md" | "lg";
showText?: boolean;
}
let { size = "md" }: Props = $props();
let { size = "md", showText = false }: Props = $props();
const sizeClasses = {
sm: "w-10 h-10",
md: "w-12 h-12",
const iconSizes = {
sm: "w-8 h-8",
md: "w-10 h-10",
lg: "w-12 h-12",
};
const textSizes = {
sm: "text-[14px]",
md: "text-[18px]",
lg: "text-[22px]",
};
</script>
<div class="flex items-center justify-center {sizeClasses[size]}">
<div class="flex items-center gap-2">
<div class="shrink-0 {iconSizes[size]} transition-all duration-300">
<svg
viewBox="0 0 38 21"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="w-full h-auto"
>
<!-- Root logo SVG paths matching Figma -->
<path
d="M0 0.5C0 0.224 0.224 0 0.5 0H37.5C37.776 0 38 0.224 38 0.5V12.203C38 12.479 37.776 12.703 37.5 12.703H0.5C0.224 12.703 0 12.479 0 12.203V0.5Z"
fill="#00A3E0"
fill-opacity="0.2"
/>
<!-- Left eye -->
<circle cx="11.5" cy="7.5" r="5" fill="#00A3E0" />
<!-- Right eye -->
<circle cx="23.5" cy="7.5" r="5" fill="#00A3E0" />
<!-- Mouth/smile -->
<path
d="M12.25 15.04C12.25 15.04 15 20.25 18.75 20.25C22.5 20.25 25.25 15.04 25.25 15.04"
stroke="#00A3E0"
@@ -37,3 +42,13 @@
/>
</svg>
</div>
{#if showText}
<span
class="font-heading {textSizes[
size
]} text-primary leading-none whitespace-nowrap transition-all duration-300"
>
Root
</span>
{/if}
</div>

View File

@@ -64,7 +64,7 @@
{title}
</h2>
<button
class="w-8 h-8 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
class="w-8 h-8 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded-full transition-colors"
onclick={onClose}
aria-label="Close"
>

View File

@@ -0,0 +1,119 @@
<script lang="ts">
import Skeleton from "./Skeleton.svelte";
interface Props {
variant?: "default" | "kanban" | "files" | "calendar" | "settings";
}
let { variant = "default" }: Props = $props();
</script>
<div class="flex flex-col h-full p-4 lg:p-5 gap-4 animate-in">
<!-- Header skeleton -->
<header class="flex items-center gap-2 p-1">
<Skeleton variant="text" width="200px" height="2rem" />
<div class="flex-1"></div>
<Skeleton variant="rectangular" width="80px" height="40px" class="rounded-[32px]" />
<Skeleton variant="circular" width="32px" height="32px" />
</header>
{#if variant === "kanban"}
<!-- Kanban skeleton: columns with cards -->
<div class="flex gap-2 flex-1 overflow-hidden">
{#each Array(3) as _}
<div class="flex-shrink-0 w-[256px] bg-background rounded-[32px] px-4 py-5 flex flex-col gap-4">
<div class="flex items-center gap-2">
<Skeleton variant="text" width="120px" height="1.25rem" />
<Skeleton variant="rectangular" width="24px" height="20px" class="rounded-[8px]" />
</div>
{#each Array(3) as __}
<Skeleton variant="card" height="80px" class="rounded-[16px]" />
{/each}
</div>
{/each}
</div>
{:else if variant === "files"}
<!-- Files skeleton: toolbar + grid -->
<div class="flex items-center gap-2">
<Skeleton variant="text" width="300px" height="2.5rem" class="rounded-[32px]" />
<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-4">
{#each Array(12) as _}
<Skeleton variant="card" height="120px" class="rounded-[16px]" />
{/each}
</div>
{:else if variant === "calendar"}
<!-- Calendar skeleton: nav + grid -->
<div class="flex items-center gap-2 px-2">
<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-[32px]" />
</div>
<div class="flex-1 bg-background rounded-xl p-2">
<div class="grid grid-cols-7 gap-2">
{#each Array(7) as _}
<Skeleton variant="text" width="100%" height="2rem" />
{/each}
</div>
<div class="grid grid-cols-7 gap-2 mt-2">
{#each Array(35) as _}
<Skeleton variant="rectangular" width="100%" height="80px" class="rounded-none" />
{/each}
</div>
</div>
{:else if variant === "settings"}
<!-- Settings skeleton: tabs + content -->
<div class="flex gap-2">
{#each Array(4) as _}
<Skeleton variant="rectangular" width="80px" height="36px" class="rounded-[32px]" />
{/each}
</div>
<div class="bg-background rounded-[32px] p-6 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-[32px]" />
<Skeleton variant="rectangular" width="100%" height="48px" class="rounded-[32px]" />
</div>
{:else}
<!-- Default: overview-like skeleton -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
{#each Array(4) as _}
<Skeleton variant="card" height="100px" class="rounded-2xl" />
{/each}
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 flex-1">
<div class="lg:col-span-2 bg-night rounded-2xl p-5 flex flex-col gap-3">
<Skeleton variant="text" width="160px" height="1.5rem" />
{#each Array(5) as _}
<div class="flex items-center gap-3 px-3 py-2">
<Skeleton variant="circular" width="24px" height="24px" />
<Skeleton variant="text" width="80%" height="1rem" />
</div>
{/each}
</div>
<div class="flex flex-col gap-6">
<div class="bg-night rounded-2xl p-5 flex flex-col gap-3">
<Skeleton variant="text" width="120px" height="1.5rem" />
{#each Array(3) as _}
<Skeleton variant="rectangular" width="100%" height="40px" class="rounded-xl" />
{/each}
</div>
</div>
</div>
{/if}
</div>
<style>
.animate-in {
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
</style>

View File

@@ -24,3 +24,5 @@ export { default as Logo } from './Logo.svelte';
export { default as ContentHeader } from './ContentHeader.svelte';
export { default as Icon } from './Icon.svelte';
export { default as AssigneePicker } from './AssigneePicker.svelte';
export { default as ContextMenu } from './ContextMenu.svelte';
export { default as PageSkeleton } from './PageSkeleton.svelte';

29
src/lib/types/layout.ts Normal file
View File

@@ -0,0 +1,29 @@
import type { Organization, Profile } from '$lib/supabase/types';
export interface OrgMemberWithProfile {
id: string;
user_id: string | null;
role: string;
profiles: {
id: string;
email: string;
full_name: string | null;
avatar_url: string | null;
} | null;
}
export interface OrgLayoutData {
org: Organization;
userRole: string;
userPermissions: string[] | null;
members: OrgMemberWithProfile[];
recentActivity: unknown[];
stats: {
memberCount: number;
documentCount: number;
folderCount: number;
kanbanCount: number;
};
user: { id: string; email?: string };
profile: Pick<Profile, 'id' | 'email' | 'full_name' | 'avatar_url'>;
}

View File

@@ -0,0 +1,114 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createLogger, setLogLevel, getRecentLogs, clearRecentLogs, dumpLogs } from './logger';
describe('logger', () => {
beforeEach(() => {
clearRecentLogs();
setLogLevel('debug');
});
it('createLogger returns scoped logger with all levels', () => {
const log = createLogger('test.context');
expect(log.debug).toBeTypeOf('function');
expect(log.info).toBeTypeOf('function');
expect(log.warn).toBeTypeOf('function');
expect(log.error).toBeTypeOf('function');
});
it('logs are stored in recent logs buffer', () => {
const log = createLogger('test');
log.info('hello');
log.warn('warning');
const recent = getRecentLogs();
expect(recent).toHaveLength(2);
expect(recent[0].level).toBe('info');
expect(recent[0].context).toBe('test');
expect(recent[0].message).toBe('hello');
expect(recent[1].level).toBe('warn');
});
it('clearRecentLogs empties the buffer', () => {
const log = createLogger('test');
log.info('msg');
expect(getRecentLogs()).toHaveLength(1);
clearRecentLogs();
expect(getRecentLogs()).toHaveLength(0);
});
it('setLogLevel filters lower-priority logs', () => {
setLogLevel('warn');
const log = createLogger('test');
log.debug('should be filtered');
log.info('should be filtered');
log.warn('should appear');
log.error('should appear');
const recent = getRecentLogs();
expect(recent).toHaveLength(2);
expect(recent[0].level).toBe('warn');
expect(recent[1].level).toBe('error');
});
it('log entries include structured data', () => {
const log = createLogger('test');
log.info('with data', { data: { userId: '123' } });
const recent = getRecentLogs();
expect(recent[0].data).toEqual({ userId: '123' });
});
it('log entries include error objects', () => {
const log = createLogger('test');
const err = new Error('test error');
log.error('failed', { error: err });
const recent = getRecentLogs();
expect(recent[0].error).toBe(err);
});
it('log entries have ISO timestamp', () => {
const log = createLogger('test');
log.info('timestamped');
const recent = getRecentLogs();
expect(recent[0].timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
it('dumpLogs formats entries as readable string', () => {
const log = createLogger('ctx');
log.info('message one', { data: { key: 'val' } });
log.error('message two');
const dump = dumpLogs();
expect(dump).toContain('[INFO]');
expect(dump).toContain('[ctx]');
expect(dump).toContain('message one');
expect(dump).toContain('"key": "val"');
expect(dump).toContain('[ERROR]');
expect(dump).toContain('message two');
});
it('getRecentLogs returns a copy, not the internal buffer', () => {
const log = createLogger('test');
log.info('msg');
const copy = getRecentLogs();
copy.push({ level: 'debug', context: 'fake', message: 'injected', timestamp: '' });
expect(getRecentLogs()).toHaveLength(1);
});
it('buffer caps at 100 entries', () => {
const log = createLogger('test');
for (let i = 0; i < 110; i++) {
log.info(`msg ${i}`);
}
const recent = getRecentLogs();
expect(recent).toHaveLength(100);
expect(recent[0].message).toBe('msg 10');
expect(recent[99].message).toBe('msg 109');
});
});

View File

@@ -40,8 +40,8 @@ const RESET = '\x1b[0m';
const BOLD = '\x1b[1m';
const DIM = '\x1b[2m';
// Minimum level to output — can be overridden
let minLevel: LogLevel = 'debug';
// Minimum level to output — debug in dev, info in prod
let minLevel: LogLevel = typeof window !== 'undefined' && window.location?.hostname === 'localhost' ? 'debug' : 'info';
function shouldLog(level: LogLevel): boolean {
return LEVEL_PRIORITY[level] >= LEVEL_PRIORITY[minLevel];

View File

@@ -0,0 +1,114 @@
import { describe, it, expect } from 'vitest';
import {
hasPermission,
hasAllPermissions,
hasAnyPermission,
resolvePermissions,
PERMISSIONS,
} from './permissions';
describe('hasPermission', () => {
it('owner has wildcard — grants any permission', () => {
expect(hasPermission('owner', null, PERMISSIONS.DOCUMENTS_VIEW)).toBe(true);
expect(hasPermission('owner', null, PERMISSIONS.SETTINGS_EDIT)).toBe(true);
expect(hasPermission('owner', null, PERMISSIONS.MEMBERS_REMOVE)).toBe(true);
});
it('admin has wildcard — grants any permission', () => {
expect(hasPermission('admin', null, PERMISSIONS.ROLES_DELETE)).toBe(true);
expect(hasPermission('admin', null, PERMISSIONS.KANBAN_CREATE)).toBe(true);
});
it('editor has limited permissions', () => {
expect(hasPermission('editor', null, PERMISSIONS.DOCUMENTS_VIEW)).toBe(true);
expect(hasPermission('editor', null, PERMISSIONS.DOCUMENTS_CREATE)).toBe(true);
expect(hasPermission('editor', null, PERMISSIONS.DOCUMENTS_DELETE)).toBe(false);
expect(hasPermission('editor', null, PERMISSIONS.MEMBERS_MANAGE)).toBe(false);
expect(hasPermission('editor', null, PERMISSIONS.SETTINGS_EDIT)).toBe(false);
});
it('viewer has read-only permissions', () => {
expect(hasPermission('viewer', null, PERMISSIONS.DOCUMENTS_VIEW)).toBe(true);
expect(hasPermission('viewer', null, PERMISSIONS.KANBAN_VIEW)).toBe(true);
expect(hasPermission('viewer', null, PERMISSIONS.DOCUMENTS_CREATE)).toBe(false);
expect(hasPermission('viewer', null, PERMISSIONS.KANBAN_EDIT)).toBe(false);
expect(hasPermission('viewer', null, PERMISSIONS.SETTINGS_VIEW)).toBe(false);
});
it('custom permissions override built-in role defaults', () => {
const custom = ['documents.view', 'documents.create', 'kanban.view'];
expect(hasPermission('viewer', custom, PERMISSIONS.DOCUMENTS_CREATE)).toBe(true);
expect(hasPermission('viewer', custom, PERMISSIONS.KANBAN_VIEW)).toBe(true);
expect(hasPermission('viewer', custom, PERMISSIONS.KANBAN_CREATE)).toBe(false);
});
it('wildcard in custom permissions grants everything', () => {
expect(hasPermission('viewer', ['*'], PERMISSIONS.SETTINGS_EDIT)).toBe(true);
});
it('unknown role with no custom permissions has no access', () => {
expect(hasPermission('unknown_role', null, PERMISSIONS.DOCUMENTS_VIEW)).toBe(false);
});
it('empty custom permissions array denies everything', () => {
expect(hasPermission('editor', [], PERMISSIONS.DOCUMENTS_VIEW)).toBe(false);
});
});
describe('hasAllPermissions', () => {
it('returns true when user has all requested permissions', () => {
expect(hasAllPermissions('owner', null, [
PERMISSIONS.DOCUMENTS_VIEW,
PERMISSIONS.DOCUMENTS_CREATE,
PERMISSIONS.SETTINGS_EDIT,
])).toBe(true);
});
it('returns false when user is missing one permission', () => {
expect(hasAllPermissions('editor', null, [
PERMISSIONS.DOCUMENTS_VIEW,
PERMISSIONS.DOCUMENTS_DELETE,
])).toBe(false);
});
it('returns true for empty permission list', () => {
expect(hasAllPermissions('viewer', null, [])).toBe(true);
});
});
describe('hasAnyPermission', () => {
it('returns true when user has at least one permission', () => {
expect(hasAnyPermission('viewer', null, [
PERMISSIONS.DOCUMENTS_DELETE,
PERMISSIONS.DOCUMENTS_VIEW,
])).toBe(true);
});
it('returns false when user has none of the permissions', () => {
expect(hasAnyPermission('viewer', null, [
PERMISSIONS.SETTINGS_EDIT,
PERMISSIONS.MEMBERS_MANAGE,
])).toBe(false);
});
it('returns false for empty permission list', () => {
expect(hasAnyPermission('owner', null, [])).toBe(false);
});
});
describe('resolvePermissions', () => {
it('returns built-in defaults when no custom permissions', () => {
const perms = resolvePermissions('viewer', null);
expect(perms).toContain('documents.view');
expect(perms).not.toContain('documents.create');
});
it('returns custom permissions when provided', () => {
const custom = ['documents.view', 'kanban.create'];
expect(resolvePermissions('viewer', custom)).toEqual(custom);
});
it('returns empty array for unknown role with no custom permissions', () => {
expect(resolvePermissions('unknown', null)).toEqual([]);
});
});

View File

@@ -0,0 +1,116 @@
/**
* Permission enforcement utility for role-based access control.
*
* Permission keys follow the pattern: `resource.action`
* e.g. "documents.view", "kanban.create", "members.manage"
*
* System roles (owner, admin) have wildcard "*" permission.
* Custom roles have explicit permission arrays stored on org_roles.
*/
/** All known permission keys */
export const PERMISSIONS = {
// Documents
DOCUMENTS_VIEW: 'documents.view',
DOCUMENTS_CREATE: 'documents.create',
DOCUMENTS_EDIT: 'documents.edit',
DOCUMENTS_DELETE: 'documents.delete',
// Kanban
KANBAN_VIEW: 'kanban.view',
KANBAN_CREATE: 'kanban.create',
KANBAN_EDIT: 'kanban.edit',
KANBAN_DELETE: 'kanban.delete',
// Calendar
CALENDAR_VIEW: 'calendar.view',
CALENDAR_CREATE: 'calendar.create',
CALENDAR_EDIT: 'calendar.edit',
CALENDAR_DELETE: 'calendar.delete',
// Members
MEMBERS_VIEW: 'members.view',
MEMBERS_INVITE: 'members.invite',
MEMBERS_MANAGE: 'members.manage',
MEMBERS_REMOVE: 'members.remove',
// Roles
ROLES_VIEW: 'roles.view',
ROLES_CREATE: 'roles.create',
ROLES_EDIT: 'roles.edit',
ROLES_DELETE: 'roles.delete',
// Settings
SETTINGS_VIEW: 'settings.view',
SETTINGS_EDIT: 'settings.edit',
} as const;
export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
/** Default permissions for built-in roles (when no custom org_role is assigned) */
const BUILTIN_ROLE_PERMISSIONS: Record<string, readonly string[]> = {
owner: ['*'],
admin: ['*'],
editor: [
'documents.view', 'documents.create', 'documents.edit',
'kanban.view', 'kanban.create', 'kanban.edit',
'calendar.view', 'calendar.create', 'calendar.edit',
'members.view',
],
viewer: [
'documents.view',
'kanban.view',
'calendar.view',
'members.view',
],
};
/**
* Check if a user has a specific permission.
*
* @param userRole - The user's built-in role string (owner/admin/editor/viewer)
* @param userPermissions - The user's resolved permission array from their org_role (if any)
* @param permission - The permission key to check
* @returns true if the user has the permission
*/
export function hasPermission(
userRole: string,
userPermissions: readonly string[] | null | undefined,
permission: Permission | string,
): boolean {
// If we have explicit permissions from a custom org_role, use those
const perms = userPermissions ?? BUILTIN_ROLE_PERMISSIONS[userRole] ?? [];
// Wildcard grants everything
if (perms.includes('*')) return true;
return perms.includes(permission);
}
/**
* Check if a user has ALL of the specified permissions.
*/
export function hasAllPermissions(
userRole: string,
userPermissions: readonly string[] | null | undefined,
permissions: readonly (Permission | string)[],
): boolean {
return permissions.every((p) => hasPermission(userRole, userPermissions, p));
}
/**
* Check if a user has ANY of the specified permissions.
*/
export function hasAnyPermission(
userRole: string,
userPermissions: readonly string[] | null | undefined,
permissions: readonly (Permission | string)[],
): boolean {
return permissions.some((p) => hasPermission(userRole, userPermissions, p));
}
/**
* Get the resolved permission array for a user.
* Useful for passing to components that need to check multiple permissions.
*/
export function resolvePermissions(
userRole: string,
userPermissions: readonly string[] | null | undefined,
): readonly string[] {
return userPermissions ?? BUILTIN_ROLE_PERMISSIONS[userRole] ?? [];
}

View File

@@ -1,4 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import { locales, localizeHref } from '$lib/paraglide/runtime';
import "./layout.css";
import favicon from "$lib/assets/favicon.svg";
import { createClient } from "$lib/supabase";
@@ -6,11 +8,21 @@
import { ToastContainer } from "$lib/components/ui";
let { children, data } = $props();
const supabase = createClient();
setContext("supabase", supabase);
</script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
{@render children()}
<ToastContainer />
<div style="display:none">
{#each locales as locale}
<a
href={localizeHref(page.url.pathname, { locale })}
>
{locale}
</a>
{/each}
</div>

View File

@@ -19,27 +19,17 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
error(404, 'Organization not found');
}
// Now fetch membership, members, and activity in parallel (all depend on org.id)
const [membershipResult, membersResult, activityResult] = await Promise.all([
// Now fetch membership, members, activity, and user profile in parallel (all depend on org.id)
const [membershipResult, membersResult, activityResult, profileResult, docCountResult, folderCountResult, kanbanCountResult] = await Promise.all([
locals.supabase
.from('org_members')
.select('role')
.select('role, role_id')
.eq('org_id', org.id)
.eq('user_id', user.id)
.single(),
locals.supabase
.from('org_members')
.select(`
id,
user_id,
role,
profiles:user_id (
id,
email,
full_name,
avatar_url
)
`)
.select('id, user_id, role')
.eq('org_id', org.id)
.limit(10),
locals.supabase
@@ -58,23 +48,87 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
`)
.eq('org_id', org.id)
.order('created_at', { ascending: false })
.limit(10)
.limit(10),
locals.supabase
.from('profiles')
.select('id, email, full_name, avatar_url')
.eq('id', user.id)
.single(),
locals.supabase
.from('documents')
.select('id', { count: 'exact', head: true })
.eq('org_id', org.id)
.eq('type', 'document'),
locals.supabase
.from('documents')
.select('id', { count: 'exact', head: true })
.eq('org_id', org.id)
.eq('type', 'folder'),
locals.supabase
.from('documents')
.select('id', { count: 'exact', head: true })
.eq('org_id', org.id)
.eq('type', 'kanban')
]);
const { data: membership } = membershipResult;
const { data: members } = membersResult;
const { data: rawMembers } = membersResult;
const { data: recentActivity } = activityResult;
const { data: profile } = profileResult;
const stats = {
memberCount: (rawMembers ?? []).length,
documentCount: docCountResult.count ?? 0,
folderCount: folderCountResult.count ?? 0,
kanbanCount: kanbanCountResult.count ?? 0,
};
if (!membership) {
error(403, 'You are not a member of this organization');
}
// Resolve user's permissions from their custom org_role (if assigned)
let userPermissions: string[] | null = null;
if (membership.role_id) {
const { data: roleData } = await locals.supabase
.from('org_roles')
.select('permissions')
.eq('id', membership.role_id)
.single();
if (roleData?.permissions && Array.isArray(roleData.permissions)) {
userPermissions = roleData.permissions as string[];
}
}
// Fetch profiles separately since org_members.user_id FK points to auth.users, not profiles
const memberUserIds = (rawMembers ?? []).map(m => m.user_id).filter((id): id is string => id !== null);
let memberProfilesMap: Record<string, { id: string; email: string; full_name: string | null; avatar_url: string | null }> = {};
if (memberUserIds.length > 0) {
const { data: memberProfiles } = await locals.supabase
.from('profiles')
.select('id, email, full_name, avatar_url')
.in('id', memberUserIds);
if (memberProfiles) {
memberProfilesMap = Object.fromEntries(memberProfiles.map(p => [p.id, p]));
}
}
const members = (rawMembers ?? []).map(m => ({
...m,
profiles: (m.user_id ? memberProfilesMap[m.user_id] : null) ?? null
}));
return {
org,
role: membership.role,
userRole: membership.role, // kept for backwards compat — same as role
members: members ?? [],
userRole: membership.role,
userPermissions,
members,
recentActivity: recentActivity ?? [],
user
stats,
user,
profile: profile ?? { id: user.id, email: user.email ?? '', full_name: null, avatar_url: null }
};
};

View File

@@ -1,7 +1,15 @@
<script lang="ts">
import { page, navigating } from "$app/stores";
import { goto } from "$app/navigation";
import type { Snippet } from "svelte";
import { Avatar, Logo } from "$lib/components/ui";
import { getContext } from "svelte";
import { on } from "svelte/events";
import { Avatar, Logo, PageSkeleton } from "$lib/components/ui";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
import { hasPermission, type Permission } from "$lib/utils/permissions";
import { setContext } from "svelte";
import * as m from "$lib/paraglide/messages";
interface Member {
id: string;
@@ -15,6 +23,13 @@
};
}
interface UserProfile {
id: string;
email: string;
full_name: string | null;
avatar_url: string | null;
}
interface Props {
data: {
org: {
@@ -23,41 +38,96 @@
slug: string;
avatar_url?: string | null;
};
role: string;
userRole: string;
userPermissions: string[] | null;
members: Member[];
profile: UserProfile;
};
children: Snippet;
}
let { data, children }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
const isAdmin = $derived(
data.userRole === "owner" || data.userRole === "admin",
);
// Provide a permission checker via context so any child component can use it
const canAccess = (permission: Permission | string): boolean =>
hasPermission(data.userRole, data.userPermissions, permission);
setContext("canAccess", canAccess);
// Sidebar collapses on all pages except org overview
const isOrgOverview = $derived($page.url.pathname === `/${data.org.slug}`);
let sidebarHovered = $state(false);
const sidebarCollapsed = $derived(!isOrgOverview && !sidebarHovered);
// User dropdown
let showUserMenu = $state(false);
let menuContainerEl = $state<HTMLElement | null>(null);
// Attach click-outside and Escape listeners only while menu is open.
// Uses svelte/events 'on' to respect Svelte 5 event delegation order.
$effect(() => {
if (!showUserMenu) return;
// Defer so the opening click doesn't immediately close the menu
const timer = setTimeout(() => {
cleanupClick = on(document, "click", (e: MouseEvent) => {
if (
menuContainerEl &&
!menuContainerEl.contains(e.target as Node)
) {
showUserMenu = false;
}
});
}, 0);
const cleanupKey = on(document, "keydown", (e: Event) => {
if ((e as KeyboardEvent).key === "Escape") showUserMenu = false;
});
let cleanupClick: (() => void) | undefined;
return () => {
clearTimeout(timer);
cleanupClick?.();
cleanupKey();
};
});
async function handleLogout() {
await supabase.auth.signOut();
goto("/");
}
const navItems = $derived([
...(canAccess("documents.view")
? [
{
href: `/${data.org.slug}/documents`,
label: "Files",
label: m.nav_files(),
icon: "cloud",
},
]
: []),
...(canAccess("calendar.view")
? [
{
href: `/${data.org.slug}/calendar`,
label: "Calendar",
label: m.nav_calendar(),
icon: "calendar_today",
},
// Only show settings for admins
...(isAdmin
]
: []),
// Settings requires settings.view or admin role
...(canAccess("settings.view")
? [
{
href: `/${data.org.slug}/settings`,
label: "Settings",
label: m.nav_settings(),
icon: "settings",
},
]
@@ -110,7 +180,7 @@
<p
class="text-body-sm text-white font-body capitalize whitespace-nowrap"
>
{data.role}
{data.userRole}
</p>
</div>
</a>
@@ -152,10 +222,107 @@
{/each}
</nav>
<!-- Logo at bottom -->
<div class="mt-auto">
<a href="/" title="Back to organizations">
<Logo size={sidebarCollapsed ? "sm" : "md"} />
<!-- User Section + Logo at bottom -->
<div class="mt-auto flex flex-col gap-3">
<!-- User Avatar + Quick Menu -->
<div
class="relative user-menu-container"
bind:this={menuContainerEl}
>
<button
type="button"
class="flex items-center gap-2 p-1 rounded-[32px] hover:bg-dark transition-colors w-full"
onclick={() => (showUserMenu = !showUserMenu)}
aria-expanded={showUserMenu}
aria-haspopup="true"
>
<div
class="shrink-0 transition-all duration-300 {sidebarCollapsed
? 'w-8 h-8'
: 'w-10 h-10'}"
>
<Avatar
name={data.profile.full_name || data.profile.email}
src={data.profile.avatar_url}
size="sm"
/>
</div>
<div
class="min-w-0 flex-1 overflow-hidden text-left transition-all duration-300 {sidebarCollapsed
? 'opacity-0 max-w-0'
: 'opacity-100 max-w-[200px]'}"
>
<p
class="font-body text-body-sm text-white truncate whitespace-nowrap leading-tight"
>
{data.profile.full_name || "User"}
</p>
<p
class="font-body text-[11px] text-light/50 truncate whitespace-nowrap leading-tight"
>
{data.profile.email}
</p>
</div>
</button>
{#if showUserMenu}
<div
class="absolute bottom-full left-0 mb-2 py-1 bg-dark border border-light/10 rounded-xl shadow-xl min-w-[200px] z-50"
>
<a
href="/{data.org.slug}/account"
class="flex items-center gap-3 px-3 py-2 text-sm text-light hover:bg-light/5 transition-colors"
onclick={() => (showUserMenu = false)}
>
<span
class="material-symbols-rounded text-light/50"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>
person
</span>
<span>{m.user_menu_account_settings()}</span>
</a>
<a
href="/"
class="flex items-center gap-3 px-3 py-2 text-sm text-light hover:bg-light/5 transition-colors"
onclick={() => (showUserMenu = false)}
>
<span
class="material-symbols-rounded text-light/50"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>
swap_horiz
</span>
<span>{m.user_menu_switch_org()}</span>
</a>
<div class="border-t border-light/10 my-1"></div>
<button
type="button"
class="w-full flex items-center gap-3 px-3 py-2 text-sm text-error hover:bg-error/10 transition-colors"
onclick={handleLogout}
>
<span
class="material-symbols-rounded text-error/60"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>
logout
</span>
<span>{m.user_menu_logout()}</span>
</button>
</div>
{/if}
</div>
<!-- Logo -->
<a
href="/"
title="Back to organizations"
class="flex items-center justify-center"
>
<Logo
size={sidebarCollapsed ? "sm" : "md"}
showText={!sidebarCollapsed}
/>
</a>
</div>
</aside>
@@ -163,17 +330,19 @@
<!-- Main Content Area -->
<main class="flex-1 bg-night rounded-[32px] overflow-auto relative">
{#if $navigating}
<div
class="absolute inset-0 z-10 flex items-center justify-center bg-night/80 backdrop-blur-sm"
>
<span
class="material-symbols-rounded text-primary animate-spin"
style="font-size: 40px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 40;"
>
progress_activity
</span>
</div>
{/if}
{@const target = $navigating.to?.url.pathname ?? ""}
{@const skeletonVariant = target.includes("/kanban")
? "kanban"
: target.includes("/documents")
? "files"
: target.includes("/calendar")
? "calendar"
: target.includes("/settings")
? "settings"
: "default"}
<PageSkeleton variant={skeletonVariant} />
{:else}
{@render children()}
{/if}
</main>
</div>

View File

@@ -1,21 +1,369 @@
<script lang="ts">
import { Avatar, Card } from "$lib/components/ui";
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 {
data: {
org: { id: string; name: string; slug: string };
userRole: string;
stats: {
memberCount: number;
documentCount: number;
folderCount: number;
kanbanCount: number;
};
recentActivity: ActivityEntry[];
members: {
id: string;
user_id: string;
role: string;
profiles: {
id: string;
email: string;
full_name: string | null;
avatar_url: string | null;
} | null;
}[];
};
}
let { data }: Props = $props();
const stats = $derived(
data.stats ?? {
memberCount: 0,
documentCount: 0,
folderCount: 0,
kanbanCount: 0,
},
);
const recentActivity = $derived(data.recentActivity ?? []);
const members = $derived(data.members ?? []);
const isAdmin = $derived(
data.userRole === "owner" || data.userRole === "admin",
);
const statCards = $derived([
{
label: m.overview_stat_members(),
value: stats.memberCount,
icon: "group",
href: isAdmin ? `/${data.org.slug}/settings` : null,
color: "text-blue-400",
bg: "bg-blue-400/10",
},
{
label: m.overview_stat_documents(),
value: stats.documentCount,
icon: "description",
href: `/${data.org.slug}/documents`,
color: "text-emerald-400",
bg: "bg-emerald-400/10",
},
{
label: m.overview_stat_folders(),
value: stats.folderCount,
icon: "folder",
href: `/${data.org.slug}/documents`,
color: "text-amber-400",
bg: "bg-amber-400/10",
},
{
label: m.overview_stat_boards(),
value: stats.kanbanCount,
icon: "view_kanban",
href: `/${data.org.slug}/documents`,
color: "text-purple-400",
bg: "bg-purple-400/10",
},
]);
const quickLinks = $derived([
{
label: m.nav_files(),
icon: "cloud",
href: `/${data.org.slug}/documents`,
},
{
label: m.nav_calendar(),
icon: "calendar_today",
href: `/${data.org.slug}/calendar`,
},
...(isAdmin
? [
{
label: m.nav_settings(),
icon: "settings",
href: `/${data.org.slug}/settings`,
},
]
: []),
]);
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,
};
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 getActivityDescription(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>
<svelte:head>
<title>{data.org.name} | Root</title>
</svelte:head>
<div class="p-4 lg:p-6">
<div class="flex flex-col h-full p-4 lg:p-5 gap-6 overflow-auto">
<!-- Header -->
<header>
<h1 class="text-h1 font-heading text-white">{data.org.name}</h1>
<p class="text-body text-light/60 font-body">Organization Overview</p>
<p class="text-body text-light/60 font-body">{m.overview_title()}</p>
</header>
<!-- Stats Grid -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
{#each statCards as stat}
{#if stat.href}
<a
href={stat.href}
class="bg-night rounded-2xl p-5 flex flex-col gap-3 hover:bg-night/80 transition-colors group"
>
<div
class="w-10 h-10 rounded-xl {stat.bg} flex items-center justify-center"
>
<span
class="material-symbols-rounded {stat.color}"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
{stat.icon}
</span>
</div>
<div>
<p class="text-2xl font-bold text-white">
{stat.value}
</p>
<p class="text-body-sm text-light/50">{stat.label}</p>
</div>
</a>
{:else}
<div class="bg-night rounded-2xl p-5 flex flex-col gap-3">
<div
class="w-10 h-10 rounded-xl {stat.bg} flex items-center justify-center"
>
<span
class="material-symbols-rounded {stat.color}"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
{stat.icon}
</span>
</div>
<div>
<p class="text-2xl font-bold text-white">
{stat.value}
</p>
<p class="text-body-sm text-light/50">{stat.label}</p>
</div>
</div>
{/if}
{/each}
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 flex-1 min-h-0">
<!-- Recent Activity -->
<div
class="lg:col-span-2 bg-night rounded-2xl p-5 flex flex-col gap-4 min-h-0"
>
<h2 class="text-h3 font-heading text-white">
{m.activity_title()}
</h2>
{#if recentActivity.length === 0}
<div
class="flex-1 flex flex-col items-center justify-center text-light/40 py-12"
>
<span
class="material-symbols-rounded mb-3"
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48;"
>
history
</span>
<p class="text-body">{m.activity_empty()}</p>
</div>
{:else}
<div class="flex flex-col gap-1 overflow-auto flex-1">
{#each recentActivity as entry}
<div
class="flex items-start gap-3 px-3 py-2.5 rounded-xl hover:bg-dark/50 transition-colors"
>
<span
class="material-symbols-rounded {getActivityColor(
entry.action,
)} mt-0.5 shrink-0"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>
{getActivityIcon(entry.action)}
</span>
<div class="flex-1 min-w-0">
<p
class="text-body-sm text-light leading-relaxed"
>
{getActivityDescription(entry)}
</p>
<p class="text-[11px] text-light/40 mt-0.5">
{formatTimeAgo(entry.created_at)}
</p>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Sidebar: Quick Links + Members -->
<div class="flex flex-col gap-6">
<!-- Quick Links -->
<div class="bg-night rounded-2xl p-5 flex flex-col gap-3">
<h2 class="text-h3 font-heading text-white">
{m.overview_quick_links()}
</h2>
<div class="flex flex-col gap-1">
{#each quickLinks as link}
<a
href={link.href}
class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-light hover:bg-dark/50 hover:text-white transition-colors"
>
<span
class="material-symbols-rounded text-light/50"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>
{link.icon}
</span>
<span class="text-body">{link.label}</span>
</a>
{/each}
</div>
</div>
<!-- Team Members Preview -->
<div class="bg-night rounded-2xl p-5 flex flex-col gap-3">
<div class="flex items-center justify-between">
<h2 class="text-h3 font-heading text-white">
{m.overview_stat_members()}
</h2>
<span class="text-body-sm text-light/40"
>{stats.memberCount}</span
>
</div>
<div class="flex flex-col gap-2">
{#each members.slice(0, 5) as member}
<div class="flex items-center gap-3 px-1 py-1">
<Avatar
name={member.profiles?.full_name ||
member.profiles?.email ||
"?"}
src={member.profiles?.avatar_url}
size="sm"
/>
<div class="flex-1 min-w-0">
<p class="text-body-sm text-white truncate">
{member.profiles?.full_name ||
member.profiles?.email ||
"Unknown"}
</p>
<p class="text-[11px] text-light/40 capitalize">
{member.role}
</p>
</div>
</div>
{/each}
{#if stats.memberCount > 5}
<a
href="/{data.org.slug}/settings"
class="text-body-sm text-primary hover:underline text-center pt-1"
>
+{stats.memberCount - 5} more
</a>
{/if}
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,29 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import type { OrgLayoutData } from '$lib/types/layout';
export const load: PageServerLoad = async ({ parent, locals }) => {
const { user } = await parent() as OrgLayoutData;
const [profileResult, prefsResult] = await Promise.all([
locals.supabase
.from('profiles')
.select('*')
.eq('id', user.id)
.single(),
locals.supabase
.from('user_preferences')
.select('*')
.eq('user_id', user.id)
.single()
]);
if (profileResult.error || !profileResult.data) {
error(500, 'Failed to load profile');
}
return {
profile: profileResult.data,
preferences: prefsResult.data
};
};

View File

@@ -0,0 +1,485 @@
<script lang="ts">
import { getContext } from "svelte";
import { invalidateAll } from "$app/navigation";
import { Button, Input, Avatar, Select } 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";
import * as m from "$lib/paraglide/messages";
import { getLocale, setLocale, locales } from "$lib/paraglide/runtime.js";
interface Props {
data: {
org: { id: string; slug: string };
profile: {
id: string;
email: string;
full_name: string | null;
avatar_url: string | null;
};
preferences: {
id: string;
theme: string | null;
accent_color: string | null;
sidebar_collapsed: boolean | null;
use_org_theme: boolean | null;
} | null;
};
}
let { data }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
// Profile state
let fullName = $state(data.profile.full_name ?? "");
let avatarUrl = $state(data.profile.avatar_url ?? null);
let isSaving = $state(false);
let isUploading = $state(false);
let avatarInput = $state<HTMLInputElement | null>(null);
// Preferences state
let theme = $state(data.preferences?.theme ?? "dark");
let accentColor = $state(data.preferences?.accent_color ?? "#00A3E0");
let useOrgTheme = $state(data.preferences?.use_org_theme ?? true);
let currentLocale = $state<(typeof locales)[number]>(getLocale());
const localeLabels: Record<string, string> = {
en: "English",
et: "Eesti",
};
function handleLanguageChange(newLocale: (typeof locales)[number]) {
currentLocale = newLocale;
setLocale(newLocale);
}
$effect(() => {
fullName = data.profile.full_name ?? "";
avatarUrl = data.profile.avatar_url ?? null;
theme = data.preferences?.theme ?? "dark";
accentColor = data.preferences?.accent_color ?? "#00A3E0";
useOrgTheme = data.preferences?.use_org_theme ?? true;
});
// Try to extract Google avatar from auth metadata
async function syncGoogleAvatar() {
const {
data: { user },
} = await supabase.auth.getUser();
const googleAvatar =
user?.user_metadata?.avatar_url || user?.user_metadata?.picture;
if (!googleAvatar) {
toasts.error("No Google avatar found.");
return;
}
const { error } = await supabase
.from("profiles")
.update({ avatar_url: googleAvatar })
.eq("id", data.profile.id);
if (error) {
toasts.error("Failed to sync avatar.");
return;
}
avatarUrl = googleAvatar;
await invalidateAll();
toasts.success("Google avatar synced.");
}
async function handleAvatarUpload(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
toasts.error("Please select an image file.");
return;
}
if (file.size > 2 * 1024 * 1024) {
toasts.error("Image must be under 2MB.");
return;
}
isUploading = true;
try {
const ext = file.name.split(".").pop() || "png";
const path = `user-avatars/${data.profile.id}.${ext}`;
const { error: uploadError } = await supabase.storage
.from("avatars")
.upload(path, file, { upsert: true });
if (uploadError) {
toasts.error("Failed to upload avatar.");
return;
}
const { data: urlData } = supabase.storage
.from("avatars")
.getPublicUrl(path);
const publicUrl = `${urlData.publicUrl}?t=${Date.now()}`;
const { error: dbError } = await supabase
.from("profiles")
.update({ avatar_url: publicUrl })
.eq("id", data.profile.id);
if (dbError) {
toasts.error("Failed to save avatar.");
return;
}
avatarUrl = publicUrl;
await invalidateAll();
toasts.success("Avatar updated.");
} catch {
toasts.error("Avatar upload failed.");
} finally {
isUploading = false;
input.value = "";
}
}
async function removeAvatar() {
const { error } = await supabase
.from("profiles")
.update({ avatar_url: null })
.eq("id", data.profile.id);
if (error) {
toasts.error("Failed to remove avatar.");
return;
}
avatarUrl = null;
await invalidateAll();
toasts.success("Avatar removed.");
}
async function saveProfile() {
isSaving = true;
const { error } = await supabase
.from("profiles")
.update({ full_name: fullName || null })
.eq("id", data.profile.id);
if (error) {
toasts.error("Failed to save profile.");
} else {
await invalidateAll();
toasts.success("Profile saved.");
}
isSaving = false;
}
async function savePreferences() {
isSaving = true;
const prefs = {
theme,
accent_color: accentColor,
use_org_theme: useOrgTheme,
user_id: data.profile.id,
};
if (data.preferences) {
const { error } = await supabase
.from("user_preferences")
.update(prefs)
.eq("id", data.preferences.id);
if (error) {
toasts.error("Failed to save preferences.");
isSaving = false;
return;
}
} else {
const { error } = await supabase
.from("user_preferences")
.insert(prefs);
if (error) {
toasts.error("Failed to save preferences.");
isSaving = false;
return;
}
}
await invalidateAll();
toasts.success("Preferences saved.");
isSaving = false;
}
const accentColors = [
{ value: "#00A3E0", label: "Blue (Default)" },
{ value: "#33E000", label: "Green" },
{ value: "#E03D00", label: "Red" },
{ value: "#FFAB00", label: "Amber" },
{ value: "#A855F7", label: "Purple" },
{ value: "#EC4899", label: "Pink" },
{ value: "#6366F1", label: "Indigo" },
{ value: "#14B8A6", label: "Teal" },
];
</script>
<svelte:head>
<title>Account Settings | Root</title>
</svelte:head>
<div class="flex flex-col h-full p-4 lg:p-5 gap-4">
<!-- Header -->
<div>
<h1 class="font-heading text-h1 text-white">{m.account_title()}</h1>
<p class="font-body text-body text-light/60 mt-1">
{m.account_subtitle()}
</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 flex-1 min-h-0">
<!-- Profile Section -->
<div class="bg-background rounded-[32px] p-6 flex flex-col gap-6">
<h2 class="font-heading text-h3 text-white">
{m.account_profile()}
</h2>
<!-- Avatar -->
<div class="flex flex-col gap-3">
<span class="font-body text-body-sm text-light"
>{m.account_photo()}</span
>
<div class="flex items-center gap-4">
<Avatar
name={fullName || data.profile.email}
src={avatarUrl}
size="xl"
/>
<div class="flex flex-col gap-2">
<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}
>
{m.btn_upload()}
</Button>
<Button
variant="tertiary"
size="sm"
onclick={syncGoogleAvatar}
>
{m.account_sync_google()}
</Button>
</div>
{#if avatarUrl}
<Button
variant="tertiary"
size="sm"
onclick={removeAvatar}
>
{m.account_remove_photo()}
</Button>
{/if}
</div>
</div>
</div>
<!-- Name -->
<Input
label={m.account_display_name()}
bind:value={fullName}
placeholder={m.account_display_name_placeholder()}
/>
<!-- Email (read-only) -->
<Input
label={m.account_email()}
value={data.profile.email}
disabled
/>
<div>
<Button onclick={saveProfile} loading={isSaving}>
{m.account_save_profile()}
</Button>
</div>
</div>
<!-- Appearance Section -->
<div class="bg-background rounded-[32px] p-6 flex flex-col gap-6">
<h2 class="font-heading text-h3 text-white">
{m.account_appearance()}
</h2>
<!-- Theme -->
<Select
label={m.account_theme()}
bind:value={theme}
placeholder=""
options={[
{ value: "dark", label: m.account_theme_dark() },
{ value: "light", label: m.account_theme_light() },
{ value: "system", label: m.account_theme_system() },
]}
/>
<!-- Accent Color -->
<div class="flex flex-col gap-2">
<span class="font-body text-body-sm text-light"
>{m.account_accent_color()}</span
>
<div class="flex flex-wrap gap-2 items-center">
{#each accentColors as color}
<button
type="button"
class="w-8 h-8 rounded-full border-2 transition-all {accentColor ===
color.value
? 'border-white scale-110'
: 'border-transparent hover:scale-105'}"
style="background-color: {color.value}"
title={color.label}
onclick={() => (accentColor = color.value)}
></button>
{/each}
<label
class="w-8 h-8 rounded-full border-2 border-dashed border-light/30 hover:border-light/60 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={accentColor}
/>
<span
class="material-symbols-rounded text-light/40"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>
colorize
</span>
</label>
</div>
</div>
<!-- Use Org Theme -->
<div class="flex items-center justify-between">
<div>
<p class="font-body text-body text-white">
{m.account_use_org_theme()}
</p>
<p class="font-body text-[12px] text-light/50">
{m.account_use_org_theme_desc()}
</p>
</div>
<button
type="button"
class="w-11 h-6 rounded-full transition-colors {useOrgTheme
? 'bg-primary'
: 'bg-light/20'}"
onclick={() => (useOrgTheme = !useOrgTheme)}
aria-label="Toggle organization theme"
>
<div
class="w-5 h-5 bg-white rounded-full shadow transition-transform {useOrgTheme
? 'translate-x-[22px]'
: 'translate-x-[2px]'}"
></div>
</button>
</div>
<!-- Language -->
<div class="flex flex-col gap-2">
<span class="font-body text-body-sm text-light"
>{m.account_language()}</span
>
<p class="font-body text-[12px] text-light/50">
{m.account_language_desc()}
</p>
<div class="flex gap-2 mt-1">
{#each locales as locale}
<button
type="button"
class="px-4 py-2 rounded-full text-sm font-medium transition-colors {currentLocale ===
locale
? 'bg-primary text-night'
: 'bg-light/10 text-light/70 hover:bg-light/20'}"
onclick={() => handleLanguageChange(locale)}
>
{localeLabels[locale] ?? locale}
</button>
{/each}
</div>
</div>
<div>
<Button onclick={savePreferences} loading={isSaving}>
{m.account_save_preferences()}
</Button>
</div>
</div>
<!-- Security & Sessions Section -->
<div class="bg-background rounded-[32px] p-6 flex flex-col gap-6">
<h2 class="font-heading text-h3 text-white">
{m.account_security()}
</h2>
<div class="flex flex-col gap-2">
<p class="font-body text-body text-white">
{m.account_password()}
</p>
<p class="font-body text-body-sm text-light/50">
{m.account_password_desc()}
</p>
<div class="mt-2">
<Button
variant="secondary"
size="sm"
onclick={async () => {
const { error } =
await supabase.auth.resetPasswordForEmail(
data.profile.email,
{
redirectTo: `${window.location.origin}/${data.org.slug}/account`,
},
);
if (error)
toasts.error("Failed to send reset email.");
else toasts.success("Password reset email sent.");
}}
>
{m.account_send_reset()}
</Button>
</div>
</div>
<div class="border-t border-light/10 pt-4 flex flex-col gap-2">
<p class="font-body text-body text-white">
{m.account_active_sessions()}
</p>
<p class="font-body text-body-sm text-light/50">
{m.account_sessions_desc()}
</p>
<div class="mt-2">
<Button
variant="danger"
size="sm"
onclick={async () => {
await supabase.auth.signOut({ scope: "others" });
toasts.success("Other sessions signed out.");
}}
>
{m.account_signout_others()}
</Button>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,10 +1,11 @@
import type { PageServerLoad } from './$types';
import type { OrgLayoutData } from '$lib/types/layout';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('page.calendar');
export const load: PageServerLoad = async ({ parent, locals }) => {
const { org, userRole } = await parent();
const { org, userRole } = await parent() as OrgLayoutData;
const { supabase } = locals;
// Fetch events for current month ± 1 month

View File

@@ -1,6 +1,14 @@
<script lang="ts">
import { getContext, onMount } from "svelte";
import { Button, Modal, Avatar } from "$lib/components/ui";
import { getContext, onMount, onDestroy } from "svelte";
import { createLogger } from "$lib/utils/logger";
import {
Button,
Modal,
Avatar,
ContextMenu,
Input,
Textarea,
} from "$lib/components/ui";
import { Calendar } from "$lib/components/calendar";
import {
getCalendarSubscribeUrl,
@@ -9,6 +17,7 @@
import type { CalendarEvent } from "$lib/supabase/types";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
import * as m from "$lib/paraglide/messages";
interface Props {
data: {
@@ -22,6 +31,7 @@
let { data }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
const log = createLogger("page.calendar");
let events = $state(data.events);
$effect(() => {
@@ -32,17 +42,86 @@
let isLoadingGoogle = $state(false);
let orgCalendarId = $state<string | null>(null);
let orgCalendarName = $state<string | null>(null);
// Track Google event IDs that are pending deletion to prevent ghost re-appearance
let deletedGoogleEventIds = $state<Set<string>>(new Set());
const isAdmin = $derived(
data.userRole === "owner" || data.userRole === "admin",
);
const allEvents = $derived([...events, ...googleEvents]);
// Deduplicate: exclude Google Calendar events that already exist locally (synced events)
// Also exclude events that are pending deletion
const allEvents = $derived.by(() => {
const localGoogleIds = new Set(
events
.filter((e) => e.google_event_id)
.map((e) => e.google_event_id),
);
const filteredGoogle = googleEvents.filter((ge) => {
const rawId = ge.id.replace("google-", "");
if (localGoogleIds.has(rawId)) return false;
if (deletedGoogleEventIds.has(rawId)) return false;
return true;
});
return [...events, ...filteredGoogle];
});
let showEventModal = $state(false);
let showEventFormModal = $state(false);
let eventFormMode = $state<"create" | "edit">("create");
let isDeleting = $state(false);
let isSavingEvent = $state(false);
let selectedEvent = $state<CalendarEvent | null>(null);
function handleDateClick(_date: Date) {
// Event creation disabled
// Event form state
let eventTitle = $state("");
let eventDescription = $state("");
let eventDate = $state("");
let eventStartTime = $state("09:00");
let eventEndTime = $state("10:00");
let eventAllDay = $state(false);
let eventColor = $state("#7986cb");
let syncToGoogleCal = $state(true);
// Google Calendar official event colors (colorId → hex)
const GCAL_COLORS: Record<string, string> = {
"1": "#7986cb", // Lavender
"2": "#33b679", // Sage
"3": "#8e24aa", // Grape
"4": "#e67c73", // Flamingo
"5": "#f6bf26", // Banana
"6": "#f4511e", // Tangerine
"7": "#039be5", // Peacock
"8": "#616161", // Graphite
"9": "#3f51b5", // Blueberry
"10": "#0b8043", // Basil
"11": "#d50000", // Tomato
};
// Reverse map: hex → colorId (for pushing to Google)
const HEX_TO_COLOR_ID: Record<string, string> = Object.fromEntries(
Object.entries(GCAL_COLORS).map(([id, hex]) => [hex, id]),
);
const EVENT_COLORS = Object.values(GCAL_COLORS);
function toLocalDateString(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
function handleDateClick(date: Date) {
eventFormMode = "create";
eventTitle = "";
eventDescription = "";
eventDate = toLocalDateString(date);
eventStartTime = "09:00";
eventEndTime = "10:00";
eventAllDay = false;
eventColor = "#7986cb";
syncToGoogleCal = isOrgCalendarConnected;
showEventFormModal = true;
}
function handleEventClick(event: CalendarEvent) {
@@ -50,11 +129,207 @@
showEventModal = true;
}
function openEditEvent() {
if (!selectedEvent || selectedEvent.id.startsWith("google-")) return;
eventFormMode = "edit";
eventTitle = selectedEvent.title;
eventDescription = selectedEvent.description ?? "";
const start = new Date(selectedEvent.start_time);
const end = new Date(selectedEvent.end_time);
eventDate = toLocalDateString(start);
eventStartTime = start.toTimeString().slice(0, 5);
eventEndTime = end.toTimeString().slice(0, 5);
eventAllDay = selectedEvent.all_day ?? false;
eventColor = selectedEvent.color ?? "#7986cb";
showEventModal = false;
showEventFormModal = true;
}
/**
* Push event to Google Calendar in the background.
* Does not block the UI — updates google_event_id on success.
*/
async function syncToGoogle(
action: "create" | "update" | "delete",
eventData: {
id?: string;
google_event_id?: string | null;
title?: string;
description?: string | null;
start_time?: string;
end_time?: string;
all_day?: boolean;
color?: string;
},
) {
const colorId = eventData.color
? (HEX_TO_COLOR_ID[eventData.color] ?? undefined)
: undefined;
try {
if (action === "create") {
const res = await fetch("/api/google-calendar/push", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
org_id: data.org.id,
title: eventData.title,
description: eventData.description,
start_time: eventData.start_time,
end_time: eventData.end_time,
all_day: eventData.all_day,
color_id: colorId,
}),
});
if (res.ok) {
const { google_event_id } = await res.json();
if (google_event_id && eventData.id) {
// Store the Google event ID back to Supabase
await supabase
.from("calendar_events")
.update({
google_event_id,
synced_at: new Date().toISOString(),
})
.eq("id", eventData.id);
// Update local state
events = events.map((e) =>
e.id === eventData.id
? {
...e,
google_event_id,
synced_at: new Date().toISOString(),
}
: e,
);
}
}
} else if (action === "update" && eventData.google_event_id) {
await fetch("/api/google-calendar/push", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
org_id: data.org.id,
google_event_id: eventData.google_event_id,
title: eventData.title,
description: eventData.description,
start_time: eventData.start_time,
end_time: eventData.end_time,
all_day: eventData.all_day,
color_id: colorId,
}),
});
} else if (action === "delete" && eventData.google_event_id) {
await fetch("/api/google-calendar/push", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
org_id: data.org.id,
google_event_id: eventData.google_event_id,
}),
});
}
} catch (e) {
log.error("Google Calendar sync failed", {
error: e,
data: { action },
});
}
}
async function handleSaveEvent() {
if (!eventTitle.trim() || !eventDate) return;
isSavingEvent = true;
const startTime = eventAllDay
? `${eventDate}T00:00:00`
: `${eventDate}T${eventStartTime}:00`;
const endTime = eventAllDay
? `${eventDate}T23:59:59`
: `${eventDate}T${eventEndTime}:00`;
if (eventFormMode === "edit" && selectedEvent) {
const { error } = await supabase
.from("calendar_events")
.update({
title: eventTitle.trim(),
description: eventDescription.trim() || null,
start_time: startTime,
end_time: endTime,
all_day: eventAllDay,
color: eventColor,
})
.eq("id", selectedEvent.id);
if (!error) {
events = events.map((e) =>
e.id === selectedEvent!.id
? {
...e,
title: eventTitle.trim(),
description: eventDescription.trim() || null,
start_time: startTime,
end_time: endTime,
all_day: eventAllDay,
color: eventColor,
}
: e,
);
// Push update to Google Calendar in background
syncToGoogle("update", {
google_event_id: selectedEvent.google_event_id,
title: eventTitle.trim(),
description: eventDescription.trim() || null,
start_time: startTime,
end_time: endTime,
all_day: eventAllDay,
color: eventColor,
});
}
} else {
const { data: newEvent, error } = await supabase
.from("calendar_events")
.insert({
org_id: data.org.id,
title: eventTitle.trim(),
description: eventDescription.trim() || null,
start_time: startTime,
end_time: endTime,
all_day: eventAllDay,
color: eventColor,
created_by: data.user?.id,
})
.select()
.single();
if (!error && newEvent) {
events = [...events, newEvent as CalendarEvent];
// Push new event to Google Calendar if sync is enabled
if (syncToGoogleCal && isOrgCalendarConnected) {
syncToGoogle("create", {
id: newEvent.id,
title: eventTitle.trim(),
description: eventDescription.trim() || null,
start_time: startTime,
end_time: endTime,
all_day: eventAllDay,
color: eventColor,
});
}
}
}
showEventFormModal = false;
selectedEvent = null;
isSavingEvent = false;
}
async function handleDeleteEvent() {
if (!selectedEvent || selectedEvent.id.startsWith("google-")) return;
isDeleting = true;
try {
const googleEventId = selectedEvent.google_event_id;
const { error } = await supabase
.from("calendar_events")
.delete()
@@ -62,11 +337,25 @@
if (!error) {
events = events.filter((e) => e.id !== selectedEvent?.id);
if (googleEventId) {
// Immediately exclude from Google events display
deletedGoogleEventIds = new Set([
...deletedGoogleEventIds,
googleEventId,
]);
googleEvents = googleEvents.filter(
(ge) => ge.id !== `google-${googleEventId}`,
);
// Await Google delete so it completes before any refresh
await syncToGoogle("delete", {
google_event_id: googleEventId,
});
}
showEventModal = false;
selectedEvent = null;
}
} catch (e) {
console.error("Failed to delete event:", e);
log.error("Failed to delete event", { error: e });
}
isDeleting = false;
}
@@ -78,8 +367,33 @@
return `${start.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} - ${end.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
}
let pollInterval: ReturnType<typeof setInterval> | null = null;
function handleWindowFocus() {
if (isOrgCalendarConnected) {
loadGoogleCalendarEvents();
}
}
onMount(async () => {
await loadGoogleCalendarEvents();
// Re-fetch when user returns to the tab (e.g. after editing in Google Calendar)
window.addEventListener("focus", handleWindowFocus);
// Poll every 60s for changes made in Google Calendar
pollInterval = setInterval(() => {
if (isOrgCalendarConnected && !document.hidden) {
loadGoogleCalendarEvents();
}
}, 60_000);
});
onDestroy(() => {
if (typeof window !== "undefined") {
window.removeEventListener("focus", handleWindowFocus);
}
if (pollInterval) clearInterval(pollInterval);
});
async function loadGoogleCalendarEvents() {
@@ -96,31 +410,37 @@
orgCalendarId = result.calendar_id;
orgCalendarName = result.calendar_name;
if (result.events && result.events.length > 0) {
googleEvents = result.events.map(
(ge: GoogleCalendarEvent) => ({
const fetchedEvents = result.events ?? [];
googleEvents = fetchedEvents.map((ge: GoogleCalendarEvent) => ({
id: `google-${ge.id}`,
org_id: data.org.id,
title: ge.summary || "(No title)",
description: ge.description ?? null,
start_time:
ge.start.dateTime ||
`${ge.start.date}T00:00:00`,
end_time:
ge.end.dateTime || `${ge.end.date}T23:59:59`,
ge.start.dateTime || `${ge.start.date}T00:00:00`,
end_time: ge.end.dateTime || `${ge.end.date}T23:59:59`,
all_day: !ge.start.dateTime,
color: "#4285f4",
color: ge.colorId
? (GCAL_COLORS[ge.colorId] ?? "#7986cb")
: "#7986cb",
recurrence: null,
created_by: data.user?.id ?? "",
created_at: new Date().toISOString(),
}),
) as CalendarEvent[];
}
})) as CalendarEvent[];
// Clear deleted IDs that Google has confirmed are gone
const fetchedIds = new Set(
fetchedEvents.map((ge: GoogleCalendarEvent) => ge.id),
);
deletedGoogleEventIds = new Set(
[...deletedGoogleEventIds].filter((id) =>
fetchedIds.has(id),
),
);
} else if (result.error) {
console.error("Calendar API error:", result.error);
log.error("Calendar API error", { error: result.error });
}
} catch (e) {
console.error("Failed to load Google events:", e);
log.error("Failed to load Google events", { error: e });
}
isLoadingGoogle = false;
}
@@ -139,36 +459,49 @@
<div class="flex flex-col h-full p-4 lg:p-5 gap-4">
<!-- Header -->
<header class="flex items-center gap-2 p-1">
<Avatar name="Calendar" size="md" />
<h1 class="flex-1 font-heading text-h1 text-white">Calendar</h1>
{#if isOrgCalendarConnected}
<button
type="button"
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-primary/10 text-primary rounded-[32px] hover:bg-primary/20 transition-colors"
onclick={subscribeToCalendar}
title="Add to your Google Calendar"
<h1 class="flex-1 font-heading text-h1 text-white">
{m.calendar_title()}
</h1>
<Button size="md" onclick={() => handleDateClick(new Date())}
>{m.btn_new()}</Button
>
<span
class="material-symbols-rounded"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>
add
</span>
Subscribe
</button>
{/if}
<button
type="button"
class="p-1 hover:bg-dark rounded-lg transition-colors"
aria-label="More options"
>
<span
class="material-symbols-rounded text-light"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
more_horiz
</span>
</button>
<ContextMenu
items={[
...(isOrgCalendarConnected
? [
{
label: m.calendar_subscribe(),
icon: "add",
onclick: subscribeToCalendar,
},
]
: []),
{
label: m.calendar_refresh(),
icon: "refresh",
onclick: () => {
loadGoogleCalendarEvents();
},
},
...(isAdmin
? [
{
label: "",
icon: "",
onclick: () => {},
divider: true,
},
{
label: m.calendar_settings(),
icon: "settings",
onclick: () => {
window.location.href = `/${data.org.slug}/settings?tab=integrations`;
},
},
]
: []),
]}
/>
</header>
<!-- Calendar Grid -->
@@ -270,18 +603,22 @@
<span class="text-light/60 text-sm">
{selectedEvent.id.startsWith("google-")
? "Google Calendar Event"
: selectedEvent.google_event_id
? "Synced to Google Calendar"
: "Local Event"}
</span>
</div>
<!-- Google Calendar link -->
{#if selectedEvent.id.startsWith("google-") && orgCalendarId}
{#if (selectedEvent.id.startsWith("google-") ? selectedEvent.id.replace("google-", "") : selectedEvent.google_event_id) && orgCalendarId}
{@const googleId = selectedEvent.id.startsWith("google-")
? selectedEvent.id.replace("google-", "")
: selectedEvent.google_event_id}
<div class="pt-3 border-t border-light/10">
<a
href="https://calendar.google.com/calendar/u/0/r/eventedit/{selectedEvent.id.replace(
'google-',
'',
)}?cid={encodeURIComponent(orgCalendarId)}"
href="https://calendar.google.com/calendar/u/0/r/eventedit/{googleId}?cid={encodeURIComponent(
orgCalendarId,
)}"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 text-sm bg-blue-500/20 text-blue-400 rounded-lg hover:bg-blue-500/30 transition-colors"
@@ -306,15 +643,14 @@
</svg>
Open in Google Calendar
</a>
<p class="text-xs text-light/40 mt-2">
Edit this event directly in Google Calendar
</p>
</div>
{/if}
<!-- Delete local event -->
<!-- Edit/Delete local event -->
{#if !selectedEvent.id.startsWith("google-")}
<div class="pt-3 border-t border-light/10">
<div
class="pt-3 border-t border-light/10 flex items-center justify-between"
>
<Button
variant="danger"
onclick={handleDeleteEvent}
@@ -322,8 +658,137 @@
>
Delete Event
</Button>
<Button onclick={openEditEvent}>Edit Event</Button>
</div>
{/if}
</div>
{/if}
</Modal>
<!-- Event Create/Edit Form Modal -->
<Modal
isOpen={showEventFormModal}
onClose={() => (showEventFormModal = false)}
title={eventFormMode === "edit"
? m.calendar_edit_event()
: m.calendar_create_event()}
>
<div class="space-y-4">
<Input
label={m.calendar_event_title()}
bind:value={eventTitle}
placeholder={m.calendar_event_title_placeholder()}
/>
<Input
type="date"
label={m.calendar_event_date()}
bind:value={eventDate}
/>
<label
class="flex items-center gap-2 text-sm text-light cursor-pointer"
>
<input type="checkbox" bind:checked={eventAllDay} class="rounded" />
All day event
</label>
{#if !eventAllDay}
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1">
<span class="px-3 font-bold font-body text-body text-white"
>Start</span
>
<input
type="time"
class="w-full p-3 bg-background text-white rounded-[32px] font-medium font-input text-body focus:outline-none focus:ring-2 focus:ring-primary"
bind:value={eventStartTime}
/>
</div>
<div class="flex flex-col gap-1">
<span class="px-3 font-bold font-body text-body text-white"
>End</span
>
<input
type="time"
class="w-full p-3 bg-background text-white rounded-[32px] font-medium font-input text-body focus:outline-none focus:ring-2 focus:ring-primary"
bind:value={eventEndTime}
/>
</div>
</div>
{/if}
<Textarea
label={m.calendar_event_desc()}
bind:value={eventDescription}
placeholder="Add a description..."
rows={3}
/>
<div>
<span class="block text-sm font-medium text-light mb-2">Color</span>
<div class="flex gap-2">
{#each EVENT_COLORS as color}
<button
type="button"
class="w-7 h-7 rounded-full transition-transform {eventColor ===
color
? 'ring-2 ring-white scale-110'
: ''}"
style="background-color: {color}"
onclick={() => (eventColor = color)}
></button>
{/each}
</div>
</div>
{#if isOrgCalendarConnected && eventFormMode === "create"}
<label
class="flex items-center gap-3 text-sm text-light cursor-pointer p-3 rounded-lg bg-light/5 border border-light/10"
>
<input
type="checkbox"
bind:checked={syncToGoogleCal}
class="rounded"
/>
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-blue-400" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
<span>Sync to Google Calendar</span>
</div>
</label>
{/if}
<div class="flex justify-end gap-2 pt-2">
<Button
variant="tertiary"
onclick={() => (showEventFormModal = false)}
>{m.btn_cancel()}</Button
>
<Button
onclick={handleSaveEvent}
loading={isSavingEvent}
disabled={!eventTitle.trim() || !eventDate}
>{eventFormMode === "edit"
? m.btn_save()
: m.btn_create()}</Button
>
</div>
</div>
</Modal>

View File

@@ -1,15 +1,16 @@
import type { PageServerLoad } from './$types';
import type { OrgLayoutData } from '$lib/types/layout';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('page.documents');
export const load: PageServerLoad = async ({ parent, locals }) => {
const { org } = await parent();
const { org } = await parent() as OrgLayoutData;
const { supabase } = locals;
const { data: documents, error } = await supabase
.from('documents')
.select('*')
.select('id, name, type, parent_id, path, position, created_at, updated_at, created_by, org_id')
.eq('org_id', org.id)
.order('name');

View File

@@ -1,11 +1,12 @@
import type { PageServerLoad } from './$types';
import type { OrgLayoutData } from '$lib/types/layout';
import { error, redirect } from '@sveltejs/kit';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('page.document');
export const load: PageServerLoad = async ({ parent, locals, params }) => {
const { org } = await parent() as { org: { id: string; slug: string } };
const { org } = await parent() as OrgLayoutData;
const { supabase } = locals;
const { id } = params;

View File

@@ -1,11 +1,12 @@
import type { PageServerLoad } from './$types';
import type { OrgLayoutData } from '$lib/types/layout';
import { error, redirect } from '@sveltejs/kit';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('page.file');
export const load: PageServerLoad = async ({ parent, locals, params }) => {
const { org, user } = await parent() as { org: { id: string; slug: string }; user: { id: string } | null };
const { org, user } = await parent() as OrgLayoutData;
const { supabase } = locals;
const { id } = params;

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { getContext, onDestroy, onMount } from "svelte";
import { Button, Modal, Input } from "$lib/components/ui";
import { Button, Modal, Input, ContextMenu } from "$lib/components/ui";
import { DocumentViewer } from "$lib/components/documents";
import { KanbanBoard, CardDetailModal } from "$lib/components/kanban";
import {
@@ -197,9 +197,11 @@
$effect(() => {
if (!kanbanBoard) return;
const colIds = kanbanBoard.columns.map((c) => c.id);
const channel = subscribeToBoard(
supabase,
kanbanBoard.id,
colIds,
() => loadKanbanBoard(),
() => loadKanbanBoard(),
);
@@ -212,15 +214,34 @@
};
});
// Reliable lock release via sendBeacon (survives page unload)
function beaconReleaseLock() {
if (hasLock && data.user) {
navigator.sendBeacon(
"/api/release-lock",
JSON.stringify({ documentId: data.document.id }),
);
}
}
onMount(() => {
window.addEventListener("beforeunload", beaconReleaseLock);
return () =>
window.removeEventListener("beforeunload", beaconReleaseLock);
});
onDestroy(() => {
if (realtimeChannel) {
supabase.removeChannel(realtimeChannel);
}
// Release document lock
// Release document lock (SPA navigation — sendBeacon as fallback)
if (hasLock && data.user) {
stopHeartbeat?.();
releaseLock(supabase, data.document.id, data.user.id);
}
if (typeof window !== "undefined") {
window.removeEventListener("beforeunload", beaconReleaseLock);
}
});
async function handleCardMove(
@@ -441,23 +462,27 @@
<h1 class="flex-1 font-heading text-h1 text-white truncate">
{data.document.name}
</h1>
<Button
variant="tertiary"
size="sm"
icon="upload"
onclick={triggerImport}
loading={isImporting}
>
Import JSON
</Button>
<Button
variant="tertiary"
size="sm"
icon="download"
onclick={handleExportJson}
>
Export JSON
</Button>
<ContextMenu
items={[
{
label: "Import JSON",
icon: "upload",
onclick: triggerImport,
},
{
label: "Export JSON",
icon: "download",
onclick: handleExportJson,
},
{ divider: true, label: "", icon: "", onclick: () => {} },
{
label: "Rename Board",
icon: "edit",
onclick: () =>
toasts.info("Rename from the documents list."),
},
]}
/>
</header>
<div class="flex-1 overflow-auto min-h-0">

View File

@@ -1,11 +1,12 @@
import type { PageServerLoad } from './$types';
import type { OrgLayoutData } from '$lib/types/layout';
import { error } from '@sveltejs/kit';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('page.folder');
export const load: PageServerLoad = async ({ parent, locals, params }) => {
const { org, user } = await parent() as { org: { id: string; slug: string }; user: { id: string } | null };
const { org, user } = await parent() as OrgLayoutData;
const { supabase } = locals;
const { id } = params;
@@ -13,7 +14,7 @@ export const load: PageServerLoad = async ({ parent, locals, params }) => {
const { data: document, error: docError } = await supabase
.from('documents')
.select('*')
.select('id, name, type, parent_id, path, position, created_at, updated_at, created_by, org_id')
.eq('org_id', org.id)
.eq('id', id)
.single();
@@ -31,7 +32,7 @@ export const load: PageServerLoad = async ({ parent, locals, params }) => {
// Load all documents in this org (for breadcrumb building and file listing)
const { data: allDocuments } = await supabase
.from('documents')
.select('*')
.select('id, name, type, parent_id, path, position, created_at, updated_at, created_by, org_id')
.eq('org_id', org.id)
.order('name');

View File

@@ -1,10 +1,11 @@
import type { PageServerLoad } from './$types';
import type { OrgLayoutData } from '$lib/types/layout';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('page.kanban');
export const load: PageServerLoad = async ({ parent, locals }) => {
const { org } = await parent();
const { org } = await parent() as OrgLayoutData;
const { supabase } = locals;
const { data: boards, error } = await supabase

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { getContext, onDestroy } from "svelte";
import { getContext, onDestroy, untrack } from "svelte";
import {
Button,
Card,
@@ -8,6 +8,7 @@
Avatar,
IconButton,
Icon,
ContextMenu,
} from "$lib/components/ui";
import { KanbanBoard, CardDetailModal } from "$lib/components/kanban";
import {
@@ -16,26 +17,44 @@
moveCard,
subscribeToBoard,
} from "$lib/api/kanban";
import type { RealtimeChangePayload } from "$lib/api/kanban";
import type { RealtimeChannel } from "@supabase/supabase-js";
import type {
KanbanBoard as KanbanBoardType,
KanbanCard,
KanbanColumn,
} from "$lib/supabase/types";
import type { BoardWithColumns } from "$lib/api/kanban";
import type { BoardWithColumns, ColumnWithCards } from "$lib/api/kanban";
import { createLogger } from "$lib/utils/logger";
import * as m from "$lib/paraglide/messages";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
interface Member {
id: string;
user_id: string;
role: string;
profiles: {
id: string;
full_name: string | null;
email: string;
avatar_url: string | null;
};
}
interface Props {
data: {
org: { id: string; name: string; slug: string };
boards: KanbanBoardType[];
user: { id: string } | null;
members: Member[];
};
}
let { data }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
const log = createLogger("page.kanban");
let boards = $state(data.boards);
$effect(() => {
@@ -44,6 +63,8 @@
let selectedBoard = $state<BoardWithColumns | null>(null);
let showCreateBoardModal = $state(false);
let showEditBoardModal = $state(false);
let isRenamingBoard = $state(false);
let renameBoardValue = $state("");
let showCardDetailModal = $state(false);
let selectedCard = $state<KanbanCard | null>(null);
let newBoardName = $state("");
@@ -56,23 +77,125 @@
selectedBoard = await fetchBoardWithColumns(supabase, boardId);
}
// Incremental realtime handlers
function handleColumnRealtime(
payload: RealtimeChangePayload<KanbanColumn>,
) {
if (!selectedBoard) return;
const { event } = payload;
if (event === "INSERT") {
const col: ColumnWithCards = { ...payload.new, cards: [] };
selectedBoard = {
...selectedBoard,
columns: [...selectedBoard.columns, col].sort(
(a, b) => a.position - b.position,
),
};
} else if (event === "UPDATE") {
selectedBoard = {
...selectedBoard,
columns: selectedBoard.columns
.map((c) =>
c.id === payload.new.id ? { ...c, ...payload.new } : c,
)
.sort((a, b) => a.position - b.position),
};
} else if (event === "DELETE") {
const deletedId = payload.old.id;
if (deletedId) {
selectedBoard = {
...selectedBoard,
columns: selectedBoard.columns.filter(
(c) => c.id !== deletedId,
),
};
}
}
}
function handleCardRealtime(payload: RealtimeChangePayload<KanbanCard>) {
if (!selectedBoard) return;
const { event } = payload;
if (event === "INSERT") {
const card = payload.new;
if (!card.column_id) return;
selectedBoard = {
...selectedBoard,
columns: selectedBoard.columns.map((col) =>
col.id === card.column_id
? {
...col,
cards: [...col.cards, card].sort(
(a, b) => a.position - b.position,
),
}
: col,
),
};
} else if (event === "UPDATE") {
const card = payload.new;
selectedBoard = {
...selectedBoard,
columns: selectedBoard.columns.map((col) => {
if (col.id === card.column_id) {
// Target column: update existing or add new
const exists = col.cards.some((c) => c.id === card.id);
const updatedCards = exists
? col.cards.map((c) =>
c.id === card.id ? { ...c, ...card } : c,
)
: [...col.cards, card];
return {
...col,
cards: updatedCards.sort(
(a, b) => a.position - b.position,
),
};
}
// All other columns: remove the card if present (handles cross-column moves)
return {
...col,
cards: col.cards.filter((c) => c.id !== card.id),
};
}),
};
} else if (event === "DELETE") {
const deletedId = payload.old.id;
if (deletedId) {
selectedBoard = {
...selectedBoard,
columns: selectedBoard.columns.map((col) => ({
...col,
cards: col.cards.filter((c) => c.id !== deletedId),
})),
};
}
}
}
// Track board ID separately so the realtime subscription only re-runs
// when the board changes, not on every column/card update
let currentBoardId = $derived(selectedBoard?.id ?? null);
// Realtime subscription with proper cleanup
$effect(() => {
const board = selectedBoard;
if (!board) return;
const boardId = currentBoardId;
if (!boardId) return;
// Subscribe to realtime changes for this board
// Read column IDs without creating a reactive dependency on selectedBoard
// (the effect should only re-run when boardId changes)
const colIds = (untrack(() => selectedBoard)?.columns ?? []).map(
(c) => c.id,
);
const channel = subscribeToBoard(
supabase,
board.id,
() => {
// Column changed - reload board data
loadBoard(board.id);
},
() => {
// Card changed - reload board data
loadBoard(board.id);
},
boardId,
colIds,
handleColumnRealtime,
handleCardRealtime,
);
realtimeChannel = channel;
@@ -111,6 +234,48 @@
showEditBoardModal = true;
}
function startBoardRename() {
if (!selectedBoard) return;
renameBoardValue = selectedBoard.name;
isRenamingBoard = true;
}
async function confirmBoardRename() {
if (!selectedBoard || !renameBoardValue.trim()) {
isRenamingBoard = false;
return;
}
const newName = renameBoardValue.trim();
if (newName === selectedBoard.name) {
isRenamingBoard = false;
return;
}
const boardId = selectedBoard.id;
const { error } = await supabase
.from("kanban_boards")
.update({ name: newName })
.eq("id", boardId);
await supabase
.from("documents")
.update({ name: newName })
.eq("id", boardId);
if (!error) {
selectedBoard = { ...selectedBoard, name: newName };
boards = boards.map((b) =>
b.id === boardId ? { ...b, name: newName } : b,
);
}
isRenamingBoard = false;
}
function cancelBoardRename() {
isRenamingBoard = false;
renameBoardValue = "";
}
async function handleEditBoard() {
if (!editingBoardId || !editBoardName.trim()) return;
@@ -119,6 +284,12 @@
.update({ name: editBoardName })
.eq("id", editingBoardId);
// Also update the linked document entry
await supabase
.from("documents")
.update({ name: editBoardName })
.eq("id", editingBoardId);
if (!error) {
if (selectedBoard?.id === editingBoardId) {
selectedBoard = { ...selectedBoard, name: editBoardName };
@@ -131,8 +302,11 @@
editingBoardId = null;
}
async function handleDeleteBoard(e: MouseEvent, board: KanbanBoardType) {
e.stopPropagation();
async function handleDeleteBoard(
e: MouseEvent | null,
board: KanbanBoardType,
) {
e?.stopPropagation();
if (!confirm(`Delete "${board.name}" and all its cards?`)) return;
const { error } = await supabase
@@ -140,6 +314,9 @@
.delete()
.eq("id", board.id);
// Also delete the corresponding document entry
await supabase.from("documents").delete().eq("id", board.id);
if (!error) {
boards = boards.filter((b) => b.id !== board.id);
if (selectedBoard?.id === board.id) {
@@ -217,6 +394,24 @@
}
}
async function handleRenameColumn(columnId: string, newName: string) {
if (!selectedBoard) return;
const { error } = await supabase
.from("kanban_columns")
.update({ name: newName })
.eq("id", columnId);
if (!error) {
selectedBoard = {
...selectedBoard,
columns: selectedBoard.columns.map((c) =>
c.id === columnId ? { ...c, name: newName } : c,
),
};
}
}
async function handleCardMove(
cardId: string,
toColumnId: string,
@@ -244,7 +439,7 @@
// Persist to database in background
moveCard(supabase, cardId, toColumnId, toPosition).catch((err) => {
console.error("Failed to persist card move:", err);
log.error("Failed to persist card move", { error: err });
// Reload to sync state on error
loadBoard(selectedBoard!.id);
});
@@ -302,17 +497,48 @@
<div class="flex flex-col h-full p-4 lg:p-5 gap-4">
<!-- Header -->
<header class="flex items-center gap-2 p-1">
<Avatar name="Kanban" size="md" />
<h1 class="flex-1 font-heading text-h1 text-white">Kanban</h1>
{#if isRenamingBoard && selectedBoard}
<input
type="text"
class="flex-1 bg-dark border border-primary rounded-lg px-3 py-1 text-white font-heading text-h1 focus:outline-none"
bind:value={renameBoardValue}
onkeydown={(e) => {
if (e.key === "Enter") confirmBoardRename();
if (e.key === "Escape") cancelBoardRename();
}}
onblur={confirmBoardRename}
autofocus
/>
{:else}
<h1 class="flex-1 font-heading text-h1 text-white">
{selectedBoard ? selectedBoard.name : m.kanban_title()}
</h1>
{/if}
<Button size="md" onclick={() => (showCreateBoardModal = true)}
>+ New</Button
>{m.btn_new()}</Button
>
<IconButton
title="More options"
onclick={() => selectedBoard && openEditBoardModal(selectedBoard)}
>
<Icon name="more_horiz" size={24} />
</IconButton>
<ContextMenu
items={[
...(selectedBoard
? [
{
label: m.kanban_rename_board(),
icon: "edit",
onclick: () => startBoardRename(),
},
{
label: m.kanban_delete_board(),
icon: "delete",
onclick: () => {
if (selectedBoard)
handleDeleteBoard(null, selectedBoard);
},
danger: true,
},
]
: []),
]}
/>
</header>
<!-- Board selector (compact) -->
@@ -344,6 +570,7 @@
onAddColumn={handleAddColumn}
onDeleteCard={handleCardDelete}
onDeleteColumn={handleDeleteColumn}
onRenameColumn={handleRenameColumn}
/>
{:else if boards.length === 0}
<div class="h-full flex items-center justify-center text-light/40">
@@ -354,18 +581,18 @@
>
view_kanban
</span>
<p class="mb-4">Kanban boards are now managed in Files</p>
<p class="mb-4">{m.kanban_empty()}</p>
<Button
onclick={() =>
(window.location.href = `/${data.org.slug}/documents`)}
>
Go to Files
{m.kanban_go_to_files()}
</Button>
</div>
</div>
{:else}
<div class="h-full flex items-center justify-center text-light/40">
<p>Select a board above</p>
<p>{m.kanban_select_board()}</p>
</div>
{/if}
</div>
@@ -374,21 +601,22 @@
<Modal
isOpen={showCreateBoardModal}
onClose={() => (showCreateBoardModal = false)}
title="Create Board"
title={m.kanban_create_board()}
>
<div class="space-y-4">
<Input
label="Board Name"
label={m.kanban_board_name_label()}
bind:value={newBoardName}
placeholder="e.g. Sprint 1"
placeholder={m.kanban_board_name_placeholder()}
/>
<div class="flex justify-end gap-2">
<Button
variant="tertiary"
onclick={() => (showCreateBoardModal = false)}>Cancel</Button
onclick={() => (showCreateBoardModal = false)}
>{m.btn_cancel()}</Button
>
<Button onclick={handleCreateBoard} disabled={!newBoardName.trim()}
>Create</Button
>{m.btn_create()}</Button
>
</div>
</div>
@@ -397,45 +625,58 @@
<Modal
isOpen={showEditBoardModal}
onClose={() => (showEditBoardModal = false)}
title="Edit Board"
title={m.kanban_edit_board()}
>
<div class="space-y-4">
<Input
label="Board Name"
label={m.kanban_board_name_label()}
bind:value={editBoardName}
placeholder="Board name"
placeholder={m.kanban_board_name_placeholder()}
/>
<div class="flex justify-end gap-2">
<div class="flex items-center justify-between gap-2">
<Button
variant="danger"
onclick={() => {
showEditBoardModal = false;
const board = boards.find((b) => b.id === editingBoardId);
if (board) handleDeleteBoard(null, board);
}}>{m.kanban_delete_board()}</Button
>
<div class="flex gap-2">
<Button
variant="tertiary"
onclick={() => (showEditBoardModal = false)}>Cancel</Button
onclick={() => (showEditBoardModal = false)}
>{m.btn_cancel()}</Button
>
<Button onclick={handleEditBoard} disabled={!editBoardName.trim()}
>Save</Button
<Button
onclick={handleEditBoard}
disabled={!editBoardName.trim()}>{m.btn_save()}</Button
>
</div>
</div>
</div>
</Modal>
<Modal
isOpen={showAddColumnModal}
onClose={() => (showAddColumnModal = false)}
title="Add Column"
title={m.kanban_add_column()}
>
<div class="space-y-4">
<Input
label="Column Name"
label={m.kanban_column_name_label()}
bind:value={newColumnName}
placeholder="e.g. To Do, In Progress, Done"
placeholder={m.kanban_column_name_placeholder()}
/>
<div class="flex justify-end gap-2">
<Button
variant="tertiary"
onclick={() => (showAddColumnModal = false)}>Cancel</Button
onclick={() => (showAddColumnModal = false)}
>{m.btn_cancel()}</Button
>
<Button
onclick={handleCreateColumn}
disabled={!newColumnName.trim()}>Create</Button
disabled={!newColumnName.trim()}>{m.btn_create()}</Button
>
</div>
</div>
@@ -448,6 +689,8 @@
showCardDetailModal = false;
selectedCard = null;
targetColumnId = null;
// Reload board to reflect tag/checklist/assignee changes made in modal
if (selectedBoard) loadBoard(selectedBoard.id);
}}
onUpdate={handleCardUpdate}
onDelete={handleCardDelete}
@@ -456,4 +699,5 @@
userId={data.user?.id}
orgId={data.org.id}
onCreate={handleCardCreated}
members={data.members ?? []}
/>

View File

@@ -1,11 +1,14 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import type { OrgLayoutData } from '$lib/types/layout';
import { createLogger } from '$lib/utils/logger';
import { getServiceAccountEmail } from '$lib/api/google-calendar-push';
import { env } from '$env/dynamic/private';
const log = createLogger('page.settings');
export const load: PageServerLoad = async ({ parent, locals }) => {
const { org, userRole } = await parent() as { org: { id: string; slug: string }; userRole: string };
const { org, userRole } = await parent() as OrgLayoutData;
// Only admins and owners can access settings
if (userRole !== 'owner' && userRole !== 'admin') {
@@ -16,22 +19,10 @@ export const load: PageServerLoad = async ({ parent, locals }) => {
// Fetch all settings data in parallel
const [membersResult, rolesResult, invitesResult, calendarResult] = await Promise.all([
// Get org members with profiles
// Get org members (without embedded profile join — FK points to auth.users, not profiles)
locals.supabase
.from('org_members')
.select(`
id,
user_id,
role,
role_id,
created_at,
profiles:user_id (
id,
email,
full_name,
avatar_url
)
`)
.select('id, user_id, role, role_id, invited_at')
.eq('org_id', orgId),
// Get org roles
locals.supabase
@@ -54,11 +45,41 @@ export const load: PageServerLoad = async ({ parent, locals }) => {
.single()
]);
if (membersResult.error) {
log.error('Failed to fetch members', { error: membersResult.error, data: { orgId } });
}
// Fetch profiles separately since org_members.user_id FK points to auth.users, not profiles
const rawMembers = membersResult.data ?? [];
const userIds = rawMembers.map(m => m.user_id).filter((id): id is string => id !== null);
let profilesMap: Record<string, { id: string; email: string; full_name: string | null; avatar_url: string | null }> = {};
if (userIds.length > 0) {
const { data: profiles } = await locals.supabase
.from('profiles')
.select('id, email, full_name, avatar_url')
.in('id', userIds);
if (profiles) {
profilesMap = Object.fromEntries(profiles.map(p => [p.id, p]));
}
}
const members = rawMembers.map(m => ({
...m,
profiles: (m.user_id ? profilesMap[m.user_id] : null) ?? null
}));
const serviceAccountEmail = env.GOOGLE_SERVICE_ACCOUNT_KEY
? getServiceAccountEmail(env.GOOGLE_SERVICE_ACCOUNT_KEY)
: null;
return {
members: membersResult.data ?? [],
members,
roles: rolesResult.data ?? [],
invites: invitesResult.data ?? [],
orgCalendar: calendarResult.data,
serviceAccountEmail,
userRole
};
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { GOOGLE_API_KEY } from '$env/static/private';
import { env } from '$env/dynamic/private';
import { fetchPublicCalendarEvents } from '$lib/api/google-calendar';
import { fetchCalendarEventsViaServiceAccount } from '$lib/api/google-calendar-push';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api:google-calendar');
@@ -30,8 +31,11 @@ export const GET: RequestHandler = async ({ url, locals }) => {
return json({ error: 'Forbidden' }, { status: 403 });
}
if (!GOOGLE_API_KEY) {
return json({ error: 'Google API key not configured' }, { status: 500 });
const serviceKey = env.GOOGLE_SERVICE_ACCOUNT_KEY;
const apiKey = env.GOOGLE_API_KEY;
if (!serviceKey && !apiKey) {
return json({ error: 'No Google credentials configured' }, { status: 500 });
}
try {
@@ -53,17 +57,28 @@ export const GET: RequestHandler = async ({ url, locals }) => {
log.debug('Fetching events for calendar', { data: { calendarId: orgCal.calendar_id } });
// Fetch events for the next 3 months
const now = new Date();
const timeMin = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const timeMax = new Date(now.getFullYear(), now.getMonth() + 3, 0);
const events = await fetchPublicCalendarEvents(
let events: unknown[];
// Prefer service account (works with private calendars) over public API key
if (serviceKey) {
events = await fetchCalendarEventsViaServiceAccount(
serviceKey,
orgCal.calendar_id,
GOOGLE_API_KEY,
timeMin,
timeMax
);
} else {
events = await fetchPublicCalendarEvents(
orgCal.calendar_id,
apiKey!,
timeMin,
timeMax
);
}
log.debug('Fetched events', { data: { count: events.length } });
@@ -74,6 +89,6 @@ export const GET: RequestHandler = async ({ url, locals }) => {
});
} catch (err) {
log.error('Failed to fetch calendar events', { data: { orgId }, error: err });
return json({ error: 'Failed to fetch events. Make sure the calendar is public.', events: [] }, { status: 500 });
return json({ error: 'Failed to fetch events. Make sure the calendar is shared with the service account.', events: [] }, { status: 500 });
}
};

View File

@@ -0,0 +1,191 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/private';
import {
pushEventToGoogle,
updateGoogleEvent,
deleteGoogleEvent,
type GoogleEventPayload,
} from '$lib/api/google-calendar-push';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api.google-calendar-push');
/**
* Shared auth + org membership check
*/
async function authorize(locals: App.Locals, orgId: string): Promise<{ user: { id: string }; error?: never } | { error: Response; user?: never }> {
const { session, user } = await locals.safeGetSession();
if (!session || !user) {
return { error: 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 { error: json({ error: 'Forbidden' }, { status: 403 }) };
}
return { user };
}
/**
* Get the org's connected Google Calendar ID
*/
async function getOrgCalendarId(locals: App.Locals, orgId: string): Promise<string | null> {
const { data } = await locals.supabase
.from('org_google_calendars')
.select('calendar_id')
.eq('org_id', orgId)
.single();
return data?.calendar_id ?? null;
}
/**
* Build a Google Calendar event payload from our app's event data
*/
function buildGooglePayload(body: {
title: string;
description?: string | null;
start_time: string;
end_time: string;
all_day?: boolean;
color_id?: string;
}): GoogleEventPayload {
const base = {
summary: body.title,
description: body.description ?? undefined,
colorId: body.color_id ?? undefined,
};
if (body.all_day) {
const startDate = body.start_time.split('T')[0];
const endDateObj = new Date(body.end_time.split('T')[0]);
endDateObj.setDate(endDateObj.getDate() + 1);
const endDate = endDateObj.toISOString().split('T')[0];
return {
...base,
start: { date: startDate },
end: { date: endDate },
};
}
return {
...base,
start: { dateTime: new Date(body.start_time).toISOString() },
end: { dateTime: new Date(body.end_time).toISOString() },
};
}
/**
* POST /api/google-calendar/push
* Create an event in Google Calendar and return the google_event_id
*/
export const POST: RequestHandler = async ({ request, locals }) => {
const serviceKey = env.GOOGLE_SERVICE_ACCOUNT_KEY;
if (!serviceKey) {
return json({ error: 'Google service account not configured' }, { status: 500 });
}
const body = await request.json();
const { org_id, title, description, start_time, end_time, all_day, color_id } = body;
if (!org_id || !title || !start_time || !end_time) {
return json({ error: 'Missing required fields' }, { status: 400 });
}
const auth = await authorize(locals, org_id);
if (auth.error) return auth.error;
const calendarId = await getOrgCalendarId(locals, org_id);
if (!calendarId) {
return json({ error: 'No Google Calendar connected' }, { status: 404 });
}
try {
const payload = buildGooglePayload({ title, description, start_time, end_time, all_day, color_id });
const googleEventId = await pushEventToGoogle(serviceKey, calendarId, payload);
return json({ google_event_id: googleEventId });
} catch (err) {
log.error('Failed to push event to Google Calendar', { error: err, data: { org_id } });
return json({ error: 'Failed to create event in Google Calendar' }, { status: 500 });
}
};
/**
* PUT /api/google-calendar/push
* Update an existing event in Google Calendar
*/
export const PUT: RequestHandler = async ({ request, locals }) => {
const serviceKey = env.GOOGLE_SERVICE_ACCOUNT_KEY;
if (!serviceKey) {
return json({ error: 'Google service account not configured' }, { status: 500 });
}
const body = await request.json();
const { org_id, google_event_id, title, description, start_time, end_time, all_day, color_id } = body;
if (!org_id || !google_event_id || !title || !start_time || !end_time) {
return json({ error: 'Missing required fields' }, { status: 400 });
}
const auth = await authorize(locals, org_id);
if (auth.error) return auth.error;
const calendarId = await getOrgCalendarId(locals, org_id);
if (!calendarId) {
return json({ error: 'No Google Calendar connected' }, { status: 404 });
}
try {
const payload = buildGooglePayload({ title, description, start_time, end_time, all_day, color_id });
await updateGoogleEvent(serviceKey, calendarId, google_event_id, payload);
return json({ ok: true });
} catch (err) {
log.error('Failed to update Google Calendar event', { error: err, data: { org_id, google_event_id } });
return json({ error: 'Failed to update event in Google Calendar' }, { status: 500 });
}
};
/**
* DELETE /api/google-calendar/push
* Delete an event from Google Calendar
*/
export const DELETE: RequestHandler = async ({ request, locals }) => {
const serviceKey = env.GOOGLE_SERVICE_ACCOUNT_KEY;
if (!serviceKey) {
return json({ error: 'Google service account not configured' }, { status: 500 });
}
const body = await request.json();
const { org_id, google_event_id } = body;
if (!org_id || !google_event_id) {
return json({ error: 'Missing required fields' }, { status: 400 });
}
const auth = await authorize(locals, org_id);
if (auth.error) return auth.error;
const calendarId = await getOrgCalendarId(locals, org_id);
if (!calendarId) {
return json({ error: 'No Google Calendar connected' }, { status: 404 });
}
try {
await deleteGoogleEvent(serviceKey, calendarId, google_event_id);
return json({ ok: true });
} catch (err) {
log.error('Failed to delete Google Calendar event', { error: err, data: { org_id, google_event_id } });
return json({ error: 'Failed to delete event from Google Calendar' }, { status: 500 });
}
};

View File

@@ -0,0 +1,41 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api.release-lock');
/**
* POST /api/release-lock
* Called via navigator.sendBeacon() when the user navigates away from a document.
* Releases the document lock so other users can edit immediately.
*/
export const POST: RequestHandler = async ({ request, locals }) => {
const { session, user } = await locals.safeGetSession();
if (!session || !user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const { documentId } = await request.json();
if (!documentId) {
return json({ error: 'Missing documentId' }, { status: 400 });
}
// Only allow releasing your own lock
const { error } = await locals.supabase
.from('document_locks')
.delete()
.eq('document_id', documentId)
.eq('user_id', user.id);
if (error) {
log.error('Failed to release lock', { error, data: { documentId, userId: user.id } });
return json({ error: 'Failed to release lock' }, { status: 500 });
}
return json({ ok: true });
} catch (e) {
log.error('release-lock request failed', { error: e });
return json({ error: 'Invalid request' }, { status: 400 });
}
};

View File

@@ -13,6 +13,21 @@ export const GET: RequestHandler = async ({ url, locals }) => {
if (code) {
const { error } = await locals.supabase.auth.exchangeCodeForSession(code);
if (!error) {
// Sync avatar from OAuth provider metadata into profiles
const { data: { user } } = await locals.supabase.auth.getUser();
if (user) {
const avatarUrl = user.user_metadata?.avatar_url || user.user_metadata?.picture || null;
const fullName = user.user_metadata?.full_name || user.user_metadata?.name || null;
if (avatarUrl || fullName) {
const updates: Record<string, string> = {};
if (avatarUrl) updates.avatar_url = avatarUrl;
if (fullName) updates.full_name = fullName;
await locals.supabase
.from('profiles')
.update(updates)
.eq('id', user.id);
}
}
redirect(303, next);
}
}

View File

@@ -32,12 +32,14 @@ export const load: PageServerLoad = async ({ params, locals }) => {
// Get current user
const { data: { user } } = await locals.supabase.auth.getUser();
const org = (invite as Record<string, unknown>).organizations as { id: string; name: string; slug: string } | null;
return {
invite: {
id: invite.id,
email: invite.email,
role: invite.role,
org: (invite as any).organizations // join not typed
org,
},
user,
token

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { Button, Card } from "$lib/components/ui";
import { createLogger } from "$lib/utils/logger";
import { getContext } from "svelte";
import type { SupabaseClient } from "@supabase/supabase-js";
@@ -20,6 +21,7 @@
let { data }: Props = $props();
const supabase = getContext<SupabaseClient>("supabase");
const log = createLogger("page.invite");
let isAccepting = $state(false);
let error = $state(data.error || "");
@@ -55,7 +57,9 @@
error = "You're already a member of this organization.";
} else {
error = "Failed to join organization. Please try again.";
console.error(memberError);
log.error("Failed to join organization", {
error: memberError,
});
}
isAccepting = false;
return;
@@ -71,7 +75,7 @@
goto(`/${data.invite.org.slug}`);
} catch (e) {
error = "Something went wrong. Please try again.";
console.error(e);
log.error("Invite acceptance failed", { error: e });
isAccepting = false;
}
}

View File

@@ -1,5 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=Tilt+Warp&family=Work+Sans:wght@400;500;600;700&family=Inter:wght@400;500;600&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,400,0,0&display=swap');
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
@@ -68,6 +68,12 @@
h4 { @apply font-heading font-normal text-h4 leading-normal; }
h5 { @apply font-heading font-normal text-h5 leading-normal; }
h6 { @apply font-heading font-normal text-h6 leading-normal; }
button, [role="button"] { @apply cursor-pointer; }
button:disabled, [role="button"][aria-disabled="true"] { @apply cursor-not-allowed; }
a { @apply cursor-pointer; }
[draggable="true"] { @apply cursor-grab; }
[draggable="true"]:active { @apply cursor-grabbing; }
}
/* Scrollbar — no Tailwind equivalent for pseudo-elements */

View File

@@ -3,6 +3,7 @@
import { createClient } from "$lib/supabase";
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import * as m from "$lib/paraglide/messages";
let email = $state($page.url.searchParams.get("email") || "");
let password = $state("");
@@ -93,8 +94,8 @@
<div class="min-h-screen bg-dark flex items-center justify-center p-4">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-primary mb-2">Root</h1>
<p class="text-light/60">Team collaboration, reimagined</p>
<h1 class="text-3xl font-bold text-primary mb-2">{m.app_name()}</h1>
<p class="text-light/60">{m.login_subtitle()}</p>
</div>
<Card variant="elevated" padding="lg">
@@ -111,12 +112,10 @@
</span>
</div>
<h2 class="text-xl font-semibold text-light mb-2">
Check your email
{m.login_signup_success_title()}
</h2>
<p class="text-light/60 text-sm mb-4">
We've sent a confirmation link to <strong
class="text-light">{email}</strong
>. Click the link to activate your account.
{m.login_signup_success_text({ email })}
</p>
<Button
variant="tertiary"
@@ -125,12 +124,14 @@
mode = "login";
}}
>
Back to Login
{m.login_tab_login()}
</Button>
</div>
{:else}
<h2 class="text-xl font-semibold text-light mb-6">
{mode === "login" ? "Welcome back" : "Create your account"}
{mode === "login"
? m.login_tab_login()
: m.login_tab_signup()}
</h2>
{#if error}
@@ -150,28 +151,32 @@
>
<Input
type="email"
label="Email"
placeholder="you@example.com"
label={m.login_email_label()}
placeholder={m.login_email_placeholder()}
bind:value={email}
required
/>
<Input
type="password"
label="Password"
placeholder="••••••••"
label={m.login_password_label()}
placeholder={m.login_password_placeholder()}
bind:value={password}
required
/>
<Button type="submit" fullWidth loading={isLoading}>
{mode === "login" ? "Log In" : "Sign Up"}
{mode === "login"
? m.login_btn_login()
: m.login_btn_signup()}
</Button>
</form>
<div class="my-6 flex items-center gap-3">
<div class="flex-1 h-px bg-light/10"></div>
<span class="text-light/40 text-sm">or continue with</span>
<span class="text-light/40 text-sm"
>{m.login_or_continue()}</span
>
<div class="flex-1 h-px bg-light/10"></div>
</div>
@@ -198,25 +203,25 @@
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Continue with Google
{m.login_google()}
</Button>
<p class="mt-6 text-center text-light/60 text-sm">
{#if mode === "login"}
Don't have an account?
{m.login_signup_prompt()}
<button
class="text-primary hover:underline"
onclick={() => (mode = "signup")}
>
Sign up
{m.login_tab_signup()}
</button>
{:else}
Already have an account?
{m.login_login_prompt()}
<button
class="text-primary hover:underline"
onclick={() => (mode = "login")}
>
Log in
{m.login_tab_login()}
</button>
{/if}
</p>

View File

@@ -0,0 +1,17 @@
-- Allow authenticated users to upload to user-avatars folder
CREATE POLICY "Authenticated users can upload user avatars"
ON storage.objects FOR INSERT
TO authenticated
WITH CHECK (bucket_id = 'avatars' AND (storage.foldername(name))[1] = 'user-avatars');
-- Allow authenticated users to update (upsert) their user avatars
CREATE POLICY "Authenticated users can update user avatars"
ON storage.objects FOR UPDATE
TO authenticated
USING (bucket_id = 'avatars' AND (storage.foldername(name))[1] = 'user-avatars');
-- Allow authenticated users to delete user avatars
CREATE POLICY "Authenticated users can delete user avatars"
ON storage.objects FOR DELETE
TO authenticated
USING (bucket_id = 'avatars' AND (storage.foldername(name))[1] = 'user-avatars');

View File

@@ -0,0 +1,8 @@
-- Fix: org_members.user_id references auth.users(id) but PostgREST needs a FK
-- to public.profiles for embedded joins like `profiles:user_id (...)` to work.
-- Since profiles.id mirrors auth.users.id, we add a second FK to profiles.
-- Add FK from org_members.user_id to profiles.id
ALTER TABLE org_members
ADD CONSTRAINT org_members_user_id_profiles_fk
FOREIGN KEY (user_id) REFERENCES profiles(id) ON DELETE CASCADE;

147
tests/e2e/app.spec.ts Normal file
View File

@@ -0,0 +1,147 @@
import { test, expect } from '@playwright/test';
import { login, TEST_ORG_SLUG, TEST_EMAIL, waitForHydration, navigateTo } from './helpers';
test('should log in and reach org dashboard', async ({ page }) => {
await login(page);
await expect(page).toHaveURL(new RegExp(`/${TEST_ORG_SLUG}`));
});
test.describe('Sidebar', () => {
test.beforeEach(async ({ page }) => {
await login(page);
});
test('should display logo in sidebar', async ({ page }) => {
const logo = page.locator('aside svg');
await expect(logo.first()).toBeVisible();
});
test('should display user avatar and email in sidebar', async ({ page }) => {
const userMenuBtn = page.locator('.user-menu-container button').first();
await expect(userMenuBtn).toBeVisible();
// Should show user email
await expect(page.locator('.user-menu-container').getByText(TEST_EMAIL)).toBeVisible();
});
test('should show user dropdown on avatar click with correct items', async ({ page }) => {
const btn = page.locator('.user-menu-container button[aria-haspopup="true"]');
await expect(btn).toBeVisible();
await btn.click();
await expect(btn).toHaveAttribute('aria-expanded', 'true', { timeout: 3000 });
await expect(page.getByText('Account Settings').first()).toBeVisible();
await expect(page.getByText('Switch Organization')).toBeVisible();
await expect(page.getByText('Log Out')).toBeVisible();
});
test('should close user dropdown on Escape', async ({ page }) => {
const btn = page.locator('.user-menu-container button[aria-haspopup="true"]');
await btn.click();
await expect(btn).toHaveAttribute('aria-expanded', 'true', { timeout: 3000 });
await page.keyboard.press('Escape');
await expect(btn).toHaveAttribute('aria-expanded', 'false', { timeout: 3000 });
});
test('should navigate to account settings from dropdown', async ({ page }) => {
const btn = page.locator('.user-menu-container button[aria-haspopup="true"]');
await btn.click();
await expect(btn).toHaveAttribute('aria-expanded', 'true', { timeout: 3000 });
await page.getByText('Account Settings').first().click();
await page.waitForURL(new RegExp(`/${TEST_ORG_SLUG}/account`), { timeout: 10000 });
await expect(page.getByRole('heading', { name: 'Account Settings' })).toBeVisible();
});
test('sidebar nav items should be visible', async ({ page }) => {
const aside = page.locator('aside');
await expect(aside).toBeVisible();
const navLinks = aside.locator('nav a');
const count = await navLinks.count();
expect(count).toBeGreaterThan(0);
});
});
test.describe('Account Settings Page', () => {
test.beforeEach(async ({ page }) => {
await navigateTo(page, `/${TEST_ORG_SLUG}/account`);
});
test('should display Profile section with all elements', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible();
await expect(page.getByText('Display Name')).toBeVisible();
await expect(page.getByText('Email', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Upload' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Sync Google' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Save Profile' })).toBeVisible();
});
test('should display Appearance section with color picker', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Appearance' })).toBeVisible();
await expect(page.getByText('Accent Color')).toBeVisible();
// Custom color picker label
const colorPicker = page.locator('label[title="Custom color"]');
await expect(colorPicker).toBeVisible();
// Org theme toggle
await expect(page.getByText('Use Organization Theme')).toBeVisible();
await expect(page.getByRole('button', { name: 'Save Preferences' })).toBeVisible();
});
test('should display Security & Sessions section', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Security & Sessions' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Send Reset Email' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Sign Out Other Sessions' })).toBeVisible();
});
test('accent color swatches should be clickable', async ({ page }) => {
const swatches = page.locator('.flex.flex-wrap button[type="button"]');
const count = await swatches.count();
expect(count).toBeGreaterThanOrEqual(8);
// Click the third swatch (Red)
await swatches.nth(2).click();
await expect(swatches.nth(2)).toHaveClass(/border-white/);
});
});
test.describe('Kanban Page - ContextMenu', () => {
test.beforeEach(async ({ page }) => {
await navigateTo(page, `/${TEST_ORG_SLUG}/kanban`);
});
test('should load kanban page', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Kanban' })).toBeVisible();
});
test('should have context menu button in header', async ({ page }) => {
const moreBtn = page.locator('header button[aria-label="More options"]');
await expect(moreBtn).toBeVisible();
});
test('should open context menu on click', async ({ page }) => {
const moreBtn = page.locator('header button[aria-label="More options"]');
await moreBtn.click();
await expect(moreBtn).toHaveAttribute('aria-expanded', 'true', { timeout: 3000 });
// Menu dropdown should be visible
const menu = page.locator('.context-menu-container div.absolute');
await expect(menu).toBeVisible({ timeout: 3000 });
});
});
test.describe('Calendar Page - ContextMenu', () => {
test.beforeEach(async ({ page }) => {
await navigateTo(page, `/${TEST_ORG_SLUG}/calendar`);
});
test('should load calendar page', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Calendar' })).toBeVisible();
});
test('should have context menu button in header', async ({ page }) => {
const moreBtn = page.locator('header button[aria-label="More options"]');
await expect(moreBtn).toBeVisible();
});
test('should show Refresh Events in context menu', async ({ page }) => {
const moreBtn = page.locator('header button[aria-label="More options"]');
await moreBtn.click();
await expect(moreBtn).toHaveAttribute('aria-expanded', 'true', { timeout: 3000 });
await expect(page.getByText('Refresh Events')).toBeVisible({ timeout: 3000 });
});
});

18
tests/e2e/auth.setup.ts Normal file
View File

@@ -0,0 +1,18 @@
import { test as setup, expect } from '@playwright/test';
import { TEST_EMAIL, TEST_PASSWORD, TEST_ORG_SLUG } from './helpers';
const authFile = 'tests/e2e/.auth/user.json';
setup('authenticate', async ({ page }) => {
// networkidle ensures Svelte 5 hydration completes (event delegation attached)
await page.goto('/login', { waitUntil: 'networkidle' });
await page.fill('input[type="email"]', TEST_EMAIL);
await page.fill('input[type="password"]', TEST_PASSWORD);
await page.click('button[type="submit"]');
// After login, the app redirects to "/" (org selector)
await page.waitForURL('/', { timeout: 30000 });
// Verify we see the org selector
await expect(page.getByRole('link', { name: /root-test/i }).first()).toBeVisible({ timeout: 10000 });
// Save auth state
await page.context().storageState({ path: authFile });
});

191
tests/e2e/cleanup.ts Normal file
View File

@@ -0,0 +1,191 @@
import { createClient } from '@supabase/supabase-js';
import * as fs from 'fs';
import * as path from 'path';
/**
* Global teardown: delete all test-created data from Supabase.
* Runs after all Playwright tests complete.
*
* Matches documents/folders/kanbans by name prefixes used in tests:
* "Test Folder", "Test Doc", "Test Board", "Nav Folder", "Rename Me", "Renamed"
* Matches kanban boards by name prefix: "PW Board", "Board A", "Board B"
* Matches org_invites by email pattern: "playwright-test-*@example.com"
* Matches org_roles by name prefix: "Tester"
*/
// Load .env manually since we're outside Vite
function loadEnv() {
try {
const envPath = path.resolve(process.cwd(), '.env');
const content = fs.readFileSync(envPath, 'utf-8');
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIdx = trimmed.indexOf('=');
if (eqIdx === -1) continue;
const key = trimmed.slice(0, eqIdx);
const value = trimmed.slice(eqIdx + 1);
if (!process.env[key]) process.env[key] = value;
}
} catch { /* .env not found — rely on process.env */ }
}
loadEnv();
const SUPABASE_URL = process.env.PUBLIC_SUPABASE_URL || '';
const SUPABASE_KEY = process.env.PUBLIC_SUPABASE_ANON_KEY || '';
// Name prefixes used by tests when creating data
const DOC_PREFIXES = ['Test Folder', 'Test Doc', 'Test Board', 'Nav Folder', 'Rename Me', 'Renamed'];
const BOARD_PREFIXES = ['PW Board', 'PW Card Board', 'PW Detail Board', 'Board A', 'Board B'];
const ROLE_PREFIX = 'Tester';
const TAG_PREFIX = 'PW Tag';
const EVENT_PREFIXES = ['PW Event', 'PW Detail', 'PW Delete'];
const INVITE_EMAIL_PATTERN = 'playwright-test-%@example.com';
export default async function globalTeardown() {
if (!SUPABASE_KEY) {
console.log('[cleanup] No SUPABASE_ANON_KEY — skipping cleanup');
return;
}
// Authenticate using the test user credentials directly
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);
const { error: authError } = await supabase.auth.signInWithPassword({
email: 'tipilan@ituk.ee',
password: 'gu&u6QTMbJK7nT',
});
if (authError) {
console.log('[cleanup] Auth failed — skipping cleanup:', authError.message);
return;
}
// Get the org ID for root-test
const { data: org } = await supabase
.from('organizations')
.select('id')
.eq('slug', 'root-test')
.single();
if (!org) {
console.log('[cleanup] root-test org not found — skipping cleanup');
return;
}
const orgId = org.id;
let totalDeleted = 0;
// 1. Delete test documents (folders, docs, kanbans)
for (const prefix of DOC_PREFIXES) {
const { data: docs } = await supabase
.from('documents')
.select('id')
.eq('org_id', orgId)
.ilike('name', `${prefix}%`);
if (docs && docs.length > 0) {
const ids = docs.map(d => d.id);
const { error } = await supabase
.from('documents')
.delete()
.in('id', ids);
if (!error) {
totalDeleted += docs.length;
} else {
console.log(`[cleanup] Failed to delete docs with prefix "${prefix}":`, error.message);
}
}
}
// 2. Delete test kanban boards
for (const prefix of BOARD_PREFIXES) {
const { data: boards } = await supabase
.from('kanban_boards')
.select('id')
.eq('org_id', orgId)
.ilike('name', `${prefix}%`);
if (boards && boards.length > 0) {
const ids = boards.map(b => b.id);
const { error } = await supabase
.from('kanban_boards')
.delete()
.in('id', ids);
if (!error) {
totalDeleted += boards.length;
} else {
console.log(`[cleanup] Failed to delete boards with prefix "${prefix}":`, error.message);
}
}
}
// 3. Delete test invites (playwright-test-*@example.com)
const { data: invites } = await supabase
.from('org_invites')
.select('id')
.eq('org_id', orgId)
.ilike('email', INVITE_EMAIL_PATTERN);
if (invites && invites.length > 0) {
const ids = invites.map(i => i.id);
await supabase.from('org_invites').delete().in('id', ids);
totalDeleted += invites.length;
}
// 4. Delete test roles
const { data: roles } = await supabase
.from('org_roles')
.select('id')
.eq('org_id', orgId)
.ilike('name', `${ROLE_PREFIX}%`);
if (roles && roles.length > 0) {
const ids = roles.map(r => r.id);
await supabase.from('org_roles').delete().in('id', ids);
totalDeleted += roles.length;
}
// 5. Delete test tags
const { data: tags } = await supabase
.from('tags')
.select('id')
.eq('org_id', orgId)
.ilike('name', `${TAG_PREFIX}%`);
if (tags && tags.length > 0) {
const ids = tags.map(t => t.id);
await supabase.from('tags').delete().in('id', ids);
totalDeleted += tags.length;
}
// 6. Delete test calendar events
for (const prefix of EVENT_PREFIXES) {
const { data: events } = await supabase
.from('calendar_events')
.select('id')
.eq('org_id', orgId)
.ilike('title', `${prefix}%`);
if (events && events.length > 0) {
const ids = events.map(e => e.id);
const { error } = await supabase
.from('calendar_events')
.delete()
.in('id', ids);
if (!error) {
totalDeleted += events.length;
} else {
console.log(`[cleanup] Failed to delete events with prefix "${prefix}":`, error.message);
}
}
}
if (totalDeleted > 0) {
console.log(`[cleanup] Deleted ${totalDeleted} test-created items from root-test org`);
} else {
console.log('[cleanup] No test data to clean up');
}
}

550
tests/e2e/features.spec.ts Normal file
View File

@@ -0,0 +1,550 @@
import { test, expect } from '@playwright/test';
import { login, TEST_ORG_SLUG, TEST_EMAIL, waitForHydration, navigateTo } from './helpers';
// ─── File Management ────────────────────────────────────────────────────────
test.describe('File Management', () => {
test.beforeEach(async ({ page }) => {
await navigateTo(page, `/${TEST_ORG_SLUG}/documents`);
});
test('should load files page with header and + New button', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Files' })).toBeVisible();
await expect(page.getByRole('button', { name: '+ New' })).toBeVisible();
});
test('should open Create New modal with type selectors', async ({ page }) => {
await page.getByRole('button', { name: '+ New' }).click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible({ timeout: 3000 });
await expect(modal.getByText('Create New')).toBeVisible();
// Type selector buttons
await expect(modal.getByText('Document')).toBeVisible();
await expect(modal.getByText('Folder')).toBeVisible();
await expect(modal.getByText('Kanban')).toBeVisible();
// Name input and Create button
await expect(modal.getByText('Name')).toBeVisible();
await expect(modal.getByRole('button', { name: 'Create' })).toBeVisible();
await expect(modal.getByRole('button', { name: 'Cancel' })).toBeVisible();
});
test('should create a folder and see it in the file list', async ({ page }) => {
const folderName = `Test Folder ${Date.now()}`;
await page.getByRole('button', { name: '+ New' }).click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible({ timeout: 3000 });
// Select Folder type
await modal.getByText('Folder').click();
// Fill name
await modal.getByPlaceholder('Folder name').fill(folderName);
await modal.getByRole('button', { name: 'Create' }).click();
// Modal should close
await expect(modal).not.toBeVisible({ timeout: 5000 });
// Folder should appear in the file list
await expect(page.getByText(folderName)).toBeVisible({ timeout: 5000 });
});
test('should create a document and navigate to editor', async ({ page }) => {
const docName = `Test Doc ${Date.now()}`;
await page.getByRole('button', { name: '+ New' }).click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible({ timeout: 3000 });
// Document type is default
await modal.getByPlaceholder('Document name').fill(docName);
await modal.getByRole('button', { name: 'Create' }).click();
// Should navigate to the file editor page
await page.waitForURL(new RegExp(`/${TEST_ORG_SLUG}/documents/file/`), { timeout: 10000 });
await expect(page.getByText(docName).first()).toBeVisible({ timeout: 5000 });
});
test('should create a kanban board from files and navigate to it', async ({ page }) => {
const boardName = `Test Board ${Date.now()}`;
await page.getByRole('button', { name: '+ New' }).click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible({ timeout: 3000 });
// Select Kanban type
await modal.getByText('Kanban').click();
await modal.getByPlaceholder('Kanban board name').fill(boardName);
await modal.getByRole('button', { name: 'Create' }).click();
// Should navigate to the kanban file page
await page.waitForURL(new RegExp(`/${TEST_ORG_SLUG}/documents/file/`), { timeout: 10000 });
await waitForHydration(page);
await expect(page.getByText(boardName).first()).toBeVisible({ timeout: 5000 });
// Default columns should be created
await expect(page.getByText('To Do').first()).toBeVisible({ timeout: 10000 });
await expect(page.getByText('In Progress').first()).toBeVisible({ timeout: 5000 });
await expect(page.getByText('Done').first()).toBeVisible({ timeout: 5000 });
});
test('should navigate into a folder and see breadcrumbs', async ({ page }) => {
// First create a folder
const folderName = `Nav Folder ${Date.now()}`;
await page.getByRole('button', { name: '+ New' }).click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible({ timeout: 3000 });
await modal.getByText('Folder').click();
await modal.getByPlaceholder('Folder name').fill(folderName);
await modal.getByRole('button', { name: 'Create' }).click();
await expect(modal).not.toBeVisible({ timeout: 5000 });
// Click the folder button to navigate into it
const folderBtn = page.locator('button', { hasText: folderName }).first();
await folderBtn.scrollIntoViewIfNeeded();
await folderBtn.click();
await page.waitForURL(new RegExp(`/${TEST_ORG_SLUG}/documents/folder/`), { timeout: 10000 });
await waitForHydration(page);
// Breadcrumb should show Home > FolderName
const breadcrumb = page.locator('nav');
await expect(breadcrumb.getByText('Home')).toBeVisible({ timeout: 3000 });
await expect(breadcrumb.getByText(folderName)).toBeVisible({ timeout: 3000 });
});
test('should rename a file via right-click context menu', async ({ page }) => {
// Create a document first
const originalName = `Rename Me ${Date.now()}`;
await page.getByRole('button', { name: '+ New' }).click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible({ timeout: 3000 });
await modal.getByText('Folder').click();
await modal.getByPlaceholder('Folder name').fill(originalName);
await modal.getByRole('button', { name: 'Create' }).click();
await expect(modal).not.toBeVisible({ timeout: 5000 });
await expect(page.getByText(originalName).first()).toBeVisible({ timeout: 5000 });
// Right-click to open context menu
await page.getByText(originalName).first().click({ button: 'right' });
// Context menu should appear — find the Rename button inside the fixed z-50 context menu
const contextMenuPanel = page.locator('.fixed.z-50.bg-night');
await expect(contextMenuPanel).toBeVisible({ timeout: 3000 });
const renameBtn = contextMenuPanel.locator('button', { hasText: 'Rename' });
await expect(renameBtn).toBeVisible({ timeout: 3000 });
// Click Rename
await renameBtn.click();
const renameModal = page.getByRole('dialog');
await expect(renameModal).toBeVisible({ timeout: 3000 });
// Clear and type new name
const newName = `Renamed ${Date.now()}`;
const nameInput = renameModal.locator('input');
await nameInput.clear();
await nameInput.fill(newName);
await renameModal.getByRole('button', { name: 'Save' }).click();
await expect(renameModal).not.toBeVisible({ timeout: 5000 });
// New name should be visible
await expect(page.getByText(newName)).toBeVisible({ timeout: 5000 });
});
test('should toggle between grid and list view', async ({ page }) => {
// Default is grid view - look for the toggle button
const toggleBtn = page.locator('button[title="Toggle view"]');
await expect(toggleBtn).toBeVisible();
// Click to switch to list view
await toggleBtn.click();
// The file list container should use list layout (flex-col)
const listContainer = page.locator('div[role="list"].flex.flex-col');
await expect(listContainer).toBeVisible({ timeout: 3000 });
// Click again to switch back to grid
await toggleBtn.click();
const gridContainer = page.locator('div[role="list"].grid');
await expect(gridContainer).toBeVisible({ timeout: 3000 });
});
});
// ─── Kanban Board ───────────────────────────────────────────────────────────
test.describe('Kanban Board Page', () => {
test.beforeEach(async ({ page }) => {
await navigateTo(page, `/${TEST_ORG_SLUG}/kanban`);
});
test('should load kanban page with header', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Kanban' })).toBeVisible();
await expect(page.getByRole('button', { name: '+ New' })).toBeVisible();
});
test('should open Create Board modal and create a board', async ({ page }) => {
const boardName = `PW Board ${Date.now()}`;
await page.getByRole('button', { name: '+ New' }).click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible({ timeout: 3000 });
await expect(modal.getByText('Create Board')).toBeVisible();
await modal.getByPlaceholder('e.g. Sprint 1').fill(boardName);
await modal.getByRole('button', { name: 'Create' }).click();
await expect(modal).not.toBeVisible({ timeout: 5000 });
// Board should be selected and show default columns
await expect(page.getByText('To Do')).toBeVisible({ timeout: 5000 });
await expect(page.getByText('In Progress')).toBeVisible({ timeout: 5000 });
await expect(page.getByText('Done')).toBeVisible({ timeout: 5000 });
});
test('should show board selector when multiple boards exist', async ({ page }) => {
// Create two boards
for (const name of [`Board A ${Date.now()}`, `Board B ${Date.now()}`]) {
await page.getByRole('button', { name: '+ New' }).click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible({ timeout: 3000 });
await modal.getByPlaceholder('e.g. Sprint 1').fill(name);
await modal.getByRole('button', { name: 'Create' }).click();
await expect(modal).not.toBeVisible({ timeout: 5000 });
// Wait for board to load
await expect(page.getByText('To Do')).toBeVisible({ timeout: 5000 });
}
// Board selector pills should be visible
const boardButtons = page.locator('button.rounded-\\[32px\\]');
const count = await boardButtons.count();
expect(count).toBeGreaterThanOrEqual(2);
});
});
// ─── Settings: Members & Roles ──────────────────────────────────────────────
test.describe('Settings Page - Members', () => {
test.beforeEach(async ({ page }) => {
await navigateTo(page, `/${TEST_ORG_SLUG}/settings`);
});
test('should load settings page with tabs', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
await expect(page.getByRole('button', { name: 'General' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Members' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Roles' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Integrations' })).toBeVisible();
});
test('should switch to Members tab and show team members', async ({ page }) => {
await page.getByRole('button', { name: 'Members' }).click();
// Team Members heading should show a non-zero count
const heading = page.locator('h2').filter({ hasText: 'Team Members' });
await expect(heading).toBeVisible({ timeout: 5000 });
await expect(heading).not.toHaveText('Team Members (0)');
await expect(page.getByRole('button', { name: 'Invite Member' })).toBeVisible();
// The test user's email should appear in the members list (scope to main to avoid sidebar match)
const main = page.locator('main');
await expect(main.getByText(TEST_EMAIL).first()).toBeVisible({ timeout: 5000 });
// Each member should have a role badge
await expect(main.getByText('owner').first()).toBeVisible();
});
test('should open Invite Member modal with email and role fields', async ({ page }) => {
await page.getByRole('button', { name: 'Members' }).click();
await expect(page.getByRole('button', { name: 'Invite Member' })).toBeVisible({ timeout: 3000 });
await page.getByRole('button', { name: 'Invite Member' }).click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible({ timeout: 3000 });
await expect(modal.getByText('Invite Member')).toBeVisible();
await expect(modal.getByText('Email address')).toBeVisible();
await expect(modal.getByText('Role')).toBeVisible();
await expect(modal.getByRole('button', { name: 'Send Invite' })).toBeVisible();
await expect(modal.getByRole('button', { name: 'Cancel' })).toBeVisible();
});
test('should send an invite and show it in pending invites', async ({ page }) => {
await page.getByRole('button', { name: 'Members' }).click();
await page.getByRole('button', { name: 'Invite Member' }).click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible({ timeout: 3000 });
const testInviteEmail = `playwright-test-${Date.now()}@example.com`;
await modal.getByPlaceholder('colleague@example.com').fill(testInviteEmail);
await modal.getByRole('button', { name: 'Send Invite' }).click();
await expect(modal).not.toBeVisible({ timeout: 5000 });
// Pending invite should appear
await expect(page.getByText('Pending Invites')).toBeVisible({ timeout: 5000 });
await expect(page.getByText(testInviteEmail).first()).toBeVisible({ timeout: 5000 });
// Clean up: cancel the invite — find the row containing the email, then its Cancel button
const inviteRow = page.locator('.bg-light\\/5').filter({ hasText: testInviteEmail });
await inviteRow.getByRole('button', { name: 'Cancel' }).click();
await expect(page.getByText(testInviteEmail)).not.toBeVisible({ timeout: 5000 });
});
test('should show pending invites section when invites exist', async ({ page }) => {
await page.getByRole('button', { name: 'Members' }).click();
await expect(page.getByText('Team Members').first()).toBeVisible({ timeout: 5000 });
// Check if Pending Invites section exists (from previous test runs or this session)
const hasPending = await page.getByText('Pending Invites').isVisible().catch(() => false);
if (hasPending) {
// Each invite should have Copy Link and Cancel buttons
await expect(page.getByRole('button', { name: 'Copy Link' }).first()).toBeVisible();
}
});
});
test.describe('Settings Page - Roles', () => {
test.beforeEach(async ({ page }) => {
await navigateTo(page, `/${TEST_ORG_SLUG}/settings`);
});
test('should switch to Roles tab and show existing roles', async ({ page }) => {
await page.getByRole('button', { name: 'Roles' }).click();
await expect(page.getByText('Roles', { exact: true }).first()).toBeVisible({ timeout: 3000 });
await expect(page.getByRole('button', { name: 'Create Role' })).toBeVisible();
// System roles should be visible
await expect(page.getByText('Owner')).toBeVisible({ timeout: 3000 });
});
test('should open Create Role modal with name, color, and permissions', async ({ page }) => {
await page.getByRole('button', { name: 'Roles' }).click();
await page.getByRole('button', { name: 'Create Role' }).click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible({ timeout: 3000 });
await expect(modal.getByText('Create Role')).toBeVisible();
await expect(modal.getByText('Name')).toBeVisible();
await expect(modal.getByText('Color')).toBeVisible();
await expect(modal.getByText('Permissions')).toBeVisible();
// Permission groups
await expect(modal.getByText('Documents')).toBeVisible();
await expect(modal.getByText('Kanban')).toBeVisible();
await expect(modal.getByText('Calendar')).toBeVisible();
await expect(modal.getByText('Members')).toBeVisible();
});
test('should create a custom role and see it in the list', async ({ page }) => {
test.setTimeout(60000);
const roleName = `Tester ${Date.now()}`;
await page.getByRole('button', { name: 'Roles' }).click();
await page.getByRole('button', { name: 'Create Role' }).click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible({ timeout: 3000 });
await modal.getByPlaceholder('e.g., Moderator').fill(roleName);
// Select a color (click the second color swatch)
const colorSwatches = modal.locator('button.rounded-full');
await colorSwatches.nth(1).click();
// Create
await modal.getByRole('button', { name: 'Create' }).click();
await expect(modal).not.toBeVisible({ timeout: 5000 });
// Role should appear in the list
await expect(page.getByText(roleName)).toBeVisible({ timeout: 5000 });
// Clean up: delete the role — set up dialog handler BEFORE clicking
page.on('dialog', dialog => dialog.accept());
// Find the specific role card that contains the role name and click its Delete button
const deleteBtn = page.getByRole('button', { name: 'Delete' }).last();
await deleteBtn.click();
await expect(page.getByText(roleName)).not.toBeVisible({ timeout: 5000 });
});
});
// ─── Settings: Tags ─────────────────────────────────────────────────────────
test.describe('Settings Page - Tags', () => {
test.beforeEach(async ({ page }) => {
await navigateTo(page, `/${TEST_ORG_SLUG}/settings`);
});
test('should switch to Tags tab and show tag management UI', async ({ page }) => {
await page.getByRole('button', { name: 'Tags' }).click();
await expect(page.getByRole('button', { name: 'Create Tag' })).toBeVisible({ timeout: 3000 });
});
test('should create a tag and see it in the list', async ({ page }) => {
test.setTimeout(60000);
const tagName = `PW Tag ${Date.now()}`;
await page.getByRole('button', { name: 'Tags' }).click();
await page.getByRole('button', { name: 'Create Tag' }).click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible({ timeout: 3000 });
await modal.getByPlaceholder('e.g., Bug').fill(tagName);
await modal.getByRole('button', { name: 'Create' }).click();
await expect(modal).not.toBeVisible({ timeout: 5000 });
await expect(page.getByText(tagName)).toBeVisible({ timeout: 5000 });
// Clean up: delete the tag
page.on('dialog', dialog => dialog.accept());
const tagCard = page.locator('div').filter({ hasText: tagName }).last();
const deleteBtn = tagCard.getByRole('button').filter({ hasText: /delete/i }).first();
if (await deleteBtn.isVisible()) {
await deleteBtn.click();
await expect(page.getByText(tagName)).not.toBeVisible({ timeout: 5000 });
}
});
});
// ─── Settings: General ──────────────────────────────────────────────────────
test.describe('Settings Page - General', () => {
test.beforeEach(async ({ page }) => {
await navigateTo(page, `/${TEST_ORG_SLUG}/settings`);
});
test('should show General tab by default with org info', async ({ page }) => {
// General tab should be active by default - check for the Settings heading
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible({ timeout: 3000 });
// General button should be the active tab
await expect(page.getByRole('button', { name: 'General' })).toBeVisible();
});
});
// ─── Calendar CRUD ──────────────────────────────────────────────────────────
test.describe('Calendar - Event CRUD', () => {
test.beforeEach(async ({ page }) => {
await navigateTo(page, `/${TEST_ORG_SLUG}/calendar`);
});
test('should open create event modal via + New button', async ({ page }) => {
await page.getByRole('button', { name: '+ New' }).click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible({ timeout: 3000 });
await expect(modal.getByText('Title')).toBeVisible();
await expect(modal.getByText('Date')).toBeVisible();
await expect(modal.getByRole('button', { name: 'Create' })).toBeVisible();
await expect(modal.getByRole('button', { name: 'Cancel' })).toBeVisible();
});
test('should create an event and see it on the calendar', async ({ page }) => {
test.setTimeout(60000);
const eventTitle = `PW Event ${Date.now()}`;
await page.getByRole('button', { name: '+ New' }).click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible({ timeout: 3000 });
// Fill in event details
await modal.getByLabel('Title').fill(eventTitle);
await modal.getByRole('button', { name: 'Create' }).click();
await expect(modal).not.toBeVisible({ timeout: 5000 });
// Event should appear on the calendar
await expect(page.getByText(eventTitle).first()).toBeVisible({ timeout: 5000 });
});
test('should click an event to view details', async ({ page }) => {
test.setTimeout(60000);
// Create an event first
const eventTitle = `PW Detail ${Date.now()}`;
await page.getByRole('button', { name: '+ New' }).click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible({ timeout: 3000 });
await modal.getByLabel('Title').fill(eventTitle);
await modal.getByRole('button', { name: 'Create' }).click();
await expect(modal).not.toBeVisible({ timeout: 5000 });
// Click the event
await page.getByText(eventTitle).first().click();
const detailModal = page.getByRole('dialog');
await expect(detailModal).toBeVisible({ timeout: 3000 });
await expect(detailModal.getByText(eventTitle)).toBeVisible();
});
test('should delete an event', async ({ page }) => {
test.setTimeout(60000);
const eventTitle = `PW Delete ${Date.now()}`;
await page.getByRole('button', { name: '+ New' }).click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible({ timeout: 3000 });
await modal.getByLabel('Title').fill(eventTitle);
await modal.getByRole('button', { name: 'Create' }).click();
await expect(modal).not.toBeVisible({ timeout: 5000 });
await expect(page.getByText(eventTitle).first()).toBeVisible({ timeout: 5000 });
// Click event to open detail modal, then delete
await page.getByText(eventTitle).first().click();
const detailModal = page.getByRole('dialog');
await expect(detailModal).toBeVisible({ timeout: 3000 });
page.on('dialog', dialog => dialog.accept());
const deleteBtn = detailModal.getByRole('button', { name: 'Delete' });
if (await deleteBtn.isVisible()) {
await deleteBtn.click();
await expect(detailModal).not.toBeVisible({ timeout: 5000 });
}
});
});
// ─── Kanban Card CRUD ───────────────────────────────────────────────────────
test.describe('Kanban - Card CRUD', () => {
test.beforeEach(async ({ page }) => {
await navigateTo(page, `/${TEST_ORG_SLUG}/kanban`);
});
test('should create a board and add a card to a column', async ({ page }) => {
test.setTimeout(60000);
const boardName = `PW Card Board ${Date.now()}`;
// Create board
await page.getByRole('button', { name: '+ New' }).click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible({ timeout: 3000 });
await modal.getByPlaceholder('e.g. Sprint 1').fill(boardName);
await modal.getByRole('button', { name: 'Create' }).click();
await expect(modal).not.toBeVisible({ timeout: 5000 });
await expect(page.getByText('To Do')).toBeVisible({ timeout: 5000 });
// Add a card — click the + button in the first column
const addCardBtn = page.locator('button[title="Add card"]').first();
if (await addCardBtn.isVisible()) {
await addCardBtn.click();
} else {
// Fallback: look for any add button in the column area
const plusBtn = page.locator('button').filter({ hasText: '+' }).first();
await plusBtn.click();
}
// Card creation modal or inline input should appear
const cardModal = page.getByRole('dialog');
const hasModal = await cardModal.isVisible().catch(() => false);
if (hasModal) {
const cardTitle = `PW Card ${Date.now()}`;
await cardModal.getByLabel('Title').fill(cardTitle);
await cardModal.getByRole('button', { name: 'Create' }).click();
await expect(cardModal).not.toBeVisible({ timeout: 5000 });
await expect(page.getByText(cardTitle).first()).toBeVisible({ timeout: 5000 });
}
});
test('should open card detail modal on card click', async ({ page }) => {
test.setTimeout(60000);
const boardName = `PW Detail Board ${Date.now()}`;
// Create board
await page.getByRole('button', { name: '+ New' }).click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible({ timeout: 3000 });
await modal.getByPlaceholder('e.g. Sprint 1').fill(boardName);
await modal.getByRole('button', { name: 'Create' }).click();
await expect(modal).not.toBeVisible({ timeout: 5000 });
await expect(page.getByText('To Do')).toBeVisible({ timeout: 5000 });
// Add a card via the + button
const addCardBtn = page.locator('button[title="Add card"]').first();
if (await addCardBtn.isVisible()) {
await addCardBtn.click();
const cardModal = page.getByRole('dialog');
const hasModal = await cardModal.isVisible().catch(() => false);
if (hasModal) {
const cardTitle = `PW Click Card ${Date.now()}`;
await cardModal.getByLabel('Title').fill(cardTitle);
await cardModal.getByRole('button', { name: 'Create' }).click();
await expect(cardModal).not.toBeVisible({ timeout: 5000 });
// Click the card to open detail modal
await page.getByText(cardTitle).first().click();
const detailModal = page.getByRole('dialog');
await expect(detailModal).toBeVisible({ timeout: 5000 });
await expect(detailModal.getByText(cardTitle)).toBeVisible();
}
}
});
});
// ─── Settings: Integrations ─────────────────────────────────────────────────
test.describe('Settings Page - Integrations', () => {
test.beforeEach(async ({ page }) => {
await navigateTo(page, `/${TEST_ORG_SLUG}/settings`);
});
test('should show Integrations tab with Google Calendar card', async ({ page }) => {
await page.getByRole('button', { name: 'Integrations' }).click();
await expect(page.getByRole('heading', { name: 'Google Calendar' })).toBeVisible({ timeout: 3000 });
// Discord and Slack should show as "Coming soon"
await expect(page.getByRole('heading', { name: 'Discord' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Slack' })).toBeVisible();
await expect(page.getByText('Coming soon').first()).toBeVisible();
});
});

41
tests/e2e/helpers.ts Normal file
View File

@@ -0,0 +1,41 @@
import { type Page } from '@playwright/test';
export const TEST_EMAIL = 'tipilan@ituk.ee';
export const TEST_PASSWORD = 'gu&u6QTMbJK7nT';
export const TEST_ORG_SLUG = 'root-test';
/**
* Navigate to the org dashboard. Auth state is pre-loaded via storageState.
* If we land on the org selector, click through to the org.
*/
export async function login(page: Page) {
await page.goto(`/${TEST_ORG_SLUG}`, { timeout: 45000 });
// If we got redirected to org selector instead of the org page
if (page.url() === 'http://localhost:5173/' || page.url().endsWith(':5173/')) {
await page.getByRole('link', { name: /root-test/i }).first().click();
await page.waitForURL(new RegExp(`/${TEST_ORG_SLUG}`), { timeout: 15000 });
}
await waitForHydration(page);
}
/**
* Navigate directly to a sub-page (skips the dashboard hop).
* Auth state is pre-loaded via storageState.
*/
export async function navigateTo(page: Page, path: string) {
await page.goto(path, { timeout: 45000 });
await waitForHydration(page);
}
/**
* Wait for Svelte 5 hydration on the current page.
* Svelte 5 uses event delegation — handlers only work after hydration.
*/
export async function waitForHydration(page: Page) {
await page.waitForFunction(() => {
const els = document.querySelectorAll('button');
return Array.from(els).some((el) => (el as any).__click !== undefined);
}, { timeout: 15000 });
}

View File

@@ -1,10 +1,21 @@
import { paraglideVitePlugin } from '@inlang/paraglide-js';
import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
plugins: [
tailwindcss(),
sveltekit(),
paraglideVitePlugin({ project: './project.inlang', outdir: './src/lib/paraglide' })
],
server: {
watch: {
// Reduce file-watcher overhead on Windows — ignore heavy dirs
ignored: ['**/node_modules/**', '**/test-results/**', '**/project.inlang/cache/**', '**/.svelte-kit/**']
}
},
test: {
expect: { requireAssertions: true },
projects: [