Mega push vol 5, working on messaging now
This commit is contained in:
@@ -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
31
.github/workflows/ci.yml
vendored
Normal 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
5
.gitignore
vendored
@@ -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/
|
||||
|
||||
92
AUDIT.md
92
AUDIT.md
@@ -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 v1–v4 fixes.
|
||||
|
||||
| Area | Score | Notes |
|
||||
|------|-------|-------|
|
||||
| **Security** | ⭐⭐⭐ 3/5 | S-2, S-3, S-5 fixed. **S-1 (credential rotation) and S-4 (server-side auth for mutations) remain critical/high.** S-6 (lock cleanup race) still open. |
|
||||
| **Type Safety** | ⭐⭐⭐⭐ 4/5 | `OrgLayoutData` shared type eliminates parent casts. 2 targeted `as any` casts fixed. Remaining `as any` casts are in Supabase join results that need full type regeneration (T-1). |
|
||||
| **Dead Code** | ⭐⭐⭐⭐⭐ 5/5 | All dead stores, unused components, placeholder tests, empty files, and unused dependencies removed in v2. No known dead code remains. |
|
||||
| **Architecture** | ⭐⭐⭐⭐ 4/5 | Settings page split into 4 components. FileBrowser migrated to API modules. `createDocument` supports all types. Remaining: some components still have inline Supabase calls (CardDetailModal, CardComments). |
|
||||
| **Performance** | ⭐⭐⭐⭐ 4/5 | Folder listings exclude content. Kanban queries parallelized. Card moves batched smartly. Realtime is incremental. Remaining: full org document fetch for breadcrumbs could be optimized further. |
|
||||
| **Error Handling** | ⭐⭐⭐⭐ 4/5 | `alert()` replaced with toasts. Structured logger adopted in API routes. `$effect` sync blocks added. Remaining: `console.error` in 3-4 files (calendar page, invite page), lock release in `onDestroy`. |
|
||||
| **Testing** | ⭐⭐⭐⭐ 4/5 | 43 unit tests (logger, calendar, google-calendar, documents API). 35+ Playwright E2E tests covering all major flows. CI pipeline on GitHub Actions. Remaining: visual regression tests, Svelte component tests. |
|
||||
| **Code Quality** | ⭐⭐⭐⭐ 4/5 | Consistent API module pattern. Shared types. i18n complete. Duplication eliminated. Remaining: `role`/`userRole` fully migrated but some inline SVGs and magic numbers persist. |
|
||||
| **Dependencies** | ⭐⭐⭐⭐⭐ 5/5 | `lucide-svelte` removed. All deps actively used. No known unused packages. |
|
||||
| **Future-Proofing** | ⭐⭐⭐ 3/5 | Permission system defined but not enforced (F-1). Kanban realtime subscription unscoped (F-2). No search, notifications, or keyboard shortcuts yet. |
|
||||
|
||||
### Overall Score: ⭐⭐⭐⭐ 4.0 / 5
|
||||
|
||||
**Breakdown:** 41 out of 50 possible stars across 10 areas.
|
||||
|
||||
### Remaining High-Priority Items
|
||||
|
||||
1. **S-1: Rotate credentials & purge `.env` from git history** — Critical security risk. Must be done manually.
|
||||
2. **S-4: Server-side auth for settings mutations** — Move destructive operations to SvelteKit form actions with explicit authorization.
|
||||
3. **T-1: Regenerate Supabase types** — `supabase gen types typescript` to eliminate remaining `as any` casts from join results.
|
||||
4. **F-1: Permission enforcement** — Create `hasPermission()` utility; the permission system is defined but never checked.
|
||||
|
||||
### Remaining Medium-Priority Items
|
||||
|
||||
5. **S-6: Lock cleanup race condition** — Consolidate to server-side cron only.
|
||||
6. **E-2: Replace remaining `console.*` calls** — 3-4 files still use raw console instead of structured logger.
|
||||
7. **E-5: Lock release in `onDestroy`** — Use `navigator.sendBeacon` for reliable cleanup.
|
||||
8. **F-2: Scoped realtime subscriptions** — Filter kanban card changes to current board's columns.
|
||||
9. **M-1/M-3: Magic numbers and inline SVGs** — Extract constants, use Icon component consistently.
|
||||
|
||||
### Feature Backlog (Tier 5)
|
||||
|
||||
10. Notifications system (mentions, assignments, due dates)
|
||||
11. Global search across documents, kanban cards, calendar events
|
||||
12. Keyboard shortcuts for common actions
|
||||
13. Mobile responsive layout (sidebar drawer, touch-friendly kanban)
|
||||
14. Dark/light theme toggle
|
||||
15. Export/import (CSV/JSON/Markdown)
|
||||
16. Undo/redo with toast-based undo for destructive actions
|
||||
17. Onboarding flow for new users
|
||||
18. Visual regression tests for key pages
|
||||
|
||||
255
messages/en.json
Normal file
255
messages/en.json
Normal 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
255
messages/et.json
Normal 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
1093
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
32
playwright.config.ts
Normal 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'],
|
||||
},
|
||||
],
|
||||
});
|
||||
15
project.inlang/settings.json
Normal file
15
project.inlang/settings.json
Normal 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"
|
||||
]
|
||||
}
|
||||
13
src/app.html
13
src/app.html
@@ -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>
|
||||
|
||||
@@ -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
3
src/hooks.ts
Normal 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
38
src/lib/api/activity.ts
Normal 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 } });
|
||||
}
|
||||
}
|
||||
90
src/lib/api/calendar.test.ts
Normal file
90
src/lib/api/calendar.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
132
src/lib/api/documents.test.ts
Normal file
132
src/lib/api/documents.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
218
src/lib/api/google-calendar-push.ts
Normal file
218
src/lib/api/google-calendar-push.ts
Normal 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 } });
|
||||
}
|
||||
61
src/lib/api/google-calendar.test.ts
Normal file
61
src/lib/api/google-calendar.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
369
src/lib/components/settings/SettingsIntegrations.svelte
Normal file
369
src/lib/components/settings/SettingsIntegrations.svelte
Normal 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>
|
||||
398
src/lib/components/settings/SettingsMembers.svelte
Normal file
398
src/lib/components/settings/SettingsMembers.svelte
Normal 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>
|
||||
350
src/lib/components/settings/SettingsRoles.svelte
Normal file
350
src/lib/components/settings/SettingsRoles.svelte
Normal 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>
|
||||
@@ -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';
|
||||
|
||||
104
src/lib/components/ui/ContextMenu.svelte
Normal file
104
src/lib/components/ui/ContextMenu.svelte
Normal 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>
|
||||
@@ -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]}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
119
src/lib/components/ui/PageSkeleton.svelte
Normal file
119
src/lib/components/ui/PageSkeleton.svelte
Normal 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>
|
||||
@@ -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
29
src/lib/types/layout.ts
Normal 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'>;
|
||||
}
|
||||
114
src/lib/utils/logger.test.ts
Normal file
114
src/lib/utils/logger.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
|
||||
114
src/lib/utils/permissions.test.ts
Normal file
114
src/lib/utils/permissions.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
116
src/lib/utils/permissions.ts
Normal file
116
src/lib/utils/permissions.ts
Normal 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] ?? [];
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
29
src/routes/[orgSlug]/account/+page.server.ts
Normal file
29
src/routes/[orgSlug]/account/+page.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
485
src/routes/[orgSlug]/account/+page.svelte
Normal file
485
src/routes/[orgSlug]/account/+page.svelte
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? []}
|
||||
/>
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
191
src/routes/api/google-calendar/push/+server.ts
Normal file
191
src/routes/api/google-calendar/push/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
41
src/routes/api/release-lock/+server.ts
Normal file
41
src/routes/api/release-lock/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
|
||||
17
supabase/migrations/018_user_avatars_storage.sql
Normal file
17
supabase/migrations/018_user_avatars_storage.sql
Normal 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');
|
||||
8
supabase/migrations/019_fix_org_members_profiles_fk.sql
Normal file
8
supabase/migrations/019_fix_org_members_profiles_fk.sql
Normal 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
147
tests/e2e/app.spec.ts
Normal 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
18
tests/e2e/auth.setup.ts
Normal 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
191
tests/e2e/cleanup.ts
Normal 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
550
tests/e2e/features.spec.ts
Normal 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
41
tests/e2e/helpers.ts
Normal 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 });
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user