From 6bcdfbc34b4f61a90142d2150861958c72c79d37 Mon Sep 17 00:00:00 2001 From: jubnl Date: Mon, 25 May 2026 21:59:42 +0200 Subject: [PATCH] chore: apply prettier on the entire project --- client/eslint.config.js | 39 + client/package.json | 3 +- client/src/App.test.tsx | 343 +- client/src/App.tsx | 302 +- .../components/Admin/AddonManager.test.tsx | 73 +- client/src/components/Admin/AddonManager.tsx | 528 +- .../Admin/AdminMcpTokensPanel.test.tsx | 139 +- .../components/Admin/AdminMcpTokensPanel.tsx | 363 +- .../components/Admin/AuditLogPanel.test.tsx | 44 +- client/src/components/Admin/AuditLogPanel.tsx | 191 +- .../src/components/Admin/BackupPanel.test.tsx | 394 +- client/src/components/Admin/BackupPanel.tsx | 505 +- .../components/Admin/CategoryManager.test.tsx | 47 +- .../src/components/Admin/CategoryManager.tsx | 275 +- .../Admin/DefaultUserSettingsTab.tsx | 313 +- .../Admin/DevNotificationsPanel.test.tsx | 110 +- .../Admin/DevNotificationsPanel.tsx | 465 +- .../src/components/Admin/GitHubPanel.test.tsx | 74 +- client/src/components/Admin/GitHubPanel.tsx | 634 ++- .../Admin/PackingTemplateManager.test.tsx | 170 +- .../Admin/PackingTemplateManager.tsx | 542 +- .../Admin/PermissionsPanel.test.tsx | 57 +- .../src/components/Admin/PermissionsPanel.tsx | 142 +- .../components/Budget/BudgetPanel.test.tsx | 166 +- client/src/components/Budget/BudgetPanel.tsx | 2698 +++++++--- .../src/components/Collab/CollabChat.test.tsx | 507 +- client/src/components/Collab/CollabChat.tsx | 1464 +++-- .../components/Collab/CollabNotes.test.tsx | 1302 ++++- client/src/components/Collab/CollabNotes.tsx | 2604 ++++++--- .../components/Collab/CollabPanel.test.tsx | 178 +- client/src/components/Collab/CollabPanel.tsx | 201 +- .../components/Collab/CollabPolls.test.tsx | 81 +- client/src/components/Collab/CollabPolls.tsx | 906 +++- .../Collab/WhatsNextWidget.test.tsx | 263 +- .../src/components/Collab/WhatsNextWidget.tsx | 304 +- .../components/Dashboard/CurrencyWidget.tsx | 287 +- .../Dashboard/TimezoneWidget.test.tsx | 218 +- .../components/Dashboard/TimezoneWidget.tsx | 245 +- .../src/components/Files/FileManager.test.tsx | 34 +- client/src/components/Files/FileManager.tsx | 2170 +++++--- .../components/Journey/JournalBody.test.tsx | 2 +- client/src/components/Journey/JournalBody.tsx | 88 +- .../components/Journey/JourneyMap.test.tsx | 79 +- client/src/components/Journey/JourneyMap.tsx | 357 +- .../src/components/Journey/JourneyMapAuto.tsx | 78 +- .../src/components/Journey/JourneyMapGL.tsx | 473 +- .../Journey/MarkdownToolbar.test.tsx | 6 +- .../components/Journey/MarkdownToolbar.tsx | 93 +- .../components/Journey/MobileEntryCard.tsx | 150 +- .../components/Journey/MobileEntryView.tsx | 199 +- .../components/Journey/MobileMapTimeline.tsx | 223 +- .../components/Journey/PhotoLightbox.test.tsx | 2 +- .../src/components/Journey/PhotoLightbox.tsx | 230 +- .../src/components/Layout/BottomNav.test.tsx | 8 +- client/src/components/Layout/BottomNav.tsx | 115 +- .../src/components/Layout/DemoBanner.test.tsx | 4 +- client/src/components/Layout/DemoBanner.tsx | 323 +- .../Layout/InAppNotificationBell.test.tsx | 69 +- .../Layout/InAppNotificationBell.tsx | 262 +- .../Layout/MobileTopHeader.test.tsx | 6 +- .../src/components/Layout/MobileTopHeader.tsx | 20 +- client/src/components/Layout/Navbar.test.tsx | 24 +- client/src/components/Layout/Navbar.tsx | 507 +- .../src/components/Layout/OfflineBanner.tsx | 64 +- client/src/components/Layout/PageSidebar.tsx | 99 +- client/src/components/Map/LocationButton.tsx | 22 +- client/src/components/Map/MapView.test.tsx | 250 +- client/src/components/Map/MapView.tsx | 738 +-- client/src/components/Map/MapViewAuto.tsx | 14 +- client/src/components/Map/MapViewGL.test.tsx | 105 +- client/src/components/Map/MapViewGL.tsx | 657 +-- .../src/components/Map/ReservationOverlay.tsx | 585 +- .../Memories/MemoriesPanel.test.tsx | 237 +- .../src/components/Memories/MemoriesPanel.tsx | 2126 +++++--- .../InAppNotificationItem.test.tsx | 23 +- .../Notifications/InAppNotificationItem.tsx | 207 +- .../OAuth/ScopeGroupPicker.test.tsx | 30 +- .../src/components/OAuth/ScopeGroupPicker.tsx | 103 +- .../components/PDF/JourneyBookPDF.test.tsx | 4 +- client/src/components/PDF/JourneyBookPDF.tsx | 153 +- client/src/components/PDF/TripPDF.tsx | 607 ++- .../Packing/ApplyTemplateButton.tsx | 122 +- .../Packing/PackingListPanel.test.tsx | 193 +- .../components/Packing/PackingListPanel.tsx | 3418 ++++++++---- .../components/Photos/PhotoGallery.test.tsx | 236 +- client/src/components/Photos/PhotoGallery.tsx | 181 +- .../components/Photos/PhotoLightbox.test.tsx | 217 +- .../src/components/Photos/PhotoLightbox.tsx | 208 +- .../components/Photos/PhotoUpload.test.tsx | 186 +- client/src/components/Photos/PhotoUpload.tsx | 176 +- .../src/components/Planner/AirportSelect.tsx | 264 +- .../Planner/DayDetailPanel.test.tsx | 642 ++- .../src/components/Planner/DayDetailPanel.tsx | 1824 +++++-- .../Planner/DayPlanSidebar.test.tsx | 2432 +++++---- .../src/components/Planner/DayPlanSidebar.tsx | 4687 +++++++++++------ .../components/Planner/FileImportModal.tsx | 446 +- .../src/components/Planner/LocationSelect.tsx | 223 +- .../Planner/PlaceFormModal.test.tsx | 48 +- .../src/components/Planner/PlaceFormModal.tsx | 635 +-- .../Planner/PlaceInspector.test.tsx | 97 +- .../src/components/Planner/PlaceInspector.tsx | 1779 +++++-- .../components/Planner/PlacesSidebar.test.tsx | 84 +- .../src/components/Planner/PlacesSidebar.tsx | 1863 ++++--- .../Planner/ReservationModal.test.tsx | 238 +- .../components/Planner/ReservationModal.tsx | 982 ++-- .../Planner/ReservationsPanel.test.tsx | 115 +- .../components/Planner/ReservationsPanel.tsx | 1130 ++-- .../Planner/TransportModal.test.tsx | 47 +- .../src/components/Planner/TransportModal.tsx | 901 +++- .../src/components/Settings/AboutTab.test.tsx | 9 +- client/src/components/Settings/AboutTab.tsx | 408 +- .../components/Settings/AccountTab.test.tsx | 153 +- client/src/components/Settings/AccountTab.tsx | 765 ++- .../Settings/DisplaySettingsTab.test.tsx | 26 +- .../Settings/DisplaySettingsTab.tsx | 361 +- .../Settings/IntegrationsTab.test.tsx | 159 +- .../components/Settings/IntegrationsTab.tsx | 1176 +++-- .../Settings/MapSettingsTab.test.tsx | 41 +- .../components/Settings/MapSettingsTab.tsx | 363 +- .../src/components/Settings/MapboxPreview.tsx | 86 +- .../Settings/NotificationsTab.test.tsx | 109 +- .../components/Settings/NotificationsTab.tsx | 442 +- client/src/components/Settings/OfflineTab.tsx | 154 +- .../Settings/PhotoProvidersSection.test.tsx | 107 +- .../Settings/PhotoProvidersSection.tsx | 339 +- client/src/components/Settings/Section.tsx | 29 +- .../components/Settings/ToggleSwitch.test.tsx | 3 +- .../src/components/Settings/ToggleSwitch.tsx | 40 +- .../SystemNotices/SystemNoticeBanner.test.tsx | 8 +- .../SystemNotices/SystemNoticeBanner.tsx | 101 +- .../SystemNotices/SystemNoticeHost.tsx | 16 +- .../SystemNotices/SystemNoticeModal.test.tsx | 19 +- .../SystemNotices/SystemNoticeModal.tsx | 349 +- .../components/Todo/TodoListPanel.test.tsx | 112 +- client/src/components/Todo/TodoListPanel.tsx | 1683 ++++-- .../components/Trips/TripFormModal.test.tsx | 38 +- client/src/components/Trips/TripFormModal.tsx | 707 ++- .../Trips/TripMembersModal.test.tsx | 42 +- .../src/components/Trips/TripMembersModal.tsx | 710 ++- .../components/Vacay/VacayCalendar.test.tsx | 176 +- client/src/components/Vacay/VacayCalendar.tsx | 154 +- .../components/Vacay/VacayMonthCard.test.tsx | 162 +- .../src/components/Vacay/VacayMonthCard.tsx | 291 +- .../components/Vacay/VacayPersons.test.tsx | 244 +- client/src/components/Vacay/VacayPersons.tsx | 349 +- .../components/Vacay/VacaySettings.test.tsx | 416 +- client/src/components/Vacay/VacaySettings.tsx | 624 ++- .../src/components/Vacay/VacayStats.test.tsx | 176 +- client/src/components/Vacay/VacayStats.tsx | 152 +- .../components/Weather/WeatherWidget.test.tsx | 178 +- .../src/components/Weather/WeatherWidget.tsx | 165 +- .../components/shared/ConfirmDialog.test.tsx | 10 +- .../src/components/shared/ConfirmDialog.tsx | 65 +- .../components/shared/ContextMenu.test.tsx | 8 +- client/src/components/shared/ContextMenu.tsx | 154 +- client/src/components/shared/CopyButton.tsx | 77 +- .../src/components/shared/CopyTripDialog.tsx | 78 +- .../shared/CustomDateTimePicker.test.tsx | 20 +- .../shared/CustomDateTimePicker.tsx | 504 +- .../components/shared/CustomSelect.test.tsx | 6 +- client/src/components/shared/CustomSelect.tsx | 424 +- .../shared/CustomTimePicker.test.tsx | 46 +- .../components/shared/CustomTimePicker.tsx | 477 +- client/src/components/shared/LoadingImage.tsx | 30 +- client/src/components/shared/Modal.test.tsx | 32 +- client/src/components/shared/Modal.tsx | 95 +- .../components/shared/PlaceAvatar.test.tsx | 8 +- client/src/components/shared/PlaceAvatar.tsx | 139 +- client/src/components/shared/Skeleton.tsx | 36 +- client/src/components/shared/SlidingTabs.tsx | 123 +- client/src/components/shared/Toast.test.tsx | 3 +- client/src/components/shared/Toast.tsx | 145 +- client/src/components/shared/Tooltip.tsx | 189 +- client/src/i18n/TranslationContext.tsx | 168 +- client/src/index.css | 885 +++- client/src/main.tsx | 18 +- client/src/pages/AdminPage.test.tsx | 100 +- client/src/pages/AdminPage.tsx | 3173 ++++++----- client/src/pages/AtlasPage.test.tsx | 395 +- client/src/pages/AtlasPage.tsx | 2844 ++++++---- client/src/pages/DashboardPage.test.tsx | 103 +- client/src/pages/DashboardPage.tsx | 2190 +++++--- client/src/pages/FilesPage.test.tsx | 28 +- client/src/pages/FilesPage.tsx | 102 +- client/src/pages/ForgotPasswordPage.tsx | 252 +- .../src/pages/InAppNotificationsPage.test.tsx | 65 +- client/src/pages/InAppNotificationsPage.tsx | 119 +- client/src/pages/JourneyDetailPage.test.tsx | 628 ++- client/src/pages/JourneyDetailPage.tsx | 4156 +++++++++------ client/src/pages/JourneyPage.test.tsx | 101 +- client/src/pages/JourneyPage.tsx | 594 ++- client/src/pages/JourneyPublicPage.test.tsx | 194 +- client/src/pages/JourneyPublicPage.tsx | 812 ++- .../pages/LoginPage.oidc-redirect.test.tsx | 17 +- client/src/pages/LoginPage.test.tsx | 36 +- client/src/pages/LoginPage.tsx | 1553 ++++-- client/src/pages/OAuthAuthorizePage.test.tsx | 39 +- client/src/pages/OAuthAuthorizePage.tsx | 547 +- client/src/pages/PhotosPage.test.tsx | 43 +- client/src/pages/PhotosPage.tsx | 99 +- client/src/pages/RegisterPage.test.tsx | 10 +- client/src/pages/RegisterPage.tsx | 130 +- client/src/pages/ResetPasswordPage.tsx | 371 +- client/src/pages/SettingsPage.test.tsx | 12 +- client/src/pages/SettingsPage.tsx | 90 +- client/src/pages/SharedTripPage.test.tsx | 58 +- client/src/pages/SharedTripPage.tsx | 1166 +++- client/src/pages/TripPlannerPage.test.tsx | 259 +- client/src/pages/TripPlannerPage.tsx | 2327 +++++--- client/src/pages/VacayPage.test.tsx | 123 +- client/src/pages/VacayPage.tsx | 488 +- docker-compose.yml | 2 +- hooks/install.sh | 12 + hooks/pre-commit | 13 + hooks/pre-push.disabled | 5 + package-lock.json | 309 ++ server/eslint.config.mjs | 41 + server/package.json | 9 +- server/src/addons.ts | 2 +- server/src/app.ts | 352 +- server/src/config.ts | 14 +- server/src/db/database.ts | 67 +- server/src/db/migrations.ts | 962 +++- server/src/db/seeds.ts | 221 +- server/src/demo/demo-reset.ts | 54 +- server/src/demo/demo-seed.ts | 726 ++- server/src/index.ts | 46 +- server/src/mcp/index.ts | 119 +- server/src/mcp/oauthProvider.ts | 341 +- server/src/mcp/resources.ts | 532 +- server/src/mcp/scopes.ts | 216 +- server/src/mcp/sessionManager.ts | 24 +- server/src/mcp/tools.ts | 32 +- server/src/mcp/tools/assignments.ts | 337 +- server/src/mcp/tools/atlas.ts | 202 +- server/src/mcp/tools/budget.ts | 329 +- server/src/mcp/tools/collab.ts | 221 +- server/src/mcp/tools/days.ts | 188 +- server/src/mcp/tools/journey.ts | 771 +-- server/src/mcp/tools/mapsWeather.ts | 268 +- server/src/mcp/tools/notifications.ts | 177 +- server/src/mcp/tools/packing.ts | 605 ++- server/src/mcp/tools/places.ts | 640 ++- server/src/mcp/tools/prompts.ts | 193 +- server/src/mcp/tools/reservations.ts | 264 +- server/src/mcp/tools/tags.ts | 148 +- server/src/mcp/tools/todos.ts | 359 +- server/src/mcp/tools/transports.ts | 189 +- server/src/mcp/tools/trips.ts | 644 ++- server/src/mcp/tools/vacay.ts | 767 +-- server/src/middleware/auth.ts | 17 +- server/src/middleware/idempotency.ts | 13 +- server/src/middleware/mfaPolicy.ts | 9 +- server/src/middleware/tripAccess.ts | 3 +- server/src/nest/app.module.ts | 6 +- server/src/nest/auth/admin.guard.ts | 5 +- .../src/nest/auth/current-user.decorator.ts | 10 +- server/src/nest/auth/jwt-auth.guard.ts | 5 +- .../src/nest/common/trek-exception.filter.ts | 7 +- server/src/nest/common/zod-validation.pipe.ts | 5 +- server/src/nest/database/database.module.ts | 2 +- server/src/nest/database/database.service.ts | 5 +- server/src/nest/health/health.controller.ts | 9 +- server/src/nest/health/health.service.ts | 2 +- server/src/nest/strangler.ts | 5 +- server/src/nest/weather/weather.controller.ts | 6 +- server/src/nest/weather/weather.module.ts | 2 +- server/src/nest/weather/weather.service.ts | 6 +- server/src/routes/admin.ts | 59 +- server/src/routes/airports.ts | 3 +- server/src/routes/assignments.ts | 208 +- server/src/routes/atlas.ts | 7 +- server/src/routes/auth.ts | 130 +- server/src/routes/backup.ts | 9 +- server/src/routes/budget.ts | 78 +- server/src/routes/categories.ts | 11 +- server/src/routes/collab.ts | 203 +- server/src/routes/dayNotes.ts | 29 +- server/src/routes/days.ts | 96 +- server/src/routes/files.ts | 86 +- server/src/routes/journey.ts | 101 +- server/src/routes/journeyPublic.ts | 29 +- server/src/routes/maps.ts | 30 +- server/src/routes/memories/immich.ts | 23 +- server/src/routes/memories/synology.ts | 220 +- server/src/routes/memories/unified.ts | 122 +- server/src/routes/notifications.ts | 25 +- server/src/routes/oauth.ts | 180 +- server/src/routes/oidc.ts | 7 +- server/src/routes/packing.ts | 83 +- server/src/routes/photos.ts | 7 +- server/src/routes/places.ts | 311 +- server/src/routes/publicConfig.ts | 3 +- server/src/routes/reservations.ts | 204 +- server/src/routes/settings.ts | 8 +- server/src/routes/share.ts | 31 +- server/src/routes/systemNotices.ts | 7 +- server/src/routes/tags.ts | 5 +- server/src/routes/todo.ts | 34 +- server/src/routes/trips.ts | 108 +- server/src/routes/vacay.ts | 24 +- server/src/scheduler.ts | 304 +- server/src/services/adminService.ts | 415 +- server/src/services/airportService.ts | 70 +- server/src/services/apiKeyCrypto.ts | 4 +- server/src/services/assignmentService.ts | 131 +- server/src/services/atlasService.ts | 1045 +++- server/src/services/auditLog.ts | 31 +- server/src/services/authService.ts | 942 +++- server/src/services/backupService.ts | 63 +- server/src/services/budgetService.ts | 229 +- server/src/services/categoryService.ts | 12 +- server/src/services/collabService.ts | 290 +- server/src/services/cookie.ts | 3 +- server/src/services/dayNoteService.ts | 37 +- server/src/services/dayService.ts | 185 +- server/src/services/demo.ts | 5 +- server/src/services/fileService.ts | 153 +- server/src/services/inAppNotifications.ts | 118 +- server/src/services/journeyService.ts | 725 ++- server/src/services/journeyShareService.ts | 85 +- server/src/services/kmlImport.ts | 10 +- server/src/services/mapsService.ts | 404 +- .../src/services/memories/helpersService.ts | 363 +- server/src/services/memories/immichService.ts | 150 +- .../services/memories/photoResolverService.ts | 114 +- .../src/services/memories/synologyService.ts | 1231 +++-- .../src/services/memories/thumbnailService.ts | 56 +- .../src/services/memories/trekPhotoCache.ts | 29 +- .../src/services/memories/unifiedService.ts | 160 +- server/src/services/mfaCrypto.ts | 3 +- .../notificationPreferencesService.ts | 99 +- server/src/services/notificationService.ts | 259 +- server/src/services/notifications.ts | 1027 +++- server/src/services/oauthService.ts | 322 +- server/src/services/oidcService.ts | 72 +- server/src/services/packingService.ts | 185 +- server/src/services/passwordPolicy.ts | 39 +- server/src/services/permissions.ts | 47 +- server/src/services/placePhotoCache.ts | 40 +- server/src/services/placeService.ts | 278 +- server/src/services/queryHelpers.ts | 34 +- server/src/services/reservationService.ts | 331 +- server/src/services/settingsService.ts | 30 +- server/src/services/shareService.ts | 131 +- server/src/services/tagService.ts | 13 +- server/src/services/todoService.ts | 82 +- server/src/services/tripService.ts | 376 +- server/src/services/vacayService.ts | 549 +- server/src/services/weatherService.ts | 229 +- server/src/systemNotices/conditions.ts | 10 +- server/src/systemNotices/registry.ts | 42 +- server/src/systemNotices/service.ts | 58 +- server/src/systemNotices/types.ts | 11 +- server/src/utils/ssrfGuard.ts | 17 +- server/src/websocket.ts | 30 +- server/tests/e2e/harness.ts | 13 +- server/tests/e2e/weather.e2e.test.ts | 29 +- server/tests/helpers/auth.ts | 8 +- server/tests/helpers/factories.ts | 410 +- server/tests/helpers/mcp-harness.ts | 23 +- server/tests/helpers/test-db.ts | 140 +- server/tests/helpers/ws-client.ts | 31 +- server/tests/integration/admin.test.ts | 374 +- server/tests/integration/assignments.test.ts | 44 +- server/tests/integration/atlas.test.ts | 126 +- server/tests/integration/auth.test.ts | 369 +- server/tests/integration/backup.test.ts | 120 +- server/tests/integration/budget.test.ts | 92 +- server/tests/integration/categories.test.ts | 49 +- server/tests/integration/collab.test.ts | 81 +- server/tests/integration/dayNotes.test.ts | 48 +- server/tests/integration/days.test.ts | 131 +- server/tests/integration/files.test.ts | 81 +- server/tests/integration/immich.test.ts | 134 +- server/tests/integration/journey.test.ts | 215 +- server/tests/integration/maps.test.ts | 119 +- server/tests/integration/mcp.test.ts | 114 +- .../tests/integration/memories-immich.test.ts | 251 +- .../integration/memories-synology.test.ts | 620 ++- .../integration/memories-unified.test.ts | 93 +- server/tests/integration/misc.test.ts | 61 +- .../tests/integration/notifications.test.ts | 189 +- server/tests/integration/oauth.test.ts | 2250 ++++---- server/tests/integration/oidc.test.ts | 33 +- server/tests/integration/packing.test.ts | 68 +- server/tests/integration/places.test.ts | 129 +- server/tests/integration/profile.test.ts | 98 +- server/tests/integration/reservations.test.ts | 89 +- server/tests/integration/security.test.ts | 70 +- server/tests/integration/settings.test.ts | 52 +- server/tests/integration/share.test.ts | 126 +- .../tests/integration/systemNotices.test.ts | 112 +- server/tests/integration/tags.test.ts | 61 +- server/tests/integration/todo.test.ts | 67 +- server/tests/integration/trips.test.ts | 299 +- server/tests/integration/vacay.test.ts | 111 +- server/tests/parity/parity.ts | 2 +- server/tests/unit/mcp/resources.test.ts | 55 +- server/tests/unit/mcp/scopes.test.ts | 3 +- server/tests/unit/mcp/sessionManager.test.ts | 15 +- .../tests/unit/mcp/tools-addon-gating.test.ts | 118 +- ...ols-assignments-reservations-extra.test.ts | 48 +- .../tests/unit/mcp/tools-assignments.test.ts | 91 +- .../unit/mcp/tools-atlas-expanded.test.ts | 74 +- server/tests/unit/mcp/tools-atlas.test.ts | 38 +- .../unit/mcp/tools-budget-advanced.test.ts | 38 +- server/tests/unit/mcp/tools-budget.test.ts | 77 +- .../unit/mcp/tools-collab-polls-chat.test.ts | 113 +- .../mcp/tools-days-accommodations.test.ts | 35 +- server/tests/unit/mcp/tools-days.test.ts | 34 +- server/tests/unit/mcp/tools-notes.test.ts | 122 +- .../unit/mcp/tools-notifications.test.ts | 60 +- .../unit/mcp/tools-packing-advanced.test.ts | 48 +- server/tests/unit/mcp/tools-packing.test.ts | 79 +- server/tests/unit/mcp/tools-places.test.ts | 101 +- server/tests/unit/mcp/tools-prompts.test.ts | 42 +- .../tests/unit/mcp/tools-reservations.test.ts | 151 +- .../unit/mcp/tools-tags-maps-weather.test.ts | 34 +- server/tests/unit/mcp/tools-todos.test.ts | 63 +- .../tests/unit/mcp/tools-trip-members.test.ts | 52 +- server/tests/unit/mcp/tools-trips.test.ts | 129 +- server/tests/unit/mcp/tools-vacay.test.ts | 47 +- server/tests/unit/middleware/auth.test.ts | 28 +- .../tests/unit/middleware/idempotency.test.ts | 26 +- .../tests/unit/middleware/mfaPolicy.test.ts | 4 +- .../tests/unit/middleware/tripAccess.test.ts | 6 +- server/tests/unit/middleware/validate.test.ts | 3 +- server/tests/unit/nest/auth-guard.test.ts | 5 +- .../tests/unit/nest/database-service.test.ts | 3 +- .../tests/unit/nest/exception-filter.test.ts | 5 +- server/tests/unit/nest/health.di.test.ts | 12 +- server/tests/unit/nest/strangler.test.ts | 3 +- .../unit/nest/weather.controller.test.ts | 7 +- server/tests/unit/nest/wiring.test.ts | 11 +- server/tests/unit/nest/zod-pipe.test.ts | 5 +- server/tests/unit/scheduler.test.ts | 50 +- .../tests/unit/services/adminService.test.ts | 125 +- .../tests/unit/services/apiKeyCrypto.test.ts | 4 +- .../tests/unit/services/atlasService.test.ts | 137 +- server/tests/unit/services/auditLog.test.ts | 16 +- .../tests/unit/services/authService.test.ts | 29 +- .../tests/unit/services/authServiceDb.test.ts | 80 +- .../tests/unit/services/backupService.test.ts | 137 +- .../tests/unit/services/budgetService.test.ts | 54 +- .../unit/services/categoryService.test.ts | 24 +- .../tests/unit/services/collabService.test.ts | 98 +- server/tests/unit/services/cookie.test.ts | 4 +- server/tests/unit/services/dayService.test.ts | 121 +- .../services/inAppNotificationActions.test.ts | 4 +- .../services/inAppNotificationPrefs.test.ts | 41 +- .../unit/services/journeyService.test.ts | 345 +- .../unit/services/journeyShareService.test.ts | 67 +- .../unit/services/kmlImportUtils.test.ts | 3 +- server/tests/unit/services/kmzUnpack.test.ts | 8 +- .../tests/unit/services/mapsService.test.ts | 897 ++-- .../unit/services/memoriesHelpers.test.ts | 33 +- .../unit/services/memoriesUnified.test.ts | 52 +- server/tests/unit/services/mfaCrypto.test.ts | 4 +- server/tests/unit/services/migration.test.ts | 91 +- .../notificationPreferencesService.test.ts | 96 +- .../unit/services/notificationService.test.ts | 236 +- .../tests/unit/services/notifications.test.ts | 59 +- .../tests/unit/services/oauthService.test.ts | 204 +- .../tests/unit/services/oidcService.test.ts | 205 +- .../unit/services/packingService.test.ts | 66 +- .../unit/services/passwordPolicy.test.ts | 3 +- .../tests/unit/services/permissions.test.ts | 10 +- .../tests/unit/services/placeService.test.ts | 127 +- .../tests/unit/services/queryHelpers.test.ts | 14 +- .../unit/services/settingsService.test.ts | 30 +- server/tests/unit/services/tagService.test.ts | 12 +- .../tests/unit/services/todoService.test.ts | 42 +- .../unit/services/trimUserWhitespace.test.ts | 5 +- .../tests/unit/services/tripService.test.ts | 104 +- .../tests/unit/services/vacayService.test.ts | 145 +- .../unit/services/versionNotification.test.ts | 41 +- .../unit/services/weatherService.test.ts | 26 +- server/tests/unit/shared-contract.test.ts | 3 +- .../unit/systemNotices/conditions.test.ts | 16 +- .../tests/unit/systemNotices/registry.test.ts | 20 +- .../unit/systemNotices/versionRange.test.ts | 3 +- server/tests/unit/utils/ssrfGuard.test.ts | 7 +- server/tests/websocket/connection.test.ts | 111 +- shared/eslint.config.mjs | 28 + shared/package.json | 3 +- shared/src/common/primitives.schema.spec.ts | 19 +- shared/src/weather/weather.schema.spec.ts | 60 +- 488 files changed, 82986 insertions(+), 45830 deletions(-) create mode 100644 client/eslint.config.js create mode 100755 hooks/install.sh create mode 100755 hooks/pre-commit create mode 100755 hooks/pre-push.disabled create mode 100644 server/eslint.config.mjs create mode 100644 shared/eslint.config.mjs diff --git a/client/eslint.config.js b/client/eslint.config.js new file mode 100644 index 00000000..dd266b8a --- /dev/null +++ b/client/eslint.config.js @@ -0,0 +1,39 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' +import gitignore from 'eslint-config-flat-gitignore' + +export default defineConfig([ + gitignore({ strict: false }), + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + }, + // Route files always export both `Route` (non-component) and the page component — expected pattern. + { + files: ['src/routes/**/*.{ts,tsx}'], + rules: { + 'react-refresh/only-export-components': 'off', + }, + }, + // shadcn UI primitives export variant helpers alongside components — generated files, don't modify. + // ThemeProvider exports both the provider component and the useTheme hook — standard pattern. + { + files: ['src/components/ui/**/*.{ts,tsx}', 'src/components/theme/ThemeProvider.tsx'], + rules: { + 'react-refresh/only-export-components': 'off', + }, + }, +]) diff --git a/client/package.json b/client/package.json index 3a99ea90..3ef7d833 100644 --- a/client/package.json +++ b/client/package.json @@ -70,6 +70,7 @@ "eslint": "^10.2.1", "eslint-config-flat-gitignore": "^2.3.0", "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.2" + "eslint-plugin-react-refresh": "^0.5.2", + "typescript-eslint": "^8.58.2" } } diff --git a/client/src/App.test.tsx b/client/src/App.test.tsx index 9062f793..95281750 100644 --- a/client/src/App.test.tsx +++ b/client/src/App.test.tsx @@ -1,31 +1,30 @@ -import React from 'react' -import { render, screen, waitFor } from '@testing-library/react' -import { MemoryRouter } from 'react-router-dom' -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { http, HttpResponse } from 'msw' -import { server } from '../tests/helpers/msw/server' -import { useAuthStore } from './store/authStore' -import { useSettingsStore } from './store/settingsStore' -import { resetAllStores } from '../tests/helpers/store' -import { buildUser, buildSettings } from '../tests/helpers/factories' -import App from './App' +import { render, screen, waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { buildSettings, buildUser } from '../tests/helpers/factories'; +import { server } from '../tests/helpers/msw/server'; +import { resetAllStores } from '../tests/helpers/store'; +import App from './App'; +import { useAuthStore } from './store/authStore'; +import { useSettingsStore } from './store/settingsStore'; // ── Mock page components ─────────────────────────────────────────────────────── -vi.mock('./pages/LoginPage', () => ({ default: () =>
Login
})) -vi.mock('./pages/DashboardPage', () => ({ default: () =>
Dashboard
})) -vi.mock('./pages/TripPlannerPage', () => ({ default: () =>
TripPlanner
})) -vi.mock('./pages/FilesPage', () => ({ default: () =>
Files
})) -vi.mock('./pages/AdminPage', () => ({ default: () =>
Admin
})) -vi.mock('./pages/SettingsPage', () => ({ default: () =>
Settings
})) -vi.mock('./pages/VacayPage', () => ({ default: () =>
Vacay
})) -vi.mock('./pages/AtlasPage', () => ({ default: () =>
Atlas
})) -vi.mock('./pages/SharedTripPage', () => ({ default: () =>
SharedTrip
})) -vi.mock('./pages/InAppNotificationsPage.tsx', () => ({ default: () =>
Notifications
})) +vi.mock('./pages/LoginPage', () => ({ default: () =>
Login
})); +vi.mock('./pages/DashboardPage', () => ({ default: () =>
Dashboard
})); +vi.mock('./pages/TripPlannerPage', () => ({ default: () =>
TripPlanner
})); +vi.mock('./pages/FilesPage', () => ({ default: () =>
Files
})); +vi.mock('./pages/AdminPage', () => ({ default: () =>
Admin
})); +vi.mock('./pages/SettingsPage', () => ({ default: () =>
Settings
})); +vi.mock('./pages/VacayPage', () => ({ default: () =>
Vacay
})); +vi.mock('./pages/AtlasPage', () => ({ default: () =>
Atlas
})); +vi.mock('./pages/SharedTripPage', () => ({ default: () =>
SharedTrip
})); +vi.mock('./pages/InAppNotificationsPage.tsx', () => ({ default: () =>
Notifications
})); // Prevent WebSocket side effects from the notification listener vi.mock('./hooks/useInAppNotificationListener.ts', () => ({ useInAppNotificationListener: vi.fn(), -})) +})); // ── Helpers ──────────────────────────────────────────────────────────────────── @@ -34,7 +33,7 @@ function renderApp(initialPath = '/') { - ) + ); } /** @@ -49,64 +48,64 @@ function seedAuth(overrides: Record = {}) { appRequireMfa: false, loadUser: vi.fn().mockResolvedValue(undefined), ...overrides, - }) + }); } beforeEach(() => { - resetAllStores() - vi.clearAllMocks() - document.documentElement.classList.remove('dark') -}) + resetAllStores(); + vi.clearAllMocks(); + document.documentElement.classList.remove('dark'); +}); // ── RootRedirect ─────────────────────────────────────────────────────────────── describe('RootRedirect', () => { it('FE-COMP-APP-001: / redirects to /login when not authenticated', async () => { - seedAuth({ isAuthenticated: false }) - renderApp('/') - await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument()) - }) + seedAuth({ isAuthenticated: false }); + renderApp('/'); + await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument()); + }); it('FE-COMP-APP-002: / redirects to /dashboard when authenticated', async () => { - seedAuth({ isAuthenticated: true, user: buildUser() }) - renderApp('/') - await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument()) - }) + seedAuth({ isAuthenticated: true, user: buildUser() }); + renderApp('/'); + await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument()); + }); it('FE-COMP-APP-003: / shows loading spinner while auth is loading', () => { - seedAuth({ isLoading: true, isAuthenticated: false }) - renderApp('/') - expect(document.querySelector('.animate-spin')).toBeInTheDocument() - expect(screen.queryByText('Login')).not.toBeInTheDocument() - }) -}) + seedAuth({ isLoading: true, isAuthenticated: false }); + renderApp('/'); + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + expect(screen.queryByText('Login')).not.toBeInTheDocument(); + }); +}); // ── ProtectedRoute — unauthenticated ────────────────────────────────────────── describe('ProtectedRoute — unauthenticated', () => { it('FE-COMP-APP-004: /dashboard redirects to /login with redirect param when not authenticated', async () => { - seedAuth({ isAuthenticated: false }) - renderApp('/dashboard') - await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument()) - }) + seedAuth({ isAuthenticated: false }); + renderApp('/dashboard'); + await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument()); + }); it('FE-COMP-APP-005: /trips/42 redirects to /login when not authenticated', async () => { - seedAuth({ isAuthenticated: false }) - renderApp('/trips/42') - await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument()) - }) -}) + seedAuth({ isAuthenticated: false }); + renderApp('/trips/42'); + await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument()); + }); +}); // ── ProtectedRoute — loading ─────────────────────────────────────────────────── describe('ProtectedRoute — loading state', () => { it('FE-COMP-APP-006: protected route shows loading spinner while isLoading is true', () => { - seedAuth({ isLoading: true, isAuthenticated: false }) - renderApp('/dashboard') - expect(document.querySelector('.animate-spin')).toBeInTheDocument() - expect(screen.queryByText('Dashboard')).not.toBeInTheDocument() - }) -}) + seedAuth({ isLoading: true, isAuthenticated: false }); + renderApp('/dashboard'); + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + expect(screen.queryByText('Dashboard')).not.toBeInTheDocument(); + }); +}); // ── ProtectedRoute — MFA enforcement ────────────────────────────────────────── @@ -116,32 +115,32 @@ describe('ProtectedRoute — MFA enforcement', () => { isAuthenticated: true, appRequireMfa: true, user: buildUser({ mfa_enabled: false }), - }) - renderApp('/dashboard') - await waitFor(() => expect(screen.getByText('Settings')).toBeInTheDocument()) - }) + }); + renderApp('/dashboard'); + await waitFor(() => expect(screen.getByText('Settings')).toBeInTheDocument()); + }); it('FE-COMP-APP-008: does NOT redirect when already on /settings even with MFA required', async () => { seedAuth({ isAuthenticated: true, appRequireMfa: true, user: buildUser({ mfa_enabled: false }), - }) - renderApp('/settings') - await waitFor(() => expect(screen.getByText('Settings')).toBeInTheDocument()) - expect(screen.queryByText('Login')).not.toBeInTheDocument() - }) + }); + renderApp('/settings'); + await waitFor(() => expect(screen.getByText('Settings')).toBeInTheDocument()); + expect(screen.queryByText('Login')).not.toBeInTheDocument(); + }); it('FE-COMP-APP-009: does NOT redirect when user has MFA enabled', async () => { seedAuth({ isAuthenticated: true, appRequireMfa: true, user: buildUser({ mfa_enabled: true }), - }) - renderApp('/dashboard') - await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument()) - }) -}) + }); + renderApp('/dashboard'); + await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument()); + }); +}); // ── ProtectedRoute — admin role ──────────────────────────────────────────────── @@ -150,173 +149,153 @@ describe('ProtectedRoute — admin role check', () => { seedAuth({ isAuthenticated: true, user: buildUser({ role: 'user' }), - }) - renderApp('/admin') - await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument()) - expect(screen.queryByText('Admin')).not.toBeInTheDocument() - }) + }); + renderApp('/admin'); + await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument()); + expect(screen.queryByText('Admin')).not.toBeInTheDocument(); + }); it('FE-COMP-APP-011: /admin is accessible for admin user', async () => { seedAuth({ isAuthenticated: true, user: buildUser({ role: 'admin' }), - }) - renderApp('/admin') - await waitFor(() => expect(screen.getByText('Admin')).toBeInTheDocument()) - }) -}) + }); + renderApp('/admin'); + await waitFor(() => expect(screen.getByText('Admin')).toBeInTheDocument()); + }); +}); // ── Public routes ────────────────────────────────────────────────────────────── describe('Public routes', () => { it('FE-COMP-APP-012: /login is accessible without authentication', async () => { - seedAuth({ isAuthenticated: false }) - renderApp('/login') - expect(screen.getByText('Login')).toBeInTheDocument() - }) + seedAuth({ isAuthenticated: false }); + renderApp('/login'); + expect(screen.getByText('Login')).toBeInTheDocument(); + }); it('FE-COMP-APP-013: /shared/:token is accessible without authentication', async () => { - seedAuth({ isAuthenticated: false }) - renderApp('/shared/sometoken') - expect(screen.getByText('SharedTrip')).toBeInTheDocument() - }) + seedAuth({ isAuthenticated: false }); + renderApp('/shared/sometoken'); + expect(screen.getByText('SharedTrip')).toBeInTheDocument(); + }); it('FE-COMP-APP-014: unknown routes redirect to / which then redirects to /login', async () => { - seedAuth({ isAuthenticated: false }) - renderApp('/does-not-exist') - await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument()) - }) -}) + seedAuth({ isAuthenticated: false }); + renderApp('/does-not-exist'); + await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument()); + }); +}); // ── App — on-mount effects ───────────────────────────────────────────────────── describe('App — on-mount effects', () => { it('FE-COMP-APP-015: loadUser is called on mount for non-shared paths', async () => { - const loadUser = vi.fn().mockResolvedValue(undefined) - useAuthStore.setState({ isLoading: false, isAuthenticated: false, loadUser }) - renderApp('/dashboard') - expect(loadUser).toHaveBeenCalled() - }) + const loadUser = vi.fn().mockResolvedValue(undefined); + useAuthStore.setState({ isLoading: false, isAuthenticated: false, loadUser }); + renderApp('/dashboard'); + expect(loadUser).toHaveBeenCalled(); + }); it('FE-COMP-APP-016: loadUser is NOT called on /shared/ paths', async () => { - const loadUser = vi.fn().mockResolvedValue(undefined) - useAuthStore.setState({ isLoading: false, isAuthenticated: false, loadUser }) - renderApp('/shared/token123') - expect(loadUser).not.toHaveBeenCalled() - }) + const loadUser = vi.fn().mockResolvedValue(undefined); + useAuthStore.setState({ isLoading: false, isAuthenticated: false, loadUser }); + renderApp('/shared/token123'); + expect(loadUser).not.toHaveBeenCalled(); + }); it('FE-COMP-APP-017: GET /api/auth/app-config is called on mount', async () => { - let configCalled = false + let configCalled = false; server.use( http.get('/api/auth/app-config', () => { - configCalled = true - return HttpResponse.json({}) + configCalled = true; + return HttpResponse.json({}); }) - ) - seedAuth() - renderApp('/') - await waitFor(() => expect(configCalled).toBe(true)) - }) + ); + seedAuth(); + renderApp('/'); + await waitFor(() => expect(configCalled).toBe(true)); + }); it('FE-COMP-APP-018: setDemoMode(true) is called when config returns demo_mode: true', async () => { - server.use( - http.get('/api/auth/app-config', () => HttpResponse.json({ demo_mode: true })) - ) - const setDemoMode = vi.fn() + server.use(http.get('/api/auth/app-config', () => HttpResponse.json({ demo_mode: true }))); + const setDemoMode = vi.fn(); useAuthStore.setState({ isLoading: false, isAuthenticated: false, loadUser: vi.fn().mockResolvedValue(undefined), setDemoMode, - }) - renderApp('/') - await waitFor(() => expect(setDemoMode).toHaveBeenCalledWith(true)) - }) + }); + renderApp('/'); + await waitFor(() => expect(setDemoMode).toHaveBeenCalledWith(true)); + }); it('FE-COMP-APP-019: loadSettings is called once the user is authenticated', async () => { - const loadSettings = vi.fn().mockResolvedValue(undefined) - seedAuth({ isAuthenticated: true, user: buildUser() }) - useSettingsStore.setState({ loadSettings }) - renderApp('/dashboard') - await waitFor(() => expect(loadSettings).toHaveBeenCalled()) - }) -}) + const loadSettings = vi.fn().mockResolvedValue(undefined); + seedAuth({ isAuthenticated: true, user: buildUser() }); + useSettingsStore.setState({ loadSettings }); + renderApp('/dashboard'); + await waitFor(() => expect(loadSettings).toHaveBeenCalled()); + }); +}); // ── Dark mode effects ────────────────────────────────────────────────────────── describe('Dark mode effects', () => { it('FE-COMP-APP-020: adds dark class to documentElement when dark_mode is true', async () => { - seedAuth({ isAuthenticated: true, user: buildUser() }) - useSettingsStore.setState({ settings: buildSettings({ dark_mode: true }) }) - renderApp('/dashboard') - await waitFor(() => - expect(document.documentElement.classList.contains('dark')).toBe(true) - ) - }) + seedAuth({ isAuthenticated: true, user: buildUser() }); + useSettingsStore.setState({ settings: buildSettings({ dark_mode: true }) }); + renderApp('/dashboard'); + await waitFor(() => expect(document.documentElement.classList.contains('dark')).toBe(true)); + }); it('FE-COMP-APP-021: removes dark class when dark_mode is false', async () => { - document.documentElement.classList.add('dark') - seedAuth({ isAuthenticated: true, user: buildUser() }) - useSettingsStore.setState({ settings: buildSettings({ dark_mode: false }) }) - renderApp('/dashboard') - await waitFor(() => - expect(document.documentElement.classList.contains('dark')).toBe(false) - ) - }) + document.documentElement.classList.add('dark'); + seedAuth({ isAuthenticated: true, user: buildUser() }); + useSettingsStore.setState({ settings: buildSettings({ dark_mode: false }) }); + renderApp('/dashboard'); + await waitFor(() => expect(document.documentElement.classList.contains('dark')).toBe(false)); + }); it('FE-COMP-APP-022: forces light mode on /shared/ path even when dark_mode is true', async () => { - document.documentElement.classList.add('dark') - useSettingsStore.setState({ settings: buildSettings({ dark_mode: true }) }) - seedAuth({ isAuthenticated: false, loadUser: vi.fn().mockResolvedValue(undefined) }) - renderApp('/shared/tok') - await waitFor(() => - expect(document.documentElement.classList.contains('dark')).toBe(false) - ) - }) + document.documentElement.classList.add('dark'); + useSettingsStore.setState({ settings: buildSettings({ dark_mode: true }) }); + seedAuth({ isAuthenticated: false, loadUser: vi.fn().mockResolvedValue(undefined) }); + renderApp('/shared/tok'); + await waitFor(() => expect(document.documentElement.classList.contains('dark')).toBe(false)); + }); it('FE-COMP-APP-023: auto mode applies dark based on matchMedia result', async () => { // matchMedia stub returns matches: false by default (from setup.ts) - seedAuth({ isAuthenticated: true, user: buildUser() }) - useSettingsStore.setState({ settings: buildSettings({ dark_mode: 'auto' as any }) }) - renderApp('/dashboard') + seedAuth({ isAuthenticated: true, user: buildUser() }); + useSettingsStore.setState({ settings: buildSettings({ dark_mode: 'auto' as any }) }); + renderApp('/dashboard'); // With matches: false, dark should NOT be added - await waitFor(() => - expect(document.documentElement.classList.contains('dark')).toBe(false) - ) - }) -}) + await waitFor(() => expect(document.documentElement.classList.contains('dark')).toBe(false)); + }); +}); // ── Version cache-busting ────────────────────────────────────────────────────── describe('Version cache-busting', () => { it('FE-COMP-APP-024: stores version in localStorage when config returns a version', async () => { - server.use( - http.get('/api/auth/app-config', () => - HttpResponse.json({ version: '2.9.10' }) - ) - ) - seedAuth() - renderApp('/') - await waitFor(() => - expect(localStorage.getItem('trek_app_version')).toBe('2.9.10') - ) - }) + server.use(http.get('/api/auth/app-config', () => HttpResponse.json({ version: '2.9.10' }))); + seedAuth(); + renderApp('/'); + await waitFor(() => expect(localStorage.getItem('trek_app_version')).toBe('2.9.10')); + }); it('FE-COMP-APP-025: calls window.location.reload() when version changes', async () => { - localStorage.setItem('trek_app_version', '2.9.9') - const reload = vi.fn() + localStorage.setItem('trek_app_version', '2.9.9'); + const reload = vi.fn(); Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload }, - }) + }); - server.use( - http.get('/api/auth/app-config', () => - HttpResponse.json({ version: '2.9.10' }) - ) - ) - seedAuth() - renderApp('/') - await waitFor(() => expect(reload).toHaveBeenCalled()) - }) -}) + server.use(http.get('/api/auth/app-config', () => HttpResponse.json({ version: '2.9.10' }))); + seedAuth(); + renderApp('/'); + await waitFor(() => expect(reload).toHaveBeenCalled()); + }); +}); diff --git a/client/src/App.tsx b/client/src/App.tsx index efe22501..dfe0d6cb 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,208 +1,242 @@ -import React, { useEffect, ReactNode } from 'react' -import { Routes, Route, Navigate, useLocation } from 'react-router-dom' -import { useAuthStore } from './store/authStore' -import { useSettingsStore } from './store/settingsStore' -import { useAddonStore } from './store/addonStore' -import LoginPage from './pages/LoginPage' -import ForgotPasswordPage from './pages/ForgotPasswordPage' -import ResetPasswordPage from './pages/ResetPasswordPage' -import DashboardPage from './pages/DashboardPage' -import TripPlannerPage from './pages/TripPlannerPage' -import FilesPage from './pages/FilesPage' -import AdminPage from './pages/AdminPage' -import SettingsPage from './pages/SettingsPage' -import VacayPage from './pages/VacayPage' -import AtlasPage from './pages/AtlasPage' -import JourneyPage from './pages/JourneyPage' -import JourneyDetailPage from './pages/JourneyDetailPage' -import JourneyPublicPage from './pages/JourneyPublicPage' -import SharedTripPage from './pages/SharedTripPage' -import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx' -import OAuthAuthorizePage from './pages/OAuthAuthorizePage' -import { ToastContainer } from './components/shared/Toast' -import BottomNav from './components/Layout/BottomNav' -import { TranslationProvider, useTranslation } from './i18n' -import { authApi } from './api/client' -import { usePermissionsStore, PermissionLevel } from './store/permissionsStore' -import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts' -import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers' -import OfflineBanner from './components/Layout/OfflineBanner' -import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js' +import { ReactNode, useEffect } from 'react'; +import { Navigate, Route, Routes, useLocation } from 'react-router-dom'; +import { authApi } from './api/client'; +import BottomNav from './components/Layout/BottomNav'; +import OfflineBanner from './components/Layout/OfflineBanner'; +import { ToastContainer } from './components/shared/Toast'; +import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'; +import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'; +import { TranslationProvider, useTranslation } from './i18n'; +import AdminPage from './pages/AdminPage'; +import AtlasPage from './pages/AtlasPage'; +import DashboardPage from './pages/DashboardPage'; +import FilesPage from './pages/FilesPage'; +import ForgotPasswordPage from './pages/ForgotPasswordPage'; +import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx'; +import JourneyDetailPage from './pages/JourneyDetailPage'; +import JourneyPage from './pages/JourneyPage'; +import JourneyPublicPage from './pages/JourneyPublicPage'; +import LoginPage from './pages/LoginPage'; +import OAuthAuthorizePage from './pages/OAuthAuthorizePage'; +import ResetPasswordPage from './pages/ResetPasswordPage'; +import SettingsPage from './pages/SettingsPage'; +import SharedTripPage from './pages/SharedTripPage'; +import TripPlannerPage from './pages/TripPlannerPage'; +import VacayPage from './pages/VacayPage'; +import { useAddonStore } from './store/addonStore'; +import { useAuthStore } from './store/authStore'; +import { PermissionLevel, usePermissionsStore } from './store/permissionsStore'; +import { useSettingsStore } from './store/settingsStore'; +import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'; // Notice action registrations (side-effect imports): -import './pages/Trips/noticeActions.js' +import './pages/Trips/noticeActions.js'; interface ProtectedRouteProps { - children: ReactNode - adminRequired?: boolean - addonId?: string + children: ReactNode; + adminRequired?: boolean; + addonId?: string; } function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedRouteProps) { - const isAuthenticated = useAuthStore((s) => s.isAuthenticated) - const user = useAuthStore((s) => s.user) - const isLoading = useAuthStore((s) => s.isLoading) - const appRequireMfa = useAuthStore((s) => s.appRequireMfa) - const addonStore = useAddonStore() - const { t } = useTranslation() - const location = useLocation() + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const user = useAuthStore((s) => s.user); + const isLoading = useAuthStore((s) => s.isLoading); + const appRequireMfa = useAuthStore((s) => s.appRequireMfa); + const addonStore = useAddonStore(); + const { t } = useTranslation(); + const location = useLocation(); if (isLoading) { return ( -
+
-
-

{t('common.loading')}

+
+

{t('common.loading')}

- ) + ); } if (!isAuthenticated) { - const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash) - return + const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash); + return ; } - if ( - appRequireMfa && - user && - !user.mfa_enabled && - location.pathname !== '/settings' - ) { - return + if (appRequireMfa && user && !user.mfa_enabled && location.pathname !== '/settings') { + return ; } if (adminRequired && user && user.role !== 'admin') { - return + return ; } if (addonId && addonStore.loaded && !addonStore.isEnabled(addonId)) { - return + return ; } return ( -
+
{children}
- ) + ); } function RootRedirect() { - const { isAuthenticated, isLoading } = useAuthStore() + const { isAuthenticated, isLoading } = useAuthStore(); if (isLoading) { return ( -
-
+
+
- ) + ); } - return + return ; } export default function App() { - const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled } = useAuthStore() - const { loadSettings } = useSettingsStore() - const { loadAddons } = useAddonStore() + const { + loadUser, + isAuthenticated, + demoMode, + setDemoMode, + setDevMode, + setIsPrerelease, + setAppVersion, + setHasMapsKey, + setServerTimezone, + setAppRequireMfa, + setTripRemindersEnabled, + setPlacesPhotosEnabled, + setPlacesAutocompleteEnabled, + setPlacesDetailsEnabled, + } = useAuthStore(); + const { loadSettings } = useSettingsStore(); + const { loadAddons } = useAddonStore(); useEffect(() => { - if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) { + if ( + !location.pathname.startsWith('/shared/') && + !location.pathname.startsWith('/public/') && + !location.pathname.startsWith('/login') + ) { // If the persist snapshot already has an authenticated user, validate // silently so the PWA shell renders immediately without a spinner. - const alreadyAuthenticated = useAuthStore.getState().isAuthenticated + const alreadyAuthenticated = useAuthStore.getState().isAuthenticated; if (alreadyAuthenticated) { - useAuthStore.setState({ isLoading: false }) - loadUser({ silent: true }) + useAuthStore.setState({ isLoading: false }); + loadUser({ silent: true }); } else { - loadUser() + loadUser(); } } - authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; permissions?: Record }) => { - if (config?.demo_mode) setDemoMode(true) - if (config?.dev_mode) setDevMode(true) - if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease) - if (config?.version) setAppVersion(config.version) - if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key) - if (config?.timezone) setServerTimezone(config.timezone) - if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa) - if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled) - if (config?.places_photos_enabled !== undefined) setPlacesPhotosEnabled(config.places_photos_enabled) - if (config?.places_autocomplete_enabled !== undefined) setPlacesAutocompleteEnabled(config.places_autocomplete_enabled) - if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled) - if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions) + authApi + .getAppConfig() + .then( + async (config: { + demo_mode?: boolean; + dev_mode?: boolean; + is_prerelease?: boolean; + has_maps_key?: boolean; + version?: string; + timezone?: string; + require_mfa?: boolean; + trip_reminders_enabled?: boolean; + places_photos_enabled?: boolean; + places_autocomplete_enabled?: boolean; + places_details_enabled?: boolean; + permissions?: Record; + }) => { + if (config?.demo_mode) setDemoMode(true); + if (config?.dev_mode) setDevMode(true); + if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease); + if (config?.version) setAppVersion(config.version); + if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key); + if (config?.timezone) setServerTimezone(config.timezone); + if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa); + if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled); + if (config?.places_photos_enabled !== undefined) setPlacesPhotosEnabled(config.places_photos_enabled); + if (config?.places_autocomplete_enabled !== undefined) + setPlacesAutocompleteEnabled(config.places_autocomplete_enabled); + if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled); + if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions); - if (config?.version) { - const storedVersion = localStorage.getItem('trek_app_version') - if (storedVersion && storedVersion !== config.version) { - try { - if ('caches' in window) { - const names = await caches.keys() - await Promise.all(names.map(n => caches.delete(n))) + if (config?.version) { + const storedVersion = localStorage.getItem('trek_app_version'); + if (storedVersion && storedVersion !== config.version) { + try { + if ('caches' in window) { + const names = await caches.keys(); + await Promise.all(names.map((n) => caches.delete(n))); + } + if ('serviceWorker' in navigator) { + const regs = await navigator.serviceWorker.getRegistrations(); + await Promise.all(regs.map((r) => r.unregister())); + } + } catch {} + localStorage.setItem('trek_app_version', config.version); + window.location.reload(); + return; } - if ('serviceWorker' in navigator) { - const regs = await navigator.serviceWorker.getRegistrations() - await Promise.all(regs.map(r => r.unregister())) - } - } catch {} - localStorage.setItem('trek_app_version', config.version) - window.location.reload() - return + localStorage.setItem('trek_app_version', config.version); + } } - localStorage.setItem('trek_app_version', config.version) - } - }).catch(() => {}) - }, []) + ) + .catch(() => {}); + }, []); - const { settings } = useSettingsStore() + const { settings } = useSettingsStore(); - useInAppNotificationListener() + useInAppNotificationListener(); useEffect(() => { if (isAuthenticated) { - loadSettings() - loadAddons() + loadSettings(); + loadAddons(); } - }, [isAuthenticated]) + }, [isAuthenticated]); useEffect(() => { - registerSyncTriggers() - return () => unregisterSyncTriggers() - }, []) + registerSyncTriggers(); + return () => unregisterSyncTriggers(); + }, []); - const location = useLocation() - const isSharedPage = location.pathname.startsWith('/shared/') + const location = useLocation(); + const isSharedPage = location.pathname.startsWith('/shared/'); useEffect(() => { // Shared page always forces light mode if (isSharedPage) { - document.documentElement.classList.remove('dark') - const meta = document.querySelector('meta[name="theme-color"]') - if (meta) meta.setAttribute('content', '#ffffff') - return + document.documentElement.classList.remove('dark'); + const meta = document.querySelector('meta[name="theme-color"]'); + if (meta) meta.setAttribute('content', '#ffffff'); + return; } - const mode = settings.dark_mode + const mode = settings.dark_mode; const applyDark = (isDark: boolean) => { - document.documentElement.classList.toggle('dark', isDark) - const meta = document.querySelector('meta[name="theme-color"]') - if (meta) meta.setAttribute('content', isDark ? '#09090b' : '#ffffff') - } + document.documentElement.classList.toggle('dark', isDark); + const meta = document.querySelector('meta[name="theme-color"]'); + if (meta) meta.setAttribute('content', isDark ? '#09090b' : '#ffffff'); + }; if (mode === 'auto') { - const mq = window.matchMedia('(prefers-color-scheme: dark)') - applyDark(mq.matches) - const handler = (e: MediaQueryListEvent) => applyDark(e.matches) - mq.addEventListener('change', handler) - return () => mq.removeEventListener('change', handler) + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + applyDark(mq.matches); + const handler = (e: MediaQueryListEvent) => applyDark(e.matches); + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); } - applyDark(mode === true || mode === 'dark') - }, [settings.dark_mode, isSharedPage]) + applyDark(mode === true || mode === 'dark'); + }, [settings.dark_mode, isSharedPage]); - const isAuthPage = location.pathname.startsWith('/login') - || location.pathname.startsWith('/register') - || location.pathname.startsWith('/forgot-password') - || location.pathname.startsWith('/reset-password') + const isAuthPage = + location.pathname.startsWith('/login') || + location.pathname.startsWith('/register') || + location.pathname.startsWith('/forgot-password') || + location.pathname.startsWith('/reset-password'); return ( @@ -302,5 +336,5 @@ export default function App() { } /> - ) + ); } diff --git a/client/src/components/Admin/AddonManager.test.tsx b/client/src/components/Admin/AddonManager.test.tsx index 206f063d..2fd9106f 100644 --- a/client/src/components/Admin/AddonManager.test.tsx +++ b/client/src/components/Admin/AddonManager.test.tsx @@ -1,11 +1,11 @@ // FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-011 -import { render, screen, waitFor, within } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; +import { render, screen, waitFor } from '../../../tests/helpers/render'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; -import { useSettingsStore } from '../../store/settingsStore'; import { useAddonStore } from '../../store/addonStore'; +import { useSettingsStore } from '../../store/settingsStore'; import { ToastContainer } from '../shared/Toast'; import AddonManager from './AddonManager'; @@ -36,9 +36,7 @@ beforeEach(() => { resetAllStores(); seedStore(useSettingsStore, { settings: { dark_mode: false } }); vi.spyOn(useAddonStore.getState(), 'loadAddons').mockResolvedValue(undefined); - server.use( - http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] })) - ); + server.use(http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] }))); }); afterEach(() => { @@ -49,7 +47,7 @@ describe('AddonManager', () => { it('FE-ADMIN-ADDON-001: loading spinner shown while fetching', async () => { server.use( http.get('/api/admin/addons', async () => { - await new Promise(resolve => setTimeout(resolve, 200)); + await new Promise((resolve) => setTimeout(resolve, 200)); return HttpResponse.json({ addons: [] }); }) ); @@ -95,19 +93,20 @@ describe('AddonManager', () => { it('FE-ADMIN-ADDON-005: toggle enables a disabled addon (optimistic update)', async () => { const user = userEvent.setup(); server.use( - http.get('/api/admin/addons', () => - HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] }) - ), - http.put('/api/admin/addons/todo', () => - HttpResponse.json({ success: true }) - ) + http.get('/api/admin/addons', () => HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] })), + http.put('/api/admin/addons/todo', () => HttpResponse.json({ success: true })) + ); + render( + <> + + + ); - render(<>); await screen.findByText('Todo List'); // Get toggle button - use getAllByRole since there might be multiple buttons const buttons = screen.getAllByRole('button'); - const toggleBtn = buttons.find(b => b.classList.contains('rounded-full')); + const toggleBtn = buttons.find((b) => b.classList.contains('rounded-full')); expect(toggleBtn).toBeInTheDocument(); // Before click - disabled state (border-primary bg) @@ -120,18 +119,19 @@ describe('AddonManager', () => { it('FE-ADMIN-ADDON-006: toggle rolls back on API failure', async () => { const user = userEvent.setup(); server.use( - http.get('/api/admin/addons', () => - HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] }) - ), - http.put('/api/admin/addons/todo', () => - HttpResponse.error() - ) + http.get('/api/admin/addons', () => HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] })), + http.put('/api/admin/addons/todo', () => HttpResponse.error()) + ); + render( + <> + + + ); - render(<>); await screen.findByText('Todo List'); const buttons = screen.getAllByRole('button'); - const toggleBtn = buttons.find(b => b.classList.contains('rounded-full')); + const toggleBtn = buttons.find((b) => b.classList.contains('rounded-full')); await user.click(toggleBtn!); // Error toast appears @@ -148,19 +148,18 @@ describe('AddonManager', () => { const user = userEvent.setup(); const mockToggle = vi.fn(); server.use( - http.get('/api/admin/addons', () => - HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] }) - ) - ); - render( - + http.get('/api/admin/addons', () => HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] })) ); + render(); await screen.findByText('Bag Tracking'); - const bagTrackingToggle = screen.getAllByRole('button').find(b => - b.closest('[style*="paddingLeft: 70"]') !== null || b.closest('div')?.textContent?.includes('Bag Tracking') - ); + const bagTrackingToggle = screen + .getAllByRole('button') + .find( + (b) => + b.closest('[style*="paddingLeft: 70"]') !== null || b.closest('div')?.textContent?.includes('Bag Tracking') + ); // Click the bag tracking toggle button (the h-6 w-11 button near "Bag Tracking") - const allBtns = screen.getAllByRole('button').filter(b => b.classList.contains('rounded-full')); + const allBtns = screen.getAllByRole('button').filter((b) => b.classList.contains('rounded-full')); // There should be two toggle buttons: one for the addon, one for bag tracking await user.click(allBtns[allBtns.length - 1]); expect(mockToggle).toHaveBeenCalled(); @@ -172,18 +171,14 @@ describe('AddonManager', () => { HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: false })] }) ) ); - render( - - ); + render(); await screen.findByText('Lists'); expect(screen.queryByText('Bag Tracking')).not.toBeInTheDocument(); }); it('FE-ADMIN-ADDON-009: bag tracking hidden when onToggleBagTracking prop not provided', async () => { server.use( - http.get('/api/admin/addons', () => - HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] }) - ) + http.get('/api/admin/addons', () => HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] })) ); render(); await screen.findByText('Lists'); @@ -213,7 +208,7 @@ describe('AddonManager', () => { expect(screen.getByText('Journey')).toBeInTheDocument(); // Toggle buttons: journey toggle + 2 provider toggles - const toggleBtns = screen.getAllByRole('button').filter(b => b.classList.contains('rounded-full')); + const toggleBtns = screen.getAllByRole('button').filter((b) => b.classList.contains('rounded-full')); expect(toggleBtns.length).toBe(3); }); diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index c2db2218..28fdf426 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -1,168 +1,243 @@ -import { useEffect, useState } from 'react' -import { adminApi } from '../../api/client' -import { useTranslation } from '../../i18n' -import { useSettingsStore } from '../../store/settingsStore' -import { useAddonStore } from '../../store/addonStore' -import { useToast } from '../shared/Toast' -import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage } from 'lucide-react' +import { + BarChart3, + BookOpen, + Briefcase, + CalendarDays, + Compass, + FileText, + Globe, + Image, + Link2, + ListChecks, + Luggage, + MessageCircle, + Puzzle, + Sparkles, + StickyNote, + Terminal, + Wallet, +} from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { adminApi } from '../../api/client'; +import { useTranslation } from '../../i18n'; +import { useAddonStore } from '../../store/addonStore'; +import { useSettingsStore } from '../../store/settingsStore'; +import { useToast } from '../shared/Toast'; const ICON_MAP = { - ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, -} + ListChecks, + Wallet, + FileText, + CalendarDays, + Puzzle, + Globe, + Briefcase, + Image, + Terminal, + Link2, + Compass, + BookOpen, +}; function ImmichIcon({ size = 14 }: { size?: number }) { return ( - + - ) + ); } function SynologyIcon({ size = 14 }: { size?: number }) { return ( - + - ) + ); } const PROVIDER_ICONS: Record> = { immich: ImmichIcon, synologyphotos: SynologyIcon, -} +}; interface Addon { - id: string - name: string - description: string - icon: string - type: string - enabled: boolean - config?: Record + id: string; + name: string; + description: string; + icon: string; + type: string; + enabled: boolean; + config?: Record; } interface ProviderOption { - key: string - label: string - description: string - enabled: boolean - toggle: () => Promise + key: string; + label: string; + description: string; + enabled: boolean; + toggle: () => Promise; } interface AddonIconProps { - name: string - size?: number + name: string; + size?: number; } function AddonIcon({ name, size = 20 }: AddonIconProps) { - const Icon = ICON_MAP[name] || Puzzle - return + const Icon = ICON_MAP[name] || Puzzle; + return ; } -interface CollabFeatures { chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean } +interface CollabFeatures { + chat: boolean; + notes: boolean; + polls: boolean; + whatsnext: boolean; +} const COLLAB_SUB_FEATURES = [ { key: 'chat', icon: MessageCircle, titleKey: 'admin.collab.chat.title', subtitleKey: 'admin.collab.chat.subtitle' }, { key: 'notes', icon: StickyNote, titleKey: 'admin.collab.notes.title', subtitleKey: 'admin.collab.notes.subtitle' }, { key: 'polls', icon: BarChart3, titleKey: 'admin.collab.polls.title', subtitleKey: 'admin.collab.polls.subtitle' }, - { key: 'whatsnext', icon: Sparkles, titleKey: 'admin.collab.whatsnext.title', subtitleKey: 'admin.collab.whatsnext.subtitle' }, -] as const + { + key: 'whatsnext', + icon: Sparkles, + titleKey: 'admin.collab.whatsnext.title', + subtitleKey: 'admin.collab.whatsnext.subtitle', + }, +] as const; -export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, collabFeatures, onToggleCollabFeature }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void; collabFeatures?: CollabFeatures; onToggleCollabFeature?: (key: string) => void }) { - const { t } = useTranslation() - const dm = useSettingsStore(s => s.settings.dark_mode) - const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) - const toast = useToast() - const refreshGlobalAddons = useAddonStore(s => s.loadAddons) - const [addons, setAddons] = useState([]) - const [loading, setLoading] = useState(true) +export default function AddonManager({ + bagTrackingEnabled, + onToggleBagTracking, + collabFeatures, + onToggleCollabFeature, +}: { + bagTrackingEnabled?: boolean; + onToggleBagTracking?: () => void; + collabFeatures?: CollabFeatures; + onToggleCollabFeature?: (key: string) => void; +}) { + const { t } = useTranslation(); + const dm = useSettingsStore((s) => s.settings.dark_mode); + const dark = + dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches); + const toast = useToast(); + const refreshGlobalAddons = useAddonStore((s) => s.loadAddons); + const [addons, setAddons] = useState([]); + const [loading, setLoading] = useState(true); useEffect(() => { - loadAddons() - }, []) + loadAddons(); + }, []); const loadAddons = async () => { - setLoading(true) + setLoading(true); try { - const data = await adminApi.addons() - setAddons(data.addons) + const data = await adminApi.addons(); + setAddons(data.addons); } catch (err: unknown) { - toast.error(t('admin.addons.toast.error')) + toast.error(t('admin.addons.toast.error')); } finally { - setLoading(false) + setLoading(false); } - } + }; const handleToggle = async (addon: Addon) => { - const newEnabled = !addon.enabled + const newEnabled = !addon.enabled; // Optimistic update - setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a)) + setAddons((prev) => prev.map((a) => (a.id === addon.id ? { ...a, enabled: newEnabled } : a))); try { - await adminApi.updateAddon(addon.id, { enabled: newEnabled }) - refreshGlobalAddons() - toast.success(t('admin.addons.toast.updated')) + await adminApi.updateAddon(addon.id, { enabled: newEnabled }); + refreshGlobalAddons(); + toast.success(t('admin.addons.toast.updated')); } catch (err: unknown) { // Rollback - setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: !newEnabled } : a)) - toast.error(t('admin.addons.toast.error')) + setAddons((prev) => prev.map((a) => (a.id === addon.id ? { ...a, enabled: !newEnabled } : a))); + toast.error(t('admin.addons.toast.error')); } - } + }; const isPhotoProviderAddon = (addon: Addon) => { - return addon.type === 'photo_provider' - } + return addon.type === 'photo_provider'; + }; const isPhotosAddon = (addon: Addon) => { - const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase() - return addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories')) - } + const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase(); + return ( + addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories')) + ); + }; const handleTogglePhotoProvider = async (providerAddon: Addon) => { - const enableProvider = !providerAddon.enabled - const prev = addons + const enableProvider = !providerAddon.enabled; + const prev = addons; - setAddons(current => current.map(a => a.id === providerAddon.id ? { ...a, enabled: enableProvider } : a)) + setAddons((current) => current.map((a) => (a.id === providerAddon.id ? { ...a, enabled: enableProvider } : a))); try { - await adminApi.updateAddon(providerAddon.id, { enabled: enableProvider }) - refreshGlobalAddons() - toast.success(t('admin.addons.toast.updated')) + await adminApi.updateAddon(providerAddon.id, { enabled: enableProvider }); + refreshGlobalAddons(); + toast.success(t('admin.addons.toast.updated')); } catch { - setAddons(prev) - toast.error(t('admin.addons.toast.error')) + setAddons(prev); + toast.error(t('admin.addons.toast.error')); } - } + }; - const photoProviderAddons = addons.filter(isPhotoProviderAddon) - const photosAddon = addons.filter(a => a.type === 'trip').find(isPhotosAddon) - const tripAddons = addons.filter(a => a.type === 'trip' && !isPhotosAddon(a)) - const globalAddons = addons.filter(a => a.type === 'global') - const integrationAddons = addons.filter(a => a.type === 'integration') + const photoProviderAddons = addons.filter(isPhotoProviderAddon); + const photosAddon = addons.filter((a) => a.type === 'trip').find(isPhotosAddon); + const tripAddons = addons.filter((a) => a.type === 'trip' && !isPhotosAddon(a)); + const globalAddons = addons.filter((a) => a.type === 'global'); + const integrationAddons = addons.filter((a) => a.type === 'integration'); const providerOptions: ProviderOption[] = photoProviderAddons.map((provider) => ({ - key: provider.id, - label: provider.name, - description: provider.description, - enabled: provider.enabled, - toggle: () => handleTogglePhotoProvider(provider), - })) - const photosDerivedEnabled = providerOptions.some(p => p.enabled) + key: provider.id, + label: provider.name, + description: provider.description, + enabled: provider.enabled, + toggle: () => handleTogglePhotoProvider(provider), + })); + const photosDerivedEnabled = providerOptions.some((p) => p.enabled); if (loading) { return (
-
+
- ) + ); } return (
{/* Header */} -
-
-

{t('admin.addons.title')}

-

- {t('admin.addons.subtitleBefore')}TREK{t('admin.addons.subtitleAfter')} +

+
+

+ {t('admin.addons.title')} +

+

+ {t('admin.addons.subtitleBefore')} + TREK + {t('admin.addons.subtitleAfter')}

@@ -175,61 +250,100 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, {/* Trip Addons */} {tripAddons.length > 0 && (
-
+
{t('admin.addons.type.trip')} — {t('admin.addons.tripHint')}
- {tripAddons.map(addon => ( + {tripAddons.map((addon) => (
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && ( -
+
-
{t('admin.bagTracking.title')}
-
{t('admin.bagTracking.subtitle')}
+
+ {t('admin.bagTracking.title')} +
+
+ {t('admin.bagTracking.subtitle')} +
-
- +
+ {bagTrackingEnabled ? t('admin.addons.enabled') : t('admin.addons.disabled')} -
)} {addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && ( -
+
- {COLLAB_SUB_FEATURES.map(feat => { - const enabled = collabFeatures[feat.key] - const Icon = feat.icon + {COLLAB_SUB_FEATURES.map((feat) => { + const enabled = collabFeatures[feat.key]; + const Icon = feat.icon; return (
-
{t(feat.titleKey)}
-
{t(feat.subtitleKey)}
+
+ {t(feat.titleKey)} +
+
+ {t(feat.subtitleKey)} +
-
- +
+ {enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')} -
- ) + ); })}
@@ -242,43 +356,68 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, {/* Global Addons */} {globalAddons.length > 0 && (
-
+
{t('admin.addons.type.global')} — {t('admin.addons.globalHint')}
- {globalAddons.map(addon => ( + {globalAddons.map((addon) => (
{/* Memories providers as sub-items under Journey addon */} {addon.id === 'journey' && providerOptions.length > 0 && ( -
+
- {providerOptions.map(provider => { - const ProviderIcon = PROVIDER_ICONS[provider.key] + {providerOptions.map((provider) => { + const ProviderIcon = PROVIDER_ICONS[provider.key]; return ( -
- {ProviderIcon && } -
-
{provider.label}
-
{provider.description}
+
+ {ProviderIcon && ( + + + + )} +
+
+ {provider.label} +
+
+ {provider.description} +
+
+
+ + {provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')} + + +
-
- - {provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')} - - -
-
- ) + ); })}
@@ -291,13 +430,16 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, {/* Integration Addons */} {integrationAddons.length > 0 && (
-
+
{t('admin.addons.type.integration')} — {t('admin.addons.integrationHint')}
- {integrationAddons.map(addon => ( + {integrationAddons.map((addon) => ( ))}
@@ -306,80 +448,122 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, )}
- ) + ); } interface AddonRowProps { - addon: Addon - onToggle: (addon: Addon) => void - t: (key: string) => string - statusOverride?: boolean - hideToggle?: boolean + addon: Addon; + onToggle: (addon: Addon) => void; + t: (key: string) => string; + statusOverride?: boolean; + hideToggle?: boolean; } function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } { - const nameKey = `admin.addons.catalog.${addon.id}.name` - const descKey = `admin.addons.catalog.${addon.id}.description` - const translatedName = t(nameKey) - const translatedDescription = t(descKey) + const nameKey = `admin.addons.catalog.${addon.id}.name`; + const descKey = `admin.addons.catalog.${addon.id}.description`; + const translatedName = t(nameKey); + const translatedDescription = t(descKey); return { name: translatedName !== nameKey ? translatedName : addon.name, description: translatedDescription !== descKey ? translatedDescription : addon.description, - } + }; } -function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statusOverride, hideToggle }: AddonRowProps & { nameOverride?: string; descriptionOverride?: string }) { - const isComingSoon = false - const label = getAddonLabel(t, addon) - const displayName = nameOverride || label.name - const displayDescription = descriptionOverride || label.description - const enabledState = statusOverride ?? addon.enabled +function AddonRow({ + addon, + onToggle, + t, + nameOverride, + descriptionOverride, + statusOverride, + hideToggle, +}: AddonRowProps & { nameOverride?: string; descriptionOverride?: string }) { + const isComingSoon = false; + const label = getAddonLabel(t, addon); + const displayName = nameOverride || label.name; + const displayDescription = descriptionOverride || label.description; + const enabledState = statusOverride ?? addon.enabled; return ( -
+
{/* Icon */} -
+
{/* Info */} -
+
- {displayName} + + {displayName} + {isComingSoon && ( - + Coming Soon )} - - {addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')} + + {addon.type === 'global' + ? t('admin.addons.type.global') + : addon.type === 'integration' + ? t('admin.addons.type.integration') + : t('admin.addons.type.trip')}
-

{displayDescription}

+

+ {displayDescription} +

{/* Toggle */} -
- - {isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')} +
+ + {isComingSoon + ? t('admin.addons.disabled') + : enabledState + ? t('admin.addons.enabled') + : t('admin.addons.disabled')} {!hideToggle && ( )}
- ) + ); } diff --git a/client/src/components/Admin/AdminMcpTokensPanel.test.tsx b/client/src/components/Admin/AdminMcpTokensPanel.test.tsx index 8abcd44d..ad67057d 100644 --- a/client/src/components/Admin/AdminMcpTokensPanel.test.tsx +++ b/client/src/components/Admin/AdminMcpTokensPanel.test.tsx @@ -1,8 +1,8 @@ // FE-ADMIN-MCP-001 to FE-ADMIN-MCP-016 -import { render, screen, waitFor } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; +import { render, screen, waitFor } from '../../../tests/helpers/render'; import { resetAllStores } from '../../../tests/helpers/store'; import { ToastContainer } from '../shared/Toast'; import AdminMcpTokensPanel from './AdminMcpTokensPanel'; @@ -39,7 +39,7 @@ describe('AdminMcpTokensPanel', () => { it('FE-ADMIN-MCP-001: loading spinner shown on mount', async () => { server.use( http.get('/api/admin/mcp-tokens', async () => { - await new Promise(resolve => setTimeout(resolve, 200)); + await new Promise((resolve) => setTimeout(resolve, 200)); return HttpResponse.json({ tokens: [] }); }) ); @@ -53,11 +53,7 @@ describe('AdminMcpTokensPanel', () => { }); it('FE-ADMIN-MCP-003: token list renders correctly', async () => { - server.use( - http.get('/api/admin/mcp-tokens', () => - HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] }) - ) - ); + server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] }))); render(); await screen.findByText('CI Token'); expect(screen.getByText('Ops Token')).toBeInTheDocument(); @@ -69,11 +65,7 @@ describe('AdminMcpTokensPanel', () => { }); it('FE-ADMIN-MCP-004: "Never" shown when last_used_at is null', async () => { - server.use( - http.get('/api/admin/mcp-tokens', () => - HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] }) - ) - ); + server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] }))); render(); await screen.findByText('CI Token'); expect(screen.getByText('Never')).toBeInTheDocument(); @@ -81,11 +73,7 @@ describe('AdminMcpTokensPanel', () => { it('FE-ADMIN-MCP-005: delete confirmation dialog opens', async () => { const user = userEvent.setup(); - server.use( - http.get('/api/admin/mcp-tokens', () => - HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] }) - ) - ); + server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] }))); render(); await screen.findByText('CI Token'); @@ -100,11 +88,7 @@ describe('AdminMcpTokensPanel', () => { it('FE-ADMIN-MCP-006: cancel closes confirmation dialog without deleting', async () => { const user = userEvent.setup(); - server.use( - http.get('/api/admin/mcp-tokens', () => - HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] }) - ) - ); + server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] }))); render(); await screen.findByText('CI Token'); @@ -121,11 +105,7 @@ describe('AdminMcpTokensPanel', () => { it('FE-ADMIN-MCP-007: backdrop click closes dialog', async () => { const user = userEvent.setup(); - server.use( - http.get('/api/admin/mcp-tokens', () => - HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] }) - ) - ); + server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] }))); render(); await screen.findByText('CI Token'); @@ -145,14 +125,15 @@ describe('AdminMcpTokensPanel', () => { it('FE-ADMIN-MCP-008: successful delete removes token from list', async () => { const user = userEvent.setup(); server.use( - http.get('/api/admin/mcp-tokens', () => - HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] }) - ), - http.delete('/api/admin/mcp-tokens/:id', () => - HttpResponse.json({ success: true }) - ) + http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })), + http.delete('/api/admin/mcp-tokens/:id', () => HttpResponse.json({ success: true })) + ); + render( + <> + + + ); - render(<>); await screen.findByText('CI Token'); const deleteButtons = screen.getAllByTitle('Delete'); @@ -170,14 +151,15 @@ describe('AdminMcpTokensPanel', () => { it('FE-ADMIN-MCP-009: failed delete shows error toast and keeps list unchanged', async () => { const user = userEvent.setup(); server.use( - http.get('/api/admin/mcp-tokens', () => - HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] }) - ), - http.delete('/api/admin/mcp-tokens/:id', () => - HttpResponse.json({ error: 'forbidden' }, { status: 403 }) - ) + http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })), + http.delete('/api/admin/mcp-tokens/:id', () => HttpResponse.json({ error: 'forbidden' }, { status: 403 })) + ); + render( + <> + + + ); - render(<>); await screen.findByText('CI Token'); const deleteButtons = screen.getAllByTitle('Delete'); @@ -189,19 +171,20 @@ describe('AdminMcpTokensPanel', () => { }); it('FE-ADMIN-MCP-010: load failure shows error toast', async () => { - server.use( - http.get('/api/admin/mcp-tokens', () => - HttpResponse.json({ error: 'server error' }, { status: 500 }) - ) + server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ error: 'server error' }, { status: 500 }))); + render( + <> + + + ); - render(<>); await screen.findByText('Failed to load tokens'); }); it('FE-ADMIN-MCP-011: OAuth sessions loading spinner shown on mount', async () => { server.use( http.get('/api/admin/oauth-sessions', async () => { - await new Promise(resolve => setTimeout(resolve, 200)); + await new Promise((resolve) => setTimeout(resolve, 200)); return HttpResponse.json({ sessions: [] }); }) ); @@ -210,11 +193,7 @@ describe('AdminMcpTokensPanel', () => { }); it('FE-ADMIN-MCP-012: OAuth sessions empty state rendered when no sessions', async () => { - server.use( - http.get('/api/admin/oauth-sessions', () => - HttpResponse.json({ sessions: [] }) - ) - ); + server.use(http.get('/api/admin/oauth-sessions', () => HttpResponse.json({ sessions: [] }))); render(); await screen.findByText('No active OAuth sessions'); }); @@ -244,13 +223,19 @@ describe('AdminMcpTokensPanel', () => { it('FE-ADMIN-MCP-014: scope expand/collapse toggle shows hidden scopes', async () => { const user = userEvent.setup(); // 7 scopes — more than SCOPES_PREVIEW=6, so "+1 more" button appears - const scopes = ['trips:read', 'trips:write', 'places:read', 'places:write', 'budget:read', 'budget:write', 'packing:read']; + const scopes = [ + 'trips:read', + 'trips:write', + 'places:read', + 'places:write', + 'budget:read', + 'budget:write', + 'packing:read', + ]; server.use( http.get('/api/admin/oauth-sessions', () => HttpResponse.json({ - sessions: [ - { id: 1, client_name: 'App', username: 'bob', scopes, created_at: '2025-01-01T00:00:00Z' }, - ], + sessions: [{ id: 1, client_name: 'App', username: 'bob', scopes, created_at: '2025-01-01T00:00:00Z' }], }) ) ); @@ -270,15 +255,24 @@ describe('AdminMcpTokensPanel', () => { http.get('/api/admin/oauth-sessions', () => HttpResponse.json({ sessions: [ - { id: 5, client_name: 'Revoke Me', username: 'carol', scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' }, + { + id: 5, + client_name: 'Revoke Me', + username: 'carol', + scopes: ['trips:read'], + created_at: '2025-01-01T00:00:00Z', + }, ], }) ), - http.delete('/api/admin/oauth-sessions/5', () => - HttpResponse.json({ success: true }) - ) + http.delete('/api/admin/oauth-sessions/5', () => HttpResponse.json({ success: true })) + ); + render( + <> + + + ); - render(<>); await screen.findByText('Revoke Me'); // Click the revoke (trash) button next to the session @@ -289,7 +283,7 @@ describe('AdminMcpTokensPanel', () => { expect(screen.getByText('Revoke Session')).toBeInTheDocument(); // Confirm — find the modal's Delete button (has no title, unlike the trash icon) const deleteBtns = screen.getAllByRole('button', { name: 'Delete' }); - const confirmBtn = deleteBtns.find(b => !b.title); + const confirmBtn = deleteBtns.find((b) => !b.title); await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]); await waitFor(() => { expect(screen.queryByText('Revoke Me')).not.toBeInTheDocument(); @@ -302,21 +296,30 @@ describe('AdminMcpTokensPanel', () => { http.get('/api/admin/oauth-sessions', () => HttpResponse.json({ sessions: [ - { id: 6, client_name: 'Error Session', username: 'dave', scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' }, + { + id: 6, + client_name: 'Error Session', + username: 'dave', + scopes: ['trips:read'], + created_at: '2025-01-01T00:00:00Z', + }, ], }) ), - http.delete('/api/admin/oauth-sessions/6', () => - HttpResponse.json({ error: 'forbidden' }, { status: 403 }) - ) + http.delete('/api/admin/oauth-sessions/6', () => HttpResponse.json({ error: 'forbidden' }, { status: 403 })) + ); + render( + <> + + + ); - render(<>); await screen.findByText('Error Session'); const deleteBtn = screen.getAllByTitle('Delete')[0]; await user.click(deleteBtn); const deleteBtns = screen.getAllByRole('button', { name: 'Delete' }); - const confirmBtn = deleteBtns.find(b => !b.title); + const confirmBtn = deleteBtns.find((b) => !b.title); await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]); await screen.findByText('Failed to revoke session'); }); diff --git a/client/src/components/Admin/AdminMcpTokensPanel.tsx b/client/src/components/Admin/AdminMcpTokensPanel.tsx index 7173ae9c..89af94d0 100644 --- a/client/src/components/Admin/AdminMcpTokensPanel.tsx +++ b/client/src/components/Admin/AdminMcpTokensPanel.tsx @@ -1,161 +1,212 @@ -import { useState, useEffect } from 'react' -import { adminApi } from '../../api/client' -import { useToast } from '../shared/Toast' -import { Key, Trash2, User, Loader2, Shield } from 'lucide-react' -import { useTranslation } from '../../i18n' +import { Key, Loader2, Shield, Trash2, User } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { adminApi } from '../../api/client'; +import { useTranslation } from '../../i18n'; +import { useToast } from '../shared/Toast'; interface AdminOAuthSession { - id: number - client_id: string - client_name: string - user_id: number - username: string - scopes: string[] - access_token_expires_at: string - refresh_token_expires_at: string - created_at: string + id: number; + client_id: string; + client_name: string; + user_id: number; + username: string; + scopes: string[]; + access_token_expires_at: string; + refresh_token_expires_at: string; + created_at: string; } interface AdminMcpToken { - id: number - name: string - token_prefix: string - created_at: string - last_used_at: string | null - user_id: number - username: string + id: number; + name: string; + token_prefix: string; + created_at: string; + last_used_at: string | null; + user_id: number; + username: string; } -const SCOPES_PREVIEW = 6 +const SCOPES_PREVIEW = 6; export default function AdminMcpTokensPanel() { - const [sessions, setSessions] = useState([]) - const [sessionsLoading, setSessionsLoading] = useState(true) - const [tokens, setTokens] = useState([]) - const [tokensLoading, setTokensLoading] = useState(true) - const [expandedScopes, setExpandedScopes] = useState>(new Set()) - const [revokeConfirmId, setRevokeConfirmId] = useState(null) - const [deleteConfirmId, setDeleteConfirmId] = useState(null) + const [sessions, setSessions] = useState([]); + const [sessionsLoading, setSessionsLoading] = useState(true); + const [tokens, setTokens] = useState([]); + const [tokensLoading, setTokensLoading] = useState(true); + const [expandedScopes, setExpandedScopes] = useState>(new Set()); + const [revokeConfirmId, setRevokeConfirmId] = useState(null); + const [deleteConfirmId, setDeleteConfirmId] = useState(null); const toggleScopes = (id: number) => - setExpandedScopes(prev => { - const next = new Set(prev) - next.has(id) ? next.delete(id) : next.add(id) - return next - }) - const toast = useToast() - const { t, locale } = useTranslation() + setExpandedScopes((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + const toast = useToast(); + const { t, locale } = useTranslation(); useEffect(() => { - adminApi.oauthSessions() - .then(d => setSessions(d.sessions || [])) + adminApi + .oauthSessions() + .then((d) => setSessions(d.sessions || [])) .catch(() => toast.error(t('admin.oauthSessions.loadError'))) - .finally(() => setSessionsLoading(false)) + .finally(() => setSessionsLoading(false)); - adminApi.mcpTokens() - .then(d => setTokens(d.tokens || [])) + adminApi + .mcpTokens() + .then((d) => setTokens(d.tokens || [])) .catch(() => toast.error(t('admin.mcpTokens.loadError'))) - .finally(() => setTokensLoading(false)) - }, []) + .finally(() => setTokensLoading(false)); + }, []); const handleRevoke = async (id: number) => { try { - await adminApi.revokeOAuthSession(id) - setSessions(prev => prev.filter(s => s.id !== id)) - setRevokeConfirmId(null) - toast.success(t('admin.oauthSessions.revokeSuccess')) + await adminApi.revokeOAuthSession(id); + setSessions((prev) => prev.filter((s) => s.id !== id)); + setRevokeConfirmId(null); + toast.success(t('admin.oauthSessions.revokeSuccess')); } catch { - toast.error(t('admin.oauthSessions.revokeError')) + toast.error(t('admin.oauthSessions.revokeError')); } - } + }; const handleDelete = async (id: number) => { try { - await adminApi.deleteMcpToken(id) - setTokens(prev => prev.filter(tk => tk.id !== id)) - setDeleteConfirmId(null) - toast.success(t('admin.mcpTokens.deleteSuccess')) + await adminApi.deleteMcpToken(id); + setTokens((prev) => prev.filter((tk) => tk.id !== id)); + setDeleteConfirmId(null); + toast.success(t('admin.mcpTokens.deleteSuccess')); } catch { - toast.error(t('admin.mcpTokens.deleteError')) + toast.error(t('admin.mcpTokens.deleteError')); } - } + }; return (
-

{t('admin.mcpTokens.title')}

-

{t('admin.mcpTokens.subtitle')}

+

+ {t('admin.mcpTokens.title')} +

+

+ {t('admin.mcpTokens.subtitle')} +

{/* OAuth Sessions */}
-

{t('admin.oauthSessions.sectionTitle')}

-
+

+ {t('admin.oauthSessions.sectionTitle')} +

+
{sessionsLoading ? (
- +
) : sessions.length === 0 ? ( -
- -

{t('admin.oauthSessions.empty')}

+
+ +

+ {t('admin.oauthSessions.empty')} +

) : ( <> -
+
{t('admin.oauthSessions.clientName')} {t('admin.oauthSessions.owner')} {t('admin.oauthSessions.created')}
{sessions.map((session, i) => { - const expanded = expandedScopes.has(session.id) - const visible = expanded ? session.scopes : session.scopes.slice(0, SCOPES_PREVIEW) - const hidden = session.scopes.length - SCOPES_PREVIEW + const expanded = expandedScopes.has(session.id); + const visible = expanded ? session.scopes : session.scopes.slice(0, SCOPES_PREVIEW); + const hidden = session.scopes.length - SCOPES_PREVIEW; return ( -
+ style={{ borderBottom: i < sessions.length - 1 ? '1px solid var(--border-primary)' : undefined }} + >
-

{session.client_name}

-
- {visible.map(scope => ( - +

+ {session.client_name} +

+
+ {visible.map((scope) => ( + {scope} ))} {!expanded && hidden > 0 && ( - )} {expanded && hidden > 0 && ( - )}
-
- +
+ {session.username}
- + {new Date(session.created_at).toLocaleDateString(locale)} -
- ) + ); })} )} @@ -164,21 +215,34 @@ export default function AdminMcpTokensPanel() { {/* MCP Tokens */}
-

{t('admin.mcpTokens.sectionTitle')}

-
+

+ {t('admin.mcpTokens.sectionTitle')} +

+
{tokensLoading ? (
- +
) : tokens.length === 0 ? ( -
- -

{t('admin.mcpTokens.empty')}

+
+ +

+ {t('admin.mcpTokens.empty')} +

) : ( <> -
+
{t('admin.mcpTokens.tokenName')} {t('admin.mcpTokens.owner')} {t('admin.mcpTokens.created')} @@ -186,27 +250,38 @@ export default function AdminMcpTokensPanel() {
{tokens.map((token, i) => ( -
+ style={{ borderBottom: i < tokens.length - 1 ? '1px solid var(--border-primary)' : undefined }} + >
-

{token.name}

-

{token.token_prefix}...

+

+ {token.name} +

+

+ {token.token_prefix}... +

- + {token.username}
- + {new Date(token.created_at).toLocaleDateString(locale)} - - {token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')} + + {token.last_used_at + ? new Date(token.last_used_at).toLocaleDateString(locale) + : t('admin.mcpTokens.never')} -
))} @@ -217,18 +292,32 @@ export default function AdminMcpTokensPanel() { {/* Revoke OAuth session modal */} {revokeConfirmId !== null && ( -
{ if (e.target === e.currentTarget) setRevokeConfirmId(null) }}> -
-

{t('admin.oauthSessions.revokeTitle')}

-

{t('admin.oauthSessions.revokeMessage')}

-
- -
@@ -238,18 +327,32 @@ export default function AdminMcpTokensPanel() { {/* Delete MCP token modal */} {deleteConfirmId !== null && ( -
{ if (e.target === e.currentTarget) setDeleteConfirmId(null) }}> -
-

{t('admin.mcpTokens.deleteTitle')}

-

{t('admin.mcpTokens.deleteMessage')}

-
- -
@@ -257,5 +360,5 @@ export default function AdminMcpTokensPanel() {
)}
- ) + ); } diff --git a/client/src/components/Admin/AuditLogPanel.test.tsx b/client/src/components/Admin/AuditLogPanel.test.tsx index 4d076f0e..0e121ef4 100644 --- a/client/src/components/Admin/AuditLogPanel.test.tsx +++ b/client/src/components/Admin/AuditLogPanel.test.tsx @@ -1,8 +1,8 @@ // FE-ADMIN-AUDIT-001 to FE-ADMIN-AUDIT-010 -import { render, screen, waitFor } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; +import { render, screen, waitFor } from '../../../tests/helpers/render'; import { resetAllStores } from '../../../tests/helpers/store'; import AuditLogPanel from './AuditLogPanel'; @@ -44,7 +44,7 @@ describe('AuditLogPanel', () => { http.get('/api/admin/audit-log', async () => { await new Promise(() => {}); // never resolves return HttpResponse.json({ entries: [], total: 0 }); - }), + }) ); render(); expect(screen.getByText('Loading...')).toBeInTheDocument(); @@ -52,22 +52,14 @@ describe('AuditLogPanel', () => { }); it('FE-ADMIN-AUDIT-002: empty state shown when no entries', async () => { - server.use( - http.get('/api/admin/audit-log', () => - HttpResponse.json({ entries: [], total: 0 }), - ), - ); + server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [], total: 0 }))); render(); await screen.findByText('No audit entries yet.'); expect(document.querySelector('table')).not.toBeInTheDocument(); }); it('FE-ADMIN-AUDIT-003: table renders all columns with data', async () => { - server.use( - http.get('/api/admin/audit-log', () => - HttpResponse.json({ entries: [ENTRY_1], total: 1 }), - ), - ); + server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [ENTRY_1], total: 1 }))); render(); await screen.findByText('trip.create'); expect(screen.getByText('Time')).toBeInTheDocument(); @@ -89,11 +81,7 @@ describe('AuditLogPanel', () => { { ...ENTRY_1, id: 12, username: null, user_email: null, user_id: 7, action: 'a.id' }, { ...ENTRY_1, id: 13, username: null, user_email: null, user_id: null, action: 'a.none' }, ]; - server.use( - http.get('/api/admin/audit-log', () => - HttpResponse.json({ entries, total: 4 }), - ), - ); + server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries, total: 4 }))); render(); await screen.findByText('a.username'); expect(screen.getByText('alice')).toBeInTheDocument(); @@ -121,9 +109,7 @@ describe('AuditLogPanel', () => { details: {}, }; server.use( - http.get('/api/admin/audit-log', () => - HttpResponse.json({ entries: [entry, entryEmptyDetails], total: 2 }), - ), + http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [entry, entryEmptyDetails], total: 2 })) ); render(); await screen.findByText('a.nulls'); @@ -133,11 +119,7 @@ describe('AuditLogPanel', () => { }); it('FE-ADMIN-AUDIT-006: showing count text reflects count and total', async () => { - server.use( - http.get('/api/admin/audit-log', () => - HttpResponse.json({ entries: [ENTRY_1], total: 50 }), - ), - ); + server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [ENTRY_1], total: 50 }))); render(); await screen.findByText('trip.create'); expect(screen.getByText('1 loaded · 50 total')).toBeInTheDocument(); @@ -152,7 +134,7 @@ describe('AuditLogPanel', () => { return HttpResponse.json({ entries: [ENTRY_1], total: 2 }); } return HttpResponse.json({ entries: [ENTRY_2], total: 2 }); - }), + }) ); const user = userEvent.setup(); render(); @@ -166,11 +148,7 @@ describe('AuditLogPanel', () => { }); it('FE-ADMIN-AUDIT-008: "Load more" hidden when all entries loaded', async () => { - server.use( - http.get('/api/admin/audit-log', () => - HttpResponse.json({ entries: [ENTRY_1, ENTRY_2], total: 2 }), - ), - ); + server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [ENTRY_1, ENTRY_2], total: 2 }))); render(); await screen.findByText('trip.create'); expect(screen.queryByText('Load more')).not.toBeInTheDocument(); @@ -191,7 +169,7 @@ describe('AuditLogPanel', () => { return HttpResponse.json({ entries: [PAGE2_ENTRY], total: 2 }); } return HttpResponse.json({ entries: [REFRESH_ENTRY], total: 1 }); - }), + }) ); const user = userEvent.setup(); render(); @@ -214,7 +192,7 @@ describe('AuditLogPanel', () => { http.get('/api/admin/audit-log', async () => { await new Promise(() => {}); // never resolves return HttpResponse.json({ entries: [], total: 0 }); - }), + }) ); render(); const refreshBtn = screen.getByText('Refresh'); diff --git a/client/src/components/Admin/AuditLogPanel.tsx b/client/src/components/Admin/AuditLogPanel.tsx index 2e6e4fa6..f1cf0d2e 100644 --- a/client/src/components/Admin/AuditLogPanel.tsx +++ b/client/src/components/Admin/AuditLogPanel.tsx @@ -1,72 +1,72 @@ -import React, { useCallback, useEffect, useState } from 'react' -import { adminApi } from '../../api/client' -import { useTranslation } from '../../i18n' -import { RefreshCw, ClipboardList } from 'lucide-react' +import { ClipboardList, RefreshCw } from 'lucide-react'; +import React, { useCallback, useEffect, useState } from 'react'; +import { adminApi } from '../../api/client'; +import { useTranslation } from '../../i18n'; interface AuditEntry { - id: number - created_at: string - user_id: number | null - username: string | null - user_email: string | null - action: string - resource: string | null - details: Record | null - ip: string | null + id: number; + created_at: string; + user_id: number | null; + username: string | null; + user_email: string | null; + action: string; + resource: string | null; + details: Record | null; + ip: string | null; } interface AuditLogPanelProps { - serverTimezone?: string + serverTimezone?: string; } export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): React.ReactElement { - const { t, locale } = useTranslation() - const [entries, setEntries] = useState([]) - const [total, setTotal] = useState(0) - const [offset, setOffset] = useState(0) - const [loading, setLoading] = useState(true) - const limit = 100 + const { t, locale } = useTranslation(); + const [entries, setEntries] = useState([]); + const [total, setTotal] = useState(0); + const [offset, setOffset] = useState(0); + const [loading, setLoading] = useState(true); + const limit = 100; const loadFirstPage = useCallback(async () => { - setLoading(true) + setLoading(true); try { - const data = await adminApi.auditLog({ limit, offset: 0 }) as { - entries: AuditEntry[] - total: number - } - setEntries(data.entries || []) - setTotal(data.total ?? 0) - setOffset(0) + const data = (await adminApi.auditLog({ limit, offset: 0 })) as { + entries: AuditEntry[]; + total: number; + }; + setEntries(data.entries || []); + setTotal(data.total ?? 0); + setOffset(0); } catch { - setEntries([]) - setTotal(0) - setOffset(0) + setEntries([]); + setTotal(0); + setOffset(0); } finally { - setLoading(false) + setLoading(false); } - }, []) + }, []); const loadMore = useCallback(async () => { - const nextOffset = offset + limit - setLoading(true) + const nextOffset = offset + limit; + setLoading(true); try { - const data = await adminApi.auditLog({ limit, offset: nextOffset }) as { - entries: AuditEntry[] - total: number - } - setEntries((prev) => [...prev, ...(data.entries || [])]) - setTotal(data.total ?? 0) - setOffset(nextOffset) + const data = (await adminApi.auditLog({ limit, offset: nextOffset })) as { + entries: AuditEntry[]; + total: number; + }; + setEntries((prev) => [...prev, ...(data.entries || [])]); + setTotal(data.total ?? 0); + setOffset(nextOffset); } catch { /* keep existing */ } finally { - setLoading(false) + setLoading(false); } - }, [offset]) + }, [offset]); useEffect(() => { - loadFirstPage() - }, [loadFirstPage]) + loadFirstPage(); + }, [loadFirstPage]); const fmtTime = (iso: string) => { try { @@ -74,43 +74,45 @@ export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): R dateStyle: 'short', timeStyle: 'medium', timeZone: serverTimezone || undefined, - }) + }); } catch { - return iso + return iso; } - } + }; const fmtDetails = (d: Record | null) => { - if (!d || Object.keys(d).length === 0) return '—' + if (!d || Object.keys(d).length === 0) return '—'; try { - return JSON.stringify(d) + return JSON.stringify(d); } catch { - return '—' + return '—'; } - } + }; const userLabel = (e: AuditEntry) => { - if (e.username) return e.username - if (e.user_email) return e.user_email - if (e.user_id != null) return `#${e.user_id}` - return '—' - } + if (e.username) return e.username; + if (e.user_email) return e.user_email; + if (e.user_id != null) return `#${e.user_id}`; + return '—'; + }; return (
-

+

{t('admin.tabs.audit')}

-

{t('admin.audit.subtitle')}

+

+ {t('admin.audit.subtitle')} +

-

+

{t('admin.audit.showing', { count: entries.length, total })}

{loading && entries.length === 0 ? ( -
{t('common.loading')}
+
+ {t('common.loading')} +
) : entries.length === 0 ? ( -
{t('admin.audit.empty')}
+
+ {t('admin.audit.empty')} +
) : ( -
- +
+
- - - - - - + + + + + + {entries.map((e) => ( - - - - - - + + + + + + ))} @@ -167,5 +200,5 @@ export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): R )} - ) + ); } diff --git a/client/src/components/Admin/BackupPanel.test.tsx b/client/src/components/Admin/BackupPanel.test.tsx index 21011795..270b2a50 100644 --- a/client/src/components/Admin/BackupPanel.test.tsx +++ b/client/src/components/Admin/BackupPanel.test.tsx @@ -1,23 +1,23 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render' -import userEvent from '@testing-library/user-event' -import { resetAllStores, seedStore } from '../../../tests/helpers/store' -import { useSettingsStore } from '../../store/settingsStore' -import { server } from '../../../tests/helpers/msw/server' -import { http, HttpResponse } from 'msw' -import BackupPanel from './BackupPanel' -import { ToastContainer } from '../shared/Toast' +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { server } from '../../../tests/helpers/msw/server'; +import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { useSettingsStore } from '../../store/settingsStore'; +import { ToastContainer } from '../shared/Toast'; +import BackupPanel from './BackupPanel'; const manualBackup = { filename: 'backup-2025-01-15.zip', created_at: '2025-01-15T10:00:00Z', size: 2048000, -} +}; const autoBackup = { filename: 'auto-backup-2025-02-01.zip', created_at: '2025-02-01T02:00:00Z', size: 1024000, -} +}; function defaultBackupHandlers() { return [ @@ -26,288 +26,300 @@ function defaultBackupHandlers() { HttpResponse.json({ settings: { enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 }, timezone: 'UTC', - }), + }) ), - ] + ]; } function getToggleButton() { // The enable toggle is a {/* Upload & Restore */} - + @@ -225,13 +248,13 @@ export default function BackupPanel() { @@ -240,63 +263,69 @@ export default function BackupPanel() { {isLoading && backups.length === 0 ? (
-
+
{t('common.loading')}
) : backups.length === 0 ? ( -
- +
+

{t('backup.empty')}

-
) : (
- {backups.map(backup => ( + {backups.map((backup) => (
-
- {isAuto(backup.filename) - ? - : - } +
+ {isAuto(backup.filename) ? ( + + ) : ( + + )}
-
+
-

{backup.filename}

+

{backup.filename}

{isAuto(backup.filename) && ( - Auto + + Auto + )}
-
+
{formatDate(backup.created_at)} {formatSize(backup.size)}
-
+
@@ -306,29 +335,35 @@ export default function BackupPanel() {
{/* Auto-Backup Settings */} -
-
- +
+
+
-

{t('backup.auto.title')}

-

{t('backup.auto.subtitle')}

+

+ {t('backup.auto.title')} +

+

+ {t('backup.auto.subtitle')} +

{/* Enable toggle */} -
+ + + - - - - ) + ); } // ── Chip with custom tooltip ───────────────────────────────────────────────── interface ChipWithTooltipProps { - label: string - avatarUrl: string | null - size?: number - paid?: boolean - onClick?: () => void + label: string; + avatarUrl: string | null; + size?: number; + paid?: boolean; + onClick?: () => void; } function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: ChipWithTooltipProps) { - const [hover, setHover] = useState(false) - const [pos, setPos] = useState({ top: 0, left: 0 }) - const ref = useRef(null) + const [hover, setHover] = useState(false); + const [pos, setPos] = useState({ top: 0, left: 0 }); + const ref = useRef(null); const onEnter = () => { if (ref.current) { - const rect = ref.current.getBoundingClientRect() - setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 }) + const rect = ref.current.getBoundingClientRect(); + setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 }); } - setHover(true) - } + setHover(true); + }; - const borderColor = paid ? '#22c55e' : 'var(--border-primary)' - const bg = paid ? 'rgba(34,197,94,0.15)' : 'var(--bg-tertiary)' + const borderColor = paid ? '#22c55e' : 'var(--border-primary)'; + const bg = paid ? 'rgba(34,197,94,0.15)' : 'var(--bg-tertiary)'; return ( <> -
setHover(false)} +
setHover(false)} onClick={onClick} style={{ - width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`, - background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center', - fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)', - overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default', + width: size, + height: size, + borderRadius: '50%', + border: `2px solid ${borderColor}`, + background: bg, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: size * 0.4, + fontWeight: 700, + color: paid ? '#16a34a' : 'var(--text-muted)', + overflow: 'hidden', + flexShrink: 0, + cursor: onClick ? 'pointer' : 'default', transition: 'border-color 0.15s, background 0.15s', - }}> - {avatarUrl - ? - : label?.[0]?.toUpperCase() - } + }} + > + {avatarUrl ? ( + + ) : ( + label?.[0]?.toUpperCase() + )}
- {hover && ReactDOM.createPortal( -
- {label} - {paid && ( - Paid - )} -
, - document.body - )} + {hover && + ReactDOM.createPortal( +
+ {label} + {paid && ( + + Paid + + )} +
, + document.body + )} - ) + ); } // ── Budget Member Chips (for Persons column) ──────────────────────────────── interface BudgetMemberChipsProps { - members?: BudgetMember[] - tripMembers?: TripMember[] - onSetMembers: (memberIds: number[]) => void - onTogglePaid?: (userId: number, paid: boolean) => void - compact?: boolean - readOnly?: boolean + members?: BudgetMember[]; + tripMembers?: TripMember[]; + onSetMembers: (memberIds: number[]) => void; + onTogglePaid?: (userId: number, paid: boolean) => void; + compact?: boolean; + readOnly?: boolean; } -function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true, readOnly = false }: BudgetMemberChipsProps) { - const chipSize = compact ? 20 : 30 - const btnSize = compact ? 18 : 28 - const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14) - const [showDropdown, setShowDropdown] = useState(false) - const [dropPos, setDropPos] = useState({ top: 0, left: 0 }) - const btnRef = useRef(null) - const dropRef = useRef(null) +function BudgetMemberChips({ + members = [], + tripMembers = [], + onSetMembers, + onTogglePaid, + compact = true, + readOnly = false, +}: BudgetMemberChipsProps) { + const chipSize = compact ? 20 : 30; + const btnSize = compact ? 18 : 28; + const iconSize = compact ? (members.length > 0 ? 8 : 9) : members.length > 0 ? 12 : 14; + const [showDropdown, setShowDropdown] = useState(false); + const [dropPos, setDropPos] = useState({ top: 0, left: 0 }); + const btnRef = useRef(null); + const dropRef = useRef(null); const openDropdown = useCallback(() => { if (btnRef.current) { - const rect = btnRef.current.getBoundingClientRect() - setDropPos({ top: rect.bottom + 4, left: rect.left + rect.width / 2 }) + const rect = btnRef.current.getBoundingClientRect(); + setDropPos({ top: rect.bottom + 4, left: rect.left + rect.width / 2 }); } - setShowDropdown(v => !v) - }, []) + setShowDropdown((v) => !v); + }, []); useEffect(() => { - if (!showDropdown) return + if (!showDropdown) return; const close = (e) => { - if (dropRef.current && dropRef.current.contains(e.target)) return - if (btnRef.current && btnRef.current.contains(e.target)) return - setShowDropdown(false) - } - document.addEventListener('mousedown', close) - return () => document.removeEventListener('mousedown', close) - }, [showDropdown]) + if (dropRef.current && dropRef.current.contains(e.target)) return; + if (btnRef.current && btnRef.current.contains(e.target)) return; + setShowDropdown(false); + }; + document.addEventListener('mousedown', close); + return () => document.removeEventListener('mousedown', close); + }, [showDropdown]); - const memberIds = members.map(m => m.user_id) + const memberIds = members.map((m) => m.user_id); const toggleMember = (userId) => { - const newIds = memberIds.includes(userId) - ? memberIds.filter(id => id !== userId) - : [...memberIds, userId] - onSetMembers(newIds) - } + const newIds = memberIds.includes(userId) ? memberIds.filter((id) => id !== userId) : [...memberIds, userId]; + onSetMembers(newIds); + }; return (
- {members.map(m => ( - ( + onTogglePaid(m.user_id, !m.paid) : undefined} /> ))} {!readOnly && ( - )} - {showDropdown && ReactDOM.createPortal( -
- {tripMembers.map(tm => { - const isActive = memberIds.includes(tm.id) - return ( - - ) - })} -
, - document.body - )} + {showDropdown && + ReactDOM.createPortal( +
+ {tripMembers.map((tm) => { + const isActive = memberIds.includes(tm.id); + return ( + + ); + })} +
, + document.body + )}
- ) + ); } // ── Per-Person Inline (inside total card) ──────────────────────────────────── interface PerPersonInlineProps { - tripId: number - budgetItems: BudgetItem[] - currency: string - locale: string + tripId: number; + budgetItems: BudgetItem[]; + currency: string; + locale: string; } const SPLIT_COLORS = [ @@ -430,302 +812,540 @@ const SPLIT_COLORS = [ { solid: '#f59e0b', gradient: 'linear-gradient(135deg, #f59e0b, #f97316)' }, { solid: '#06b6d4', gradient: 'linear-gradient(135deg, #06b6d4, #3b82f6)' }, { solid: '#a855f7', gradient: 'linear-gradient(135deg, #a855f7, #d946ef)' }, -] +]; export function splitColorFor(userId: number, order: number) { - return SPLIT_COLORS[order % SPLIT_COLORS.length] + return SPLIT_COLORS[order % SPLIT_COLORS.length]; } function colorForUserId(userId: number) { - return SPLIT_COLORS[((userId | 0) - 1 + SPLIT_COLORS.length * 1000) % SPLIT_COLORS.length] + return SPLIT_COLORS[((userId | 0) - 1 + SPLIT_COLORS.length * 1000) % SPLIT_COLORS.length]; } -function RingAvatar({ userId, username, avatarUrl, size = 34, innerBg = '#17171d', textColor = '#fff' }: { userId: number; username?: string; avatarUrl?: string | null; size?: number; innerBg?: string; textColor?: string }) { - const color = colorForUserId(userId) +function RingAvatar({ + userId, + username, + avatarUrl, + size = 34, + innerBg = '#17171d', + textColor = '#fff', +}: { + userId: number; + username?: string; + avatarUrl?: string | null; + size?: number; + innerBg?: string; + textColor?: string; +}) { + const color = colorForUserId(userId); return ( -
-
- {avatarUrl ? : username?.[0]?.toUpperCase()} +
+
+ {avatarUrl ? ( + + ) : ( + username?.[0]?.toUpperCase() + )}
- ) + ); } -function PerPersonInline({ tripId, budgetItems, currency, locale, grandTotal, theme }: PerPersonInlineProps & { grandTotal: number; theme: ReturnType }) { - const [data, setData] = useState(null) - const fmt = (v: number) => fmtNum(v, locale, currency) +function PerPersonInline({ + tripId, + budgetItems, + currency, + locale, + grandTotal, + theme, +}: PerPersonInlineProps & { grandTotal: number; theme: ReturnType }) { + const [data, setData] = useState(null); + const fmt = (v: number) => fmtNum(v, locale, currency); useEffect(() => { - budgetApi.perPersonSummary(tripId).then(d => setData(d.summary)).catch(() => {}) - }, [tripId, budgetItems]) + budgetApi + .perPersonSummary(tripId) + .then((d) => setData(d.summary)) + .catch(() => {}); + }, [tripId, budgetItems]); - if (!data || data.length === 0) return null + if (!data || data.length === 0) return null; - const people = data.map((p: any) => ({ ...p, color: colorForUserId(p.user_id) })) + const people = data.map((p: any) => ({ ...p, color: colorForUserId(p.user_id) })); return ( <> {grandTotal > 0 && ( -
- {people.map(p => ( -
+
+ {people.map((p) => ( +
))}
)} -
- {people.map(p => { - const percent = grandTotal > 0 ? Math.round((p.total_assigned / grandTotal) * 100) : 0 +
+ {people.map((p) => { + const percent = grandTotal > 0 ? Math.round((p.total_assigned / grandTotal) * 100) : 0; return (
- +
-
{p.username}
+
+ {p.username} +
{percent}%
-
{fmt(p.total_assigned)}
+
+ {fmt(p.total_assigned)} +
- ) + ); })}
- ) + ); } // ── Pie Chart (pure CSS conic-gradient) ────────────────────────────────────── interface PieChartProps { - segments: PieSegment[] - size?: number - totalLabel: string + segments: PieSegment[]; + size?: number; + totalLabel: string; } function PieChart({ segments, size = 200, totalLabel }: PieChartProps) { - if (!segments.length) return null + if (!segments.length) return null; - const total = segments.reduce((s, x) => s + x.value, 0) - if (total === 0) return null + const total = segments.reduce((s, x) => s + x.value, 0); + if (total === 0) return null; - let cumDeg = 0 - const stops = segments.map(seg => { - const start = cumDeg - const deg = (seg.value / total) * 360 - cumDeg += deg - return `${seg.color} ${start}deg ${start + deg}deg` - }).join(', ') + let cumDeg = 0; + const stops = segments + .map((seg) => { + const start = cumDeg; + const deg = (seg.value / total) * 360; + cumDeg += deg; + return `${seg.color} ${start}deg ${start + deg}deg`; + }) + .join(', '); return (
-
+
{totalLabel}
- ) + ); } // ── Main Component ─────────────────────────────────────────────────────────── interface BudgetPanelProps { - tripId: number - tripMembers?: TripMember[] + tripId: number; + tripMembers?: TripMember[]; } export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) { - const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories } = useTripStore() - const can = useCanDo() - const { t, locale } = useTranslation() - const isDark = useIsDark() - const theme = useMemo(() => widgetTheme(isDark), [isDark]) - const [newCategoryName, setNewCategoryName] = useState('') - const [editingCat, setEditingCat] = useState(null) // { name, value } - const [settlement, setSettlement] = useState<{ balances: any[]; flows: any[] } | null>(null) - const [settlementOpen, setSettlementOpen] = useState(false) - const currency = trip?.currency || 'EUR' - const canEdit = can('budget_edit', trip) + const { + trip, + budgetItems, + addBudgetItem, + updateBudgetItem, + deleteBudgetItem, + loadBudgetItems, + updateTrip, + setBudgetItemMembers, + toggleBudgetMemberPaid, + reorderBudgetItems, + reorderBudgetCategories, + } = useTripStore(); + const can = useCanDo(); + const { t, locale } = useTranslation(); + const isDark = useIsDark(); + const theme = useMemo(() => widgetTheme(isDark), [isDark]); + const [newCategoryName, setNewCategoryName] = useState(''); + const [editingCat, setEditingCat] = useState(null); // { name, value } + const [settlement, setSettlement] = useState<{ balances: any[]; flows: any[] } | null>(null); + const [settlementOpen, setSettlementOpen] = useState(false); + const currency = trip?.currency || 'EUR'; + const canEdit = can('budget_edit', trip); - const fmt = (v, cur) => fmtNum(v, locale, cur) - const hasMultipleMembers = tripMembers.length > 1 + const fmt = (v, cur) => fmtNum(v, locale, cur); + const hasMultipleMembers = tripMembers.length > 1; // Drag state for categories - const [dragCat, setDragCat] = useState(null) - const [dragOverCat, setDragOverCat] = useState(null) + const [dragCat, setDragCat] = useState(null); + const [dragOverCat, setDragOverCat] = useState(null); // Drag state for items within a category - const [dragItem, setDragItem] = useState(null) - const [dragOverItem, setDragOverItem] = useState(null) - const [dragItemCat, setDragItemCat] = useState(null) + const [dragItem, setDragItem] = useState(null); + const [dragOverItem, setDragOverItem] = useState(null); + const [dragItemCat, setDragItemCat] = useState(null); // Load settlement data whenever budget items change useEffect(() => { - if (!hasMultipleMembers) return - budgetApi.settlement(tripId).then(setSettlement).catch(() => {}) - }, [tripId, budgetItems, hasMultipleMembers]) + if (!hasMultipleMembers) return; + budgetApi + .settlement(tripId) + .then(setSettlement) + .catch(() => {}); + }, [tripId, budgetItems, hasMultipleMembers]); const setCurrency = (cur) => { - if (tripId) updateTrip(tripId, { currency: cur }) - } + if (tripId) updateTrip(tripId, { currency: cur }); + }; - useEffect(() => { if (tripId) loadBudgetItems(tripId) }, [tripId]) + useEffect(() => { + if (tripId) loadBudgetItems(tripId); + }, [tripId]); const grouped = useMemo(() => { - const map = new Map() - for (const item of (budgetItems || [])) { - const cat = item.category || 'Other' - if (!map.has(cat)) map.set(cat, []) - map.get(cat)!.push(item) + const map = new Map(); + for (const item of budgetItems || []) { + const cat = item.category || 'Other'; + if (!map.has(cat)) map.set(cat, []); + map.get(cat)!.push(item); } - return map - }, [budgetItems]) + return map; + }, [budgetItems]); - const categoryNames = Array.from(grouped.keys()) + const categoryNames = Array.from(grouped.keys()); // Stable color mapping: assign index-based colors once, never reassign on reorder - const colorMapRef = useRef(new Map()) + const colorMapRef = useRef(new Map()); const categoryColor = useCallback((cat: string) => { - const map = colorMapRef.current + const map = colorMapRef.current; if (!map.has(cat)) { - map.set(cat, PIE_COLORS[map.size % PIE_COLORS.length]) + map.set(cat, PIE_COLORS[map.size % PIE_COLORS.length]); } - return map.get(cat)! - }, []) - const grandTotal = (budgetItems || []).reduce((s, i) => s + (i.total_price || 0), 0) + return map.get(cat)!; + }, []); + const grandTotal = (budgetItems || []).reduce((s, i) => s + (i.total_price || 0), 0); - const pieSegments = useMemo(() => - categoryNames.map((cat, i) => ({ - name: cat, - value: (grouped.get(cat) || []).reduce((s, x) => s + (x.total_price || 0), 0), - color: categoryColor(cat), - })).filter(s => s.value > 0) - , [grouped, categoryNames]) + const pieSegments = useMemo( + () => + categoryNames + .map((cat, i) => ({ + name: cat, + value: (grouped.get(cat) || []).reduce((s, x) => s + (x.total_price || 0), 0), + color: categoryColor(cat), + })) + .filter((s) => s.value > 0), + [grouped, categoryNames] + ); - const handleAddItem = async (category, data) => { try { await addBudgetItem(tripId, { ...data, category }) } catch {} } - const handleUpdateField = async (id, field, value) => { try { await updateBudgetItem(tripId, id, { [field]: value }) } catch {} } - const handleDeleteItem = async (id) => { try { await deleteBudgetItem(tripId, id) } catch {} } + const handleAddItem = async (category, data) => { + try { + await addBudgetItem(tripId, { ...data, category }); + } catch {} + }; + const handleUpdateField = async (id, field, value) => { + try { + await updateBudgetItem(tripId, id, { [field]: value }); + } catch {} + }; + const handleDeleteItem = async (id) => { + try { + await deleteBudgetItem(tripId, id); + } catch {} + }; const handleDeleteCategory = async (cat) => { - const items = grouped.get(cat) || [] - for (const item of Array.from(items)) await deleteBudgetItem(tripId, item.id) - } + const items = grouped.get(cat) || []; + for (const item of Array.from(items)) await deleteBudgetItem(tripId, item.id); + }; const handleRenameCategory = async (oldName, newName) => { - if (!newName.trim() || newName.trim() === oldName) return - const items = grouped.get(oldName) || [] - for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() }) - } + if (!newName.trim() || newName.trim() === oldName) return; + const items = grouped.get(oldName) || []; + for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() }); + }; const handleAddCategory = () => { - if (!newCategoryName.trim()) return - addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 }) - setNewCategoryName('') - } + if (!newCategoryName.trim()) return; + addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 }); + setNewCategoryName(''); + }; const handleExportCsv = () => { - const sep = ';' - const esc = (v: any) => { const s = String(v ?? ''); return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s } - const d = currencyDecimals(currency) - const fmtPrice = (v: number | null | undefined) => v != null ? v.toFixed(d) : '' + const sep = ';'; + const esc = (v: any) => { + const s = String(v ?? ''); + return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s; + }; + const d = currencyDecimals(currency); + const fmtPrice = (v: number | null | undefined) => (v != null ? v.toFixed(d) : ''); - const fmtDate = (iso: string) => { if (!iso) return ''; const d = new Date(iso + 'T00:00:00Z'); return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) } - const header = ['Category', 'Name', 'Date', 'Total (' + currency + ')', 'Persons', 'Days', 'Per Person', 'Per Day', 'Per Person/Day', 'Note'] - const rows = [header.join(sep)] + const fmtDate = (iso: string) => { + if (!iso) return ''; + const d = new Date(iso + 'T00:00:00Z'); + return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }); + }; + const header = [ + 'Category', + 'Name', + 'Date', + 'Total (' + currency + ')', + 'Persons', + 'Days', + 'Per Person', + 'Per Day', + 'Per Person/Day', + 'Note', + ]; + const rows = [header.join(sep)]; for (const cat of categoryNames) { - for (const item of (grouped.get(cat) || [])) { - const pp = calcPP(item.total_price, item.persons) - const pd = calcPD(item.total_price, item.days) - const ppd = calcPPD(item.total_price, item.persons, item.days) - rows.push([ - esc(item.category), esc(item.name), esc(fmtDate(item.expense_date || '')), - fmtPrice(item.total_price), item.persons ?? '', item.days ?? '', - fmtPrice(pp), fmtPrice(pd), fmtPrice(ppd), - esc(item.note || ''), - ].join(sep)) + for (const item of grouped.get(cat) || []) { + const pp = calcPP(item.total_price, item.persons); + const pd = calcPD(item.total_price, item.days); + const ppd = calcPPD(item.total_price, item.persons, item.days); + rows.push( + [ + esc(item.category), + esc(item.name), + esc(fmtDate(item.expense_date || '')), + fmtPrice(item.total_price), + item.persons ?? '', + item.days ?? '', + fmtPrice(pp), + fmtPrice(pd), + fmtPrice(ppd), + esc(item.note || ''), + ].join(sep) + ); } } - const bom = '\uFEFF' - const blob = new Blob([bom + rows.join('\r\n')], { type: 'text/csv;charset=utf-8;' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - const safeName = (trip?.title || 'trip').replace(/[^a-zA-Z0-9\u00C0-\u024F _-]/g, '').trim() - a.download = `budget-${safeName}.csv` - a.click() - URL.revokeObjectURL(url) - } + const bom = '\uFEFF'; + const blob = new Blob([bom + rows.join('\r\n')], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + const safeName = (trip?.title || 'trip').replace(/[^a-zA-Z0-9\u00C0-\u024F _-]/g, '').trim(); + a.download = `budget-${safeName}.csv`; + a.click(); + URL.revokeObjectURL(url); + }; - const th = { padding: '6px 8px', textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '2px solid var(--border-primary)', whiteSpace: 'nowrap', background: 'var(--bg-secondary)' } - const td = { padding: '2px 6px', borderBottom: '1px solid var(--border-secondary)', fontSize: 13, verticalAlign: 'middle', color: 'var(--text-primary)' } + const th = { + padding: '6px 8px', + textAlign: 'center', + fontSize: 11, + fontWeight: 600, + color: 'var(--text-muted)', + textTransform: 'uppercase', + letterSpacing: '0.05em', + borderBottom: '2px solid var(--border-primary)', + whiteSpace: 'nowrap', + background: 'var(--bg-secondary)', + }; + const td = { + padding: '2px 6px', + borderBottom: '1px solid var(--border-secondary)', + fontSize: 13, + verticalAlign: 'middle', + color: 'var(--text-primary)', + }; // ── Empty State ────────────────────────────────────────────────────────── if (!budgetItems || budgetItems.length === 0) { return (
-
+
-

{t('budget.emptyTitle')}

-

{t('budget.emptyText')}

+

+ {t('budget.emptyTitle')} +

+

+ {t('budget.emptyText')} +

{canEdit && ( -
- setNewCategoryName(e.target.value)} - onKeyDown={e => e.key === 'Enter' && handleAddCategory()} +
+ setNewCategoryName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleAddCategory()} placeholder={t('budget.emptyPlaceholder')} - style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} /> -
)}
- ) + ); } // ── Main Layout ────────────────────────────────────────────────────────── - const totalBudget = budgetItems.reduce((s, x) => s + (x.total_price || 0), 0) + const totalBudget = budgetItems.reduce((s, x) => s + (x.total_price || 0), 0); return (
-
-

+
+

{t('budget.title')}

-
+
({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))} + options={CURRENCIES.map((c) => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))} searchable />
@@ -733,35 +1353,73 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
setNewCategoryName(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }} + onChange={(e) => setNewCategoryName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleAddCategory(); + }} placeholder={t('budget.categoryName')} - style={{ flex: 1, minWidth: 0, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-primary)' }} + style={{ + flex: 1, + minWidth: 0, + border: '1px solid var(--border-primary)', + borderRadius: 10, + padding: '9px 14px', + fontSize: 13, + outline: 'none', + fontFamily: 'inherit', + background: 'var(--bg-card)', + color: 'var(--text-primary)', + }} /> -
)} - @@ -769,50 +1427,96 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
-
+
{categoryNames.map((cat, ci) => { - const items = grouped.get(cat) || [] - const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0) - const color = categoryColor(cat) + const items = grouped.get(cat) || []; + const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0); + const color = categoryColor(cat); return ( -
{ - if (!dragCat || dragCat === cat || dragItem) return - e.preventDefault(); e.dataTransfer.dropEffect = 'move' - setDragOverCat(cat) + onDragOver={(e) => { + if (!dragCat || dragCat === cat || dragItem) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDragOverCat(cat); }} - onDragLeave={e => { - if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOverCat(null) + onDragLeave={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOverCat(null); }} - onDrop={e => { - e.preventDefault() + onDrop={(e) => { + e.preventDefault(); if (dragCat && dragCat !== cat) { - const newOrder = [...categoryNames] - const fromIdx = newOrder.indexOf(dragCat) - const toIdx = newOrder.indexOf(cat) - newOrder.splice(fromIdx, 1) - newOrder.splice(toIdx, 0, dragCat) - reorderBudgetCategories(tripId, newOrder) + const newOrder = [...categoryNames]; + const fromIdx = newOrder.indexOf(dragCat); + const toIdx = newOrder.indexOf(cat); + newOrder.splice(fromIdx, 1); + newOrder.splice(toIdx, 0, dragCat); + reorderBudgetCategories(tripId, newOrder); } - setDragCat(null); setDragOverCat(null) + setDragCat(null); + setDragOverCat(null); }} > - {dragOverCat === cat &&
} -
+ {dragOverCat === cat && ( +
+ )} +
{canEdit && ( -
{ e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/x-budget-cat', cat); setDragCat(cat) }} - onDragEnd={() => { setDragCat(null); setDragOverCat(null) }} - style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}> +
{ + e.stopPropagation(); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/x-budget-cat', cat); + setDragCat(cat); + }} + onDragEnd={() => { + setDragCat(null); + setDragOverCat(null); + }} + style={{ + cursor: 'grab', + display: 'flex', + alignItems: 'center', + color: 'rgba(255,255,255,0.4)', + flexShrink: 0, + }} + >
)} @@ -821,18 +1525,48 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro setEditingCat({ ...editingCat, value: e.target.value })} - onBlur={() => { handleRenameCategory(cat, editingCat.value); setEditingCat(null) }} - onKeyDown={e => { if (e.key === 'Enter') { handleRenameCategory(cat, editingCat.value); setEditingCat(null) } if (e.key === 'Escape') setEditingCat(null) }} - style={{ fontWeight: 600, fontSize: 13, background: 'rgba(255,255,255,0.15)', border: 'none', borderRadius: 4, color: '#fff', padding: '1px 6px', outline: 'none', fontFamily: 'inherit', width: '100%' }} + onChange={(e) => setEditingCat({ ...editingCat, value: e.target.value })} + onBlur={() => { + handleRenameCategory(cat, editingCat.value); + setEditingCat(null); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleRenameCategory(cat, editingCat.value); + setEditingCat(null); + } + if (e.key === 'Escape') setEditingCat(null); + }} + style={{ + fontWeight: 600, + fontSize: 13, + background: 'rgba(255,255,255,0.15)', + border: 'none', + borderRadius: 4, + color: '#fff', + padding: '1px 6px', + outline: 'none', + fontFamily: 'inherit', + width: '100%', + }} /> ) : ( <> {cat} {canEdit && ( - )} @@ -842,82 +1576,166 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
{fmt(subtotal, currency)} {canEdit && ( - )}
-
{ if (dragCat) { e.preventDefault(); e.dataTransfer.dropEffect = 'move' } }}> +
{ + if (dragCat) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + } + }} + >

{t('admin.audit.col.time')}{t('admin.audit.col.user')}{t('admin.audit.col.action')}{t('admin.audit.col.resource')}{t('admin.audit.col.ip')}{t('admin.audit.col.details')} + {t('admin.audit.col.time')} + + {t('admin.audit.col.user')} + + {t('admin.audit.col.action')} + + {t('admin.audit.col.resource')} + + {t('admin.audit.col.ip')} + + {t('admin.audit.col.details')} +
{fmtTime(e.created_at)}{userLabel(e)}{e.action}{e.resource || '—'}{e.ip || '—'}{fmtDetails(e.details)} + {fmtTime(e.created_at)} + + {userLabel(e)} + + {e.action} + + {e.resource || '—'} + + {e.ip || '—'} + + {fmtDetails(e.details)} +
- setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} - placeholder={t('budget.newEntry')} style={inp} /> + setName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleAdd()} + placeholder={t('budget.newEntry')} + style={inp} + /> - setPrice(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} - onPaste={e => { e.preventDefault(); let t = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = t.lastIndexOf(','), ld = t.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { t = t.substring(0, dp).replace(/[.,]/g, '') + '.' + t.substring(dp + 1) } else { t = t.replace(/[.,]/g, '') } setPrice(t) }} - placeholder="0,00" inputMode="decimal" style={{ ...inp, textAlign: 'center' }} /> + setPrice(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleAdd()} + onPaste={(e) => { + e.preventDefault(); + let t = e.clipboardData + .getData('text') + .trim() + .replace(/[^\d.,-]/g, ''); + const lc = t.lastIndexOf(','), + ld = t.lastIndexOf('.'), + dp = Math.max(lc, ld); + if (dp > -1) { + t = t.substring(0, dp).replace(/[.,]/g, '') + '.' + t.substring(dp + 1); + } else { + t = t.replace(/[.,]/g, ''); + } + setPrice(t); + }} + placeholder="0,00" + inputMode="decimal" + style={{ ...inp, textAlign: 'center' }} + /> - setPersons(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} - placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} /> + setPersons(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleAdd()} + placeholder="-" + inputMode="numeric" + style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} + /> - setDays(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} - placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} /> + setDays(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleAdd()} + placeholder="-" + inputMode="numeric" + style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} + /> + + - + + - + + - ---
- setNote(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder={t('budget.table.note')} style={inp} /> + setNote(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleAdd()} + placeholder={t('budget.table.note')} + style={inp} + /> -
- - - - - - - + + + + + + + - {items.map(item => { - const pp = calcPP(item.total_price, item.persons) - const pd = calcPD(item.total_price, item.days) - const ppd = calcPPD(item.total_price, item.persons, item.days) - const hasMembers = item.members?.length > 0 + {items.map((item) => { + const pp = calcPP(item.total_price, item.persons); + const pd = calcPD(item.total_price, item.days); + const ppd = calcPPD(item.total_price, item.persons, item.days); + const hasMembers = item.members?.length > 0; return ( - { - if (dragCat && dragCat !== cat) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; return } - if (dragItem && dragItemCat === cat && dragItem !== item.id) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDragOverItem(item.id) } - }} - onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOverItem(null) }} - onDrop={e => { + onDragOver={(e) => { + if (dragCat && dragCat !== cat) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + return; + } if (dragItem && dragItemCat === cat && dragItem !== item.id) { - e.preventDefault(); e.stopPropagation() - const ids = items.map(i => i.id) - const fromIdx = ids.indexOf(dragItem) - const toIdx = ids.indexOf(item.id) - ids.splice(fromIdx, 1) - ids.splice(toIdx, 0, dragItem) - reorderBudgetItems(tripId, ids) - setDragItem(null); setDragOverItem(null); setDragItemCat(null) + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDragOverItem(item.id); } }} - onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} - onMouseLeave={e => e.currentTarget.style.background = 'transparent'}> + onDragLeave={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOverItem(null); + }} + onDrop={(e) => { + if (dragItem && dragItemCat === cat && dragItem !== item.id) { + e.preventDefault(); + e.stopPropagation(); + const ids = items.map((i) => i.id); + const fromIdx = ids.indexOf(dragItem); + const toIdx = ids.indexOf(item.id); + ids.splice(fromIdx, 1); + ids.splice(toIdx, 0, dragItem); + reorderBudgetItems(tripId, ids); + setDragItem(null); + setDragOverItem(null); + setDragItemCat(null); + } + }} + onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--bg-hover)')} + onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')} + > - - - - - + + + - + - ) + ); })} - {canEdit && handleAddItem(cat, data)} t={t} />} + {canEdit && handleAddItem(cat, data)} t={t} />}
{t('budget.table.name')} {t('budget.table.total')}{t('budget.table.persons')}{t('budget.table.days')}{t('budget.table.perPerson')}{t('budget.table.perDay')}{t('budget.table.perPersonDay')}{t('budget.table.date')}{t('budget.table.note')} + {t('budget.table.persons')} + + {t('budget.table.days')} + + {t('budget.table.perPerson')} + + {t('budget.table.perDay')} + + {t('budget.table.perPersonDay')} + + {t('budget.table.date')} + + {t('budget.table.note')} +
{canEdit && ( -
{ e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(item.id); setDragItemCat(cat) }} - onDragEnd={() => { setDragItem(null); setDragOverItem(null); setDragItemCat(null) }} - style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0 }}> +
{ + e.stopPropagation(); + e.dataTransfer.effectAllowed = 'move'; + setDragItem(item.id); + setDragItemCat(cat); + }} + onDragEnd={() => { + setDragItem(null); + setDragOverItem(null); + setDragItemCat(null); + }} + style={{ + cursor: 'grab', + display: 'flex', + alignItems: 'center', + color: 'var(--text-faint)', + flexShrink: 0, + }} + >
)}
- handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={item.reservation_id ? t('budget.linkedToReservation') : t('budget.editTooltip')} readOnly={!canEdit || !!item.reservation_id} /> + handleUpdateField(item.id, 'name', v)} + placeholder={t('budget.table.name')} + locale={locale} + editTooltip={ + item.reservation_id ? t('budget.linkedToReservation') : t('budget.editTooltip') + } + readOnly={!canEdit || !!item.reservation_id} + /> {hasMultipleMembers && (
setBudgetItemMembers(tripId, item.id, userIds)} - onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)} + onTogglePaid={(userId, paid) => + toggleBudgetMemberPaid(tripId, item.id, userId, paid) + } compact={false} readOnly={!canEdit} /> @@ -927,9 +1745,22 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
- handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> + handleUpdateField(item.id, 'total_price', v)} + style={{ textAlign: 'center' }} + placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} + locale={locale} + editTooltip={t('budget.editTooltip')} + readOnly={!canEdit} + /> + {hasMultipleMembers ? ( ) : ( - handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> + + handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null) + } + style={{ textAlign: 'center' }} + placeholder="-" + locale={locale} + editTooltip={t('budget.editTooltip')} + readOnly={!canEdit} + /> )} - handleUpdateField(item.id, 'days', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> + + handleUpdateField(item.id, 'days', v != null ? parseInt(v) || null : null) + } + style={{ textAlign: 'center' }} + placeholder="-" + locale={locale} + editTooltip={t('budget.editTooltip')} + readOnly={!canEdit} + /> {pp != null ? fmt(pp, currency) : '-'}{pd != null ? fmt(pd, currency) : '-'}{ppd != null ? fmt(ppd, currency) : '-'} + + {pp != null ? fmt(pp, currency) : '-'} + + {pd != null ? fmt(pd, currency) : '-'} + + {ppd != null ? fmt(ppd, currency) : '-'} + {canEdit ? (
- handleUpdateField(item.id, 'expense_date', v || null)} placeholder="—" compact borderless /> + handleUpdateField(item.id, 'expense_date', v || null)} + placeholder="—" + compact + borderless + />
) : ( - {item.expense_date || '—'} + + {item.expense_date || '—'} + )}
handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> + handleUpdateField(item.id, 'note', v)} + placeholder={t('budget.table.note')} + locale={locale} + editTooltip={t('budget.editTooltip')} + readOnly={!canEdit} + /> + {canEdit && ( - + )}
- ) + ); })}
-
- -
+
+
-
+
-
{t('budget.totalBudget')}
+
+ {t('budget.totalBudget')} +
{(() => { - const decimals = currencyDecimals(currency) - const full = Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) - const sep = (0.1).toLocaleString(locale).replace(/\d/g, '') - const [integerPart, decimalPart] = decimals > 0 ? full.split(sep) : [full, ''] + const decimals = currencyDecimals(currency); + const full = Number(grandTotal).toLocaleString(locale, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); + const sep = (0.1).toLocaleString(locale).replace(/\d/g, ''); + const [integerPart, decimalPart] = decimals > 0 ? full.split(sep) : [full, '']; return ( -
+
{integerPart} - {decimalPart && {sep}{decimalPart}} - {SYMBOLS[currency] || currency} + {decimalPart && ( + + {sep} + {decimalPart} + + )} + + {SYMBOLS[currency] || currency} +
- ) + ); })()} -
+
{currency}
- {hasMultipleMembers && (budgetItems || []).some(i => i.members?.length > 0) && ( - + {hasMultipleMembers && (budgetItems || []).some((i) => i.members?.length > 0) && ( + )} {/* Settlement dropdown inside the total card */} {hasMultipleMembers && settlement && settlement.flows.length > 0 && (
- ))} @@ -150,13 +241,30 @@ function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: EmojiPicker {/* Emoji grid */}
{EMOJI_CATEGORIES[cat].map((emoji, i) => ( - @@ -164,393 +272,608 @@ function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: EmojiPicker
, document.body - ) + ); } /* ── Reaction Quick Menu (right-click) ── */ -const QUICK_REACTIONS = ['❤️', '😂', '👍', '😮', '😢', '🔥', '👏', '🎉'] +const QUICK_REACTIONS = ['❤️', '😂', '👍', '😮', '😢', '🔥', '👏', '🎉']; interface ReactionMenuProps { - x: number - y: number - onReact: (emoji: string) => void - onClose: () => void + x: number; + y: number; + onReact: (emoji: string) => void; + onClose: () => void; } function ReactionMenu({ x, y, onReact, onClose }: ReactionMenuProps) { - const ref = useRef(null) + const ref = useRef(null); useEffect(() => { - const close = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose() } - document.addEventListener('mousedown', close) - return () => document.removeEventListener('mousedown', close) - }, [onClose]) + const close = (e) => { + if (ref.current && !ref.current.contains(e.target)) onClose(); + }; + document.addEventListener('mousedown', close); + return () => document.removeEventListener('mousedown', close); + }, [onClose]); // Clamp to viewport - const menuWidth = 156 - const clampedLeft = Math.max(menuWidth / 2 + 8, Math.min(x, window.innerWidth - menuWidth / 2 - 8)) + const menuWidth = 156; + const clampedLeft = Math.max(menuWidth / 2 + 8, Math.min(x, window.innerWidth - menuWidth / 2 - 8)); return ( -
- {QUICK_REACTIONS.map(emoji => ( - ))}
- ) + ); } /* ── Message Text with clickable URLs ── */ interface MessageTextProps { - text: string + text: string; } function MessageText({ text }: MessageTextProps) { - const parts = text.split(URL_REGEX) - const urls = text.match(URL_REGEX) || [] - const result = [] + const parts = text.split(URL_REGEX); + const urls = text.match(URL_REGEX) || []; + const result = []; parts.forEach((part, i) => { - if (part) result.push(part) - if (urls[i]) result.push( - - {urls[i]} - - ) - }) - return <>{result} + if (part) result.push(part); + if (urls[i]) + result.push( + + {urls[i]} + + ); + }); + return <>{result}; } /* ── Link Preview ── */ -const URL_REGEX = /https?:\/\/[^\s<>"']+/g -const previewCache = {} +const URL_REGEX = /https?:\/\/[^\s<>"']+/g; +const previewCache = {}; interface LinkPreviewProps { - url: string - tripId: number - own: boolean - onLoad: (() => void) | undefined + url: string; + tripId: number; + own: boolean; + onLoad: (() => void) | undefined; } function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) { - const [data, setData] = useState(previewCache[url] || null) - const [loading, setLoading] = useState(!previewCache[url]) + const [data, setData] = useState(previewCache[url] || null); + const [loading, setLoading] = useState(!previewCache[url]); useEffect(() => { - if (previewCache[url]) return - collabApi.linkPreview(tripId, url).then(d => { - previewCache[url] = d - setData(d) - setLoading(false) - if (d?.title || d?.description || d?.image) onLoad?.() - }).catch(() => setLoading(false)) - }, [url, tripId]) + if (previewCache[url]) return; + collabApi + .linkPreview(tripId, url) + .then((d) => { + previewCache[url] = d; + setData(d); + setLoading(false); + if (d?.title || d?.description || d?.image) onLoad?.(); + }) + .catch(() => setLoading(false)); + }, [url, tripId]); - if (loading || !data || (!data.title && !data.description && !data.image)) return null + if (loading || !data || (!data.title && !data.description && !data.image)) return null; - const domain = (() => { try { return new URL(url).hostname.replace('www.', '') } catch { return '' } })() + const domain = (() => { + try { + return new URL(url).hostname.replace('www.', ''); + } catch { + return ''; + } + })(); return ( - e.currentTarget.style.opacity = '0.85'} - onMouseLeave={e => e.currentTarget.style.opacity = '1'} + (e.currentTarget.style.opacity = '0.85')} + onMouseLeave={(e) => (e.currentTarget.style.opacity = '1')} > {data.image && ( - e.target.style.display = 'none'} /> + (e.target.style.display = 'none')} + /> )}
{domain && ( -
+
{data.site_name || domain}
)} {data.title && ( -
+
{data.title}
)} {data.description && ( -
+
{data.description}
)}
- ) + ); } /* ── Reaction Badge with NOMAD tooltip ── */ interface ReactionBadgeProps { - reaction: ChatReaction - currentUserId: number - onReact: () => void + reaction: ChatReaction; + currentUserId: number; + onReact: () => void; } function ReactionBadge({ reaction, currentUserId, onReact }: ReactionBadgeProps) { - const [hover, setHover] = useState(false) - const [pos, setPos] = useState({ top: 0, left: 0 }) - const ref = useRef(null) - const names = reaction.users.map(u => u.username).join(', ') + const [hover, setHover] = useState(false); + const [pos, setPos] = useState({ top: 0, left: 0 }); + const ref = useRef(null); + const names = reaction.users.map((u) => u.username).join(', '); return ( <> - - {hover && names && ReactDOM.createPortal( -
- {names} -
, - document.body - )} + {hover && + names && + ReactDOM.createPortal( +
+ {names} +
, + document.body + )} - ) + ); } /* ── Main Component ── */ interface CollabChatProps { - tripId: number - currentUser: User + tripId: number; + currentUser: User; } export default function CollabChat({ tripId, currentUser }: CollabChatProps) { - const { t } = useTranslation() - const is12h = useSettingsStore(s => s.settings.time_format) === '12h' - const can = useCanDo() - const trip = useTripStore((s) => s.trip) - const canEdit = can('collab_edit', trip) + const { t } = useTranslation(); + const is12h = useSettingsStore((s) => s.settings.time_format) === '12h'; + const can = useCanDo(); + const trip = useTripStore((s) => s.trip); + const canEdit = can('collab_edit', trip); - const [messages, setMessages] = useState([]) - const [loading, setLoading] = useState(true) - const [hasMore, setHasMore] = useState(false) - const [loadingMore, setLoadingMore] = useState(false) - const [text, setText] = useState('') - const [replyTo, setReplyTo] = useState(null) - const [hoveredId, setHoveredId] = useState(null) - const [sending, setSending] = useState(false) - const [showEmoji, setShowEmoji] = useState(false) - const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y } - const [deletingIds, setDeletingIds] = useState(new Set()) - const deleteTimersRef = useRef[]>([]) + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(true); + const [hasMore, setHasMore] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [text, setText] = useState(''); + const [replyTo, setReplyTo] = useState(null); + const [hoveredId, setHoveredId] = useState(null); + const [sending, setSending] = useState(false); + const [showEmoji, setShowEmoji] = useState(false); + const [reactMenu, setReactMenu] = useState(null); // { msgId, x, y } + const [deletingIds, setDeletingIds] = useState(new Set()); + const deleteTimersRef = useRef[]>([]); useEffect(() => { - return () => { deleteTimersRef.current.forEach(clearTimeout) } - }, []) + return () => { + deleteTimersRef.current.forEach(clearTimeout); + }; + }, []); - const containerRef = useRef(null) - const messagesRef = useRef(messages) - messagesRef.current = messages - const scrollRef = useRef(null) - const textareaRef = useRef(null) - const emojiBtnRef = useRef(null) - const isAtBottom = useRef(true) + const containerRef = useRef(null); + const messagesRef = useRef(messages); + messagesRef.current = messages; + const scrollRef = useRef(null); + const textareaRef = useRef(null); + const emojiBtnRef = useRef(null); + const isAtBottom = useRef(true); const scrollToBottom = useCallback((behavior = 'auto') => { - const el = scrollRef.current - if (!el) return - requestAnimationFrame(() => el.scrollTo({ top: el.scrollHeight, behavior })) - }, []) + const el = scrollRef.current; + if (!el) return; + requestAnimationFrame(() => el.scrollTo({ top: el.scrollHeight, behavior })); + }, []); const checkAtBottom = useCallback(() => { - const el = scrollRef.current - if (!el) return - isAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48 - }, []) + const el = scrollRef.current; + if (!el) return; + isAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48; + }, []); /* ── load messages ── */ useEffect(() => { - let cancelled = false - setLoading(true) - collabApi.getMessages(tripId).then(data => { - if (cancelled) return - const msgs = (Array.isArray(data) ? data : data.messages || []).map(m => m.deleted ? { ...m, _deleted: true } : m) - setMessages(msgs) - setHasMore(msgs.length >= 100) - setLoading(false) - setTimeout(() => scrollToBottom(), 30) - }).catch(() => { if (!cancelled) setLoading(false) }) - return () => { cancelled = true } - }, [tripId, scrollToBottom]) + let cancelled = false; + setLoading(true); + collabApi + .getMessages(tripId) + .then((data) => { + if (cancelled) return; + const msgs = (Array.isArray(data) ? data : data.messages || []).map((m) => + m.deleted ? { ...m, _deleted: true } : m + ); + setMessages(msgs); + setHasMore(msgs.length >= 100); + setLoading(false); + setTimeout(() => scrollToBottom(), 30); + }) + .catch(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [tripId, scrollToBottom]); /* ── load more ── */ const handleLoadMore = useCallback(async () => { - if (loadingMore || messages.length === 0) return - setLoadingMore(true) - const el = scrollRef.current - const prevHeight = el ? el.scrollHeight : 0 + if (loadingMore || messages.length === 0) return; + setLoadingMore(true); + const el = scrollRef.current; + const prevHeight = el ? el.scrollHeight : 0; try { - const data = await collabApi.getMessages(tripId, messages[0]?.id) - const older = (Array.isArray(data) ? data : data.messages || []).map(m => m.deleted ? { ...m, _deleted: true } : m) - if (older.length === 0) { setHasMore(false) } - else { - setMessages(prev => [...older, ...prev]) - setHasMore(older.length >= 100) - requestAnimationFrame(() => { if (el) el.scrollTop = el.scrollHeight - prevHeight }) + const data = await collabApi.getMessages(tripId, messages[0]?.id); + const older = (Array.isArray(data) ? data : data.messages || []).map((m) => + m.deleted ? { ...m, _deleted: true } : m + ); + if (older.length === 0) { + setHasMore(false); + } else { + setMessages((prev) => [...older, ...prev]); + setHasMore(older.length >= 100); + requestAnimationFrame(() => { + if (el) el.scrollTop = el.scrollHeight - prevHeight; + }); } - } catch {} finally { setLoadingMore(false) } - }, [tripId, loadingMore, messages]) + } catch { + } finally { + setLoadingMore(false); + } + }, [tripId, loadingMore, messages]); /* ── websocket ── */ useEffect(() => { const handler = (event) => { if (event.type === 'collab:message:created' && String(event.tripId) === String(tripId)) { - setMessages(prev => prev.some(m => m.id === event.message.id) ? prev : [...prev, event.message]) - if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 30) + setMessages((prev) => (prev.some((m) => m.id === event.message.id) ? prev : [...prev, event.message])); + if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 30); } if (event.type === 'collab:message:deleted' && String(event.tripId) === String(tripId)) { - setMessages(prev => prev.map(m => m.id === event.messageId ? { ...m, _deleted: true } : m)) - if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) + setMessages((prev) => prev.map((m) => (m.id === event.messageId ? { ...m, _deleted: true } : m))); + if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50); } if (event.type === 'collab:message:reacted' && String(event.tripId) === String(tripId)) { - setMessages(prev => prev.map(m => m.id === event.messageId ? { ...m, reactions: event.reactions } : m)) + setMessages((prev) => prev.map((m) => (m.id === event.messageId ? { ...m, reactions: event.reactions } : m))); } - } - addListener(handler) - return () => removeListener(handler) - }, [tripId, scrollToBottom]) + }; + addListener(handler); + return () => removeListener(handler); + }, [tripId, scrollToBottom]); /* ── auto-resize textarea ── */ const handleTextChange = useCallback((e) => { - setText(e.target.value) - const ta = textareaRef.current + setText(e.target.value); + const ta = textareaRef.current; if (ta) { - ta.style.height = 'auto' - const h = Math.min(ta.scrollHeight, 100) - ta.style.height = h + 'px' - ta.style.overflowY = ta.scrollHeight > 100 ? 'auto' : 'hidden' + ta.style.height = 'auto'; + const h = Math.min(ta.scrollHeight, 100); + ta.style.height = h + 'px'; + ta.style.overflowY = ta.scrollHeight > 100 ? 'auto' : 'hidden'; } - }, []) + }, []); /* ── send ── */ const handleSend = useCallback(async () => { - const body = text.trim() - if (!body || sending) return - setSending(true) + const body = text.trim(); + if (!body || sending) return; + setSending(true); try { - const payload = { text: body } - if (replyTo) payload.reply_to = replyTo.id - const data = await collabApi.sendMessage(tripId, payload) + const payload = { text: body }; + if (replyTo) payload.reply_to = replyTo.id; + const data = await collabApi.sendMessage(tripId, payload); if (data?.message) { - setMessages(prev => prev.some(m => m.id === data.message.id) ? prev : [...prev, data.message]) + setMessages((prev) => (prev.some((m) => m.id === data.message.id) ? prev : [...prev, data.message])); } - setText(''); setReplyTo(null); setShowEmoji(false) - if (textareaRef.current) textareaRef.current.style.height = 'auto' - isAtBottom.current = true - setTimeout(() => scrollToBottom('smooth'), 50) - } catch {} finally { setSending(false) } - }, [text, sending, replyTo, tripId, scrollToBottom]) + setText(''); + setReplyTo(null); + setShowEmoji(false); + if (textareaRef.current) textareaRef.current.style.height = 'auto'; + isAtBottom.current = true; + setTimeout(() => scrollToBottom('smooth'), 50); + } catch { + } finally { + setSending(false); + } + }, [text, sending, replyTo, tripId, scrollToBottom]); - const handleKeyDown = useCallback((e) => { - if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() } - }, [handleSend]) + const handleKeyDown = useCallback( + (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }, + [handleSend] + ); - const handleDelete = useCallback(async (msgId) => { - const msg = messages.find(m => m.id === msgId) - requestAnimationFrame(() => { - setDeletingIds(prev => new Set(prev).add(msgId)) - }) - const t = setTimeout(async () => { + const handleDelete = useCallback( + async (msgId) => { + const msg = messages.find((m) => m.id === msgId); + requestAnimationFrame(() => { + setDeletingIds((prev) => new Set(prev).add(msgId)); + }); + const t = setTimeout(async () => { + try { + await collabApi.deleteMessage(tripId, msgId); + setMessages((prev) => prev.map((m) => (m.id === msgId ? { ...m, _deleted: true } : m))); + } catch {} + setDeletingIds((prev) => { + const s = new Set(prev); + s.delete(msgId); + return s; + }); + }, 400); + deleteTimersRef.current.push(t); + }, + [tripId] + ); + + const handleReact = useCallback( + async (msgId, emoji) => { + setReactMenu(null); try { - await collabApi.deleteMessage(tripId, msgId) - setMessages(prev => prev.map(m => m.id === msgId ? { ...m, _deleted: true } : m)) + const data = await collabApi.reactMessage(tripId, msgId, emoji); + setMessages((prev) => prev.map((m) => (m.id === msgId ? { ...m, reactions: data.reactions } : m))); } catch {} - setDeletingIds(prev => { const s = new Set(prev); s.delete(msgId); return s }) - }, 400) - deleteTimersRef.current.push(t) - }, [tripId]) - - const handleReact = useCallback(async (msgId, emoji) => { - setReactMenu(null) - try { - const data = await collabApi.reactMessage(tripId, msgId, emoji) - setMessages(prev => prev.map(m => m.id === msgId ? { ...m, reactions: data.reactions } : m)) - } catch {} - }, [tripId]) + }, + [tripId] + ); const handleEmojiSelect = useCallback((emoji) => { - setText(prev => prev + emoji) - textareaRef.current?.focus() - }, []) + setText((prev) => prev + emoji); + textareaRef.current?.focus(); + }, []); - const isOwn = (msg) => String(msg.user_id) === String(currentUser.id) + const isOwn = (msg) => String(msg.user_id) === String(currentUser.id); // Check if message is only emoji (1-3 emojis, no other text) const isEmojiOnly = (text) => { - const emojiRegex = /^(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}[\uFE0F]?(?:\u200D\p{Extended_Pictographic}[\uFE0F]?)*){1,3}$/u - return emojiRegex.test(text.trim()) - } + const emojiRegex = + /^(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}[\uFE0F]?(?:\u200D\p{Extended_Pictographic}[\uFE0F]?)*){1,3}$/u; + return emojiRegex.test(text.trim()); + }; /* ── Loading ── */ if (loading) { return (
-
+
- ) + ); } /* ── Main ── */ return ( -
+
{/* Messages */} {messages.length === 0 ? ( -
+
{t('collab.chat.empty')} {t('collab.chat.emptyDesc') || ''}
) : ( -
+
{hasMore && (
- @@ -558,86 +881,140 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) { )} {messages.map((msg, idx) => { - const own = isOwn(msg) - const prevMsg = messages[idx - 1] - const nextMsg = messages[idx + 1] - const isNewGroup = idx === 0 || String(prevMsg?.user_id) !== String(msg.user_id) - const isLastInGroup = !nextMsg || String(nextMsg?.user_id) !== String(msg.user_id) - const showDate = shouldShowDateSeparator(msg, prevMsg) - const showAvatar = !own && isLastInGroup - const bigEmoji = isEmojiOnly(msg.text) - const hasReply = msg.reply_text || msg.reply_to + const own = isOwn(msg); + const prevMsg = messages[idx - 1]; + const nextMsg = messages[idx + 1]; + const isNewGroup = idx === 0 || String(prevMsg?.user_id) !== String(msg.user_id); + const isLastInGroup = !nextMsg || String(nextMsg?.user_id) !== String(msg.user_id); + const showDate = shouldShowDateSeparator(msg, prevMsg); + const showAvatar = !own && isLastInGroup; + const bigEmoji = isEmojiOnly(msg.text); + const hasReply = msg.reply_text || msg.reply_to; // Deleted message placeholder if (msg._deleted) { return ( {showDate && (
- + {formatDateSeparator(msg.created_at, t)}
)}
- {msg.username} {t('collab.chat.deletedMessage') || 'deleted a message'} · {formatTime(msg.created_at, is12h)} + {msg.username} {t('collab.chat.deletedMessage') || 'deleted a message'} ·{' '} + {formatTime(msg.created_at, is12h)}
- ) + ); } // Bubble border radius — iMessage style tails const br = own ? `18px 18px ${isLastInGroup ? '4px' : '18px'} 18px` - : `18px 18px 18px ${isLastInGroup ? '4px' : '18px'}` + : `18px 18px 18px ${isLastInGroup ? '4px' : '18px'}`; return ( {/* Date separator */} {showDate && (
- + {formatDateSeparator(msg.created_at, t)}
)} -
+
{/* Avatar slot for others */} {!own && (
- {showAvatar && ( - msg.user_avatar ? ( - + {showAvatar && + (msg.user_avatar ? ( + ) : ( -
+
{(msg.username || '?')[0].toUpperCase()}
- ) - )} + ))}
)} -
+
{/* Username for others at group start */} {!own && isNewGroup && ( - + {msg.username} )} @@ -647,82 +1024,153 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) { style={{ position: 'relative' }} onMouseEnter={() => setHoveredId(msg.id)} onMouseLeave={() => setHoveredId(null)} - onContextMenu={e => { e.preventDefault(); if (canEdit) setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }} - onTouchEnd={e => { - const now = Date.now() - const lastTap = e.currentTarget.dataset.lastTap || 0 + onContextMenu={(e) => { + e.preventDefault(); + if (canEdit) setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }); + }} + onTouchEnd={(e) => { + const now = Date.now(); + const lastTap = e.currentTarget.dataset.lastTap || 0; if (now - lastTap < 300 && canEdit) { - e.preventDefault() - const touch = e.changedTouches?.[0] - if (touch) setReactMenu({ msgId: msg.id, x: touch.clientX, y: touch.clientY }) + e.preventDefault(); + const touch = e.changedTouches?.[0]; + if (touch) setReactMenu({ msgId: msg.id, x: touch.clientX, y: touch.clientY }); } - e.currentTarget.dataset.lastTap = now + e.currentTarget.dataset.lastTap = now; }} > {bigEmoji ? ( -
- {msg.text} -
+
{msg.text}
) : ( -
+
{/* Inline reply quote */} {hasReply && ( -
+
{msg.reply_username || ''}
-
+
{(msg.reply_text || '').slice(0, 80)}
)} {hasReply ? ( -
- ) : } - {(msg.text.match(URL_REGEX) || []).slice(0, 1).map(url => ( - { if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) }} /> +
+ +
+ ) : ( + + )} + {(msg.text.match(URL_REGEX) || []).slice(0, 1).map((url) => ( + { + if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50); + }} + /> ))}
)} {/* Hover actions */} -
- {own && canEdit && ( - @@ -732,22 +1180,43 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) { {/* Reactions — iMessage style floating badge */} {msg.reactions?.length > 0 && ( -
-
- {msg.reactions.map(r => { - const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id)) +
+
+ {msg.reactions.map((r) => { + const myReaction = r.users.some((u) => String(u.user_id) === String(currentUser.id)); return ( - { if (canEdit) handleReact(msg.id, r.emoji) }} /> - ) + { + if (canEdit) handleReact(msg.id, r.emoji); + }} + /> + ); })}
@@ -762,28 +1231,55 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
- ) + ); })}
)} {/* Composer */} -
+
{/* Reply preview */} {replyTo && ( -
+
{replyTo.username}: {(replyTo.text || '').slice(0, 60)} -
@@ -792,12 +1288,25 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
{/* Emoji button */} {canEdit && ( - )} @@ -807,10 +1316,19 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) { rows={1} disabled={!canEdit} style={{ - flex: 1, resize: 'none', border: '1px solid var(--border-primary)', borderRadius: 20, - padding: '8px 14px', fontSize: 14, lineHeight: 1.4, fontFamily: 'inherit', - background: 'var(--bg-input)', color: 'var(--text-primary)', outline: 'none', - maxHeight: 100, overflowY: 'hidden', + flex: 1, + resize: 'none', + border: '1px solid var(--border-primary)', + borderRadius: 20, + padding: '8px 14px', + fontSize: 14, + lineHeight: 1.4, + fontFamily: 'inherit', + background: 'var(--bg-input)', + color: 'var(--text-primary)', + outline: 'none', + maxHeight: 100, + overflowY: 'hidden', opacity: canEdit ? 1 : 0.5, }} placeholder={t('collab.chat.placeholder')} @@ -821,13 +1339,24 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) { {/* Send */} {canEdit && ( - )} @@ -835,13 +1364,26 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
{/* Emoji picker */} - {showEmoji && setShowEmoji(false)} anchorRef={emojiBtnRef} containerRef={containerRef} />} + {showEmoji && ( + setShowEmoji(false)} + anchorRef={emojiBtnRef} + containerRef={containerRef} + /> + )} {/* Reaction quick menu (right-click) */} - {reactMenu && ReactDOM.createPortal( - handleReact(reactMenu.msgId, emoji)} onClose={() => setReactMenu(null)} />, - document.body - )} + {reactMenu && + ReactDOM.createPortal( + handleReact(reactMenu.msgId, emoji)} + onClose={() => setReactMenu(null)} + />, + document.body + )}
- ) + ); } diff --git a/client/src/components/Collab/CollabNotes.test.tsx b/client/src/components/Collab/CollabNotes.test.tsx index 9c8fc884..2fb2003b 100644 --- a/client/src/components/Collab/CollabNotes.test.tsx +++ b/client/src/components/Collab/CollabNotes.test.tsx @@ -10,14 +10,14 @@ vi.mock('../../api/websocket', () => ({ removeListener: vi.fn(), })); -import { render, screen, waitFor, act } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; +import { buildTrip, buildUser } from '../../../tests/helpers/factories'; import { server } from '../../../tests/helpers/msw/server'; +import { act, render, screen, waitFor } from '../../../tests/helpers/render'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { useAuthStore } from '../../store/authStore'; import { useTripStore } from '../../store/tripStore'; -import { resetAllStores, seedStore } from '../../../tests/helpers/store'; -import { buildUser, buildTrip } from '../../../tests/helpers/factories'; import CollabNotes from './CollabNotes'; const currentUser = buildUser({ id: 1, username: 'testuser' }); @@ -29,11 +29,7 @@ const defaultProps = { beforeEach(() => { resetAllStores(); - server.use( - http.get('/api/trips/1/collab/notes', () => - HttpResponse.json({ notes: [] }) - ), - ); + server.use(http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ notes: [] }))); seedStore(useAuthStore, { user: currentUser, isAuthenticated: true }); seedStore(useTripStore, { trip: buildTrip({ id: 1 }) }); }); @@ -59,12 +55,22 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 1, trip_id: 1, user_id: currentUser.id, author_username: 'testuser', - author_avatar: null, title: 'Packing Tips', content: 'Bring sunscreen', - category: null, color: '#3b82f6', files: [], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 1, + trip_id: 1, + user_id: currentUser.id, + author_username: 'testuser', + author_avatar: null, + title: 'Packing Tips', + content: 'Bring sunscreen', + category: null, + color: '#3b82f6', + files: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ) ); @@ -85,12 +91,22 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', - author_avatar: null, title: 'My Checklist', content: 'Items', - category: 'Travel', color: '#ef4444', files: [], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'My Checklist', + content: 'Items', + category: 'Travel', + color: '#ef4444', + files: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ) ); @@ -103,8 +119,34 @@ describe('CollabNotes', () => { http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ notes: [ - { id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, title: 'Note A', content: '', category: null, color: '#3b82f6', files: [], created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z' }, - { id: 2, trip_id: 1, user_id: 2, author_username: 'alice', author_avatar: null, title: 'Note B', content: '', category: null, color: '#ef4444', files: [], created_at: '2025-06-01T10:01:00.000Z', updated_at: '2025-06-01T10:01:00.000Z' }, + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Note A', + content: '', + category: null, + color: '#3b82f6', + files: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + { + id: 2, + trip_id: 1, + user_id: 2, + author_username: 'alice', + author_avatar: null, + title: 'Note B', + content: '', + category: null, + color: '#ef4444', + files: [], + created_at: '2025-06-01T10:01:00.000Z', + updated_at: '2025-06-01T10:01:00.000Z', + }, ], }) ) @@ -127,7 +169,20 @@ describe('CollabNotes', () => { http.post('/api/trips/1/collab/notes', async () => { postCalled = true; return HttpResponse.json({ - note: { id: 99, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, title: 'New Note', content: '', category: null, color: '#3b82f6', files: [], created_at: new Date().toISOString(), updated_at: new Date().toISOString() }, + note: { + id: 99, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'New Note', + content: '', + category: null, + color: '#3b82f6', + files: [], + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, }); }) ); @@ -146,7 +201,22 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, title: 'Details', content: 'Bring passport', category: null, color: '#3b82f6', files: [], created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z' }], + notes: [ + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Details', + content: 'Bring passport', + category: null, + color: '#3b82f6', + files: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ) ); @@ -159,7 +229,22 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, title: 'Hotel Info', content: '', category: 'Accommodation', color: '#8b5cf6', files: [], created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z' }], + notes: [ + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Hotel Info', + content: '', + category: 'Accommodation', + color: '#8b5cf6', + files: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ) ); @@ -180,16 +265,25 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 42, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'Remove Me', content: '', category: null, color: '#3b82f6', files: [], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 42, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Remove Me', + content: '', + category: null, + color: '#3b82f6', + files: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ), - http.delete('/api/trips/1/collab/notes/42', () => - HttpResponse.json({ success: true }) - ), + http.delete('/api/trips/1/collab/notes/42', () => HttpResponse.json({ success: true })) ); render(); await screen.findByText('Remove Me'); @@ -202,11 +296,23 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'Pinned Note', content: '', category: null, color: '#3b82f6', pinned: true, files: [], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Pinned Note', + content: '', + category: null, + color: '#3b82f6', + pinned: true, + files: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ) ); @@ -221,11 +327,22 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'Editable Note', content: 'Original', category: null, color: '#3b82f6', files: [], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Editable Note', + content: 'Original', + category: null, + color: '#3b82f6', + files: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ) ); @@ -241,8 +358,34 @@ describe('CollabNotes', () => { http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ notes: [ - { id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, title: 'Hotels Note', content: '', category: 'Hotels', color: '#3b82f6', files: [], created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z' }, - { id: 2, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, title: 'Food Note', content: '', category: 'Food', color: '#ef4444', files: [], created_at: '2025-06-01T10:01:00.000Z', updated_at: '2025-06-01T10:01:00.000Z' }, + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Hotels Note', + content: '', + category: 'Hotels', + color: '#3b82f6', + files: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + { + id: 2, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Food Note', + content: '', + category: 'Food', + color: '#ef4444', + files: [], + created_at: '2025-06-01T10:01:00.000Z', + updated_at: '2025-06-01T10:01:00.000Z', + }, ], }) ) @@ -269,9 +412,19 @@ describe('CollabNotes', () => { listener({ type: 'collab:note:created', note: { - id: 50, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'Live Note', content: '', category: null, color: '#3b82f6', pinned: false, files: [], - created_at: new Date().toISOString(), updated_at: new Date().toISOString(), + id: 50, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Live Note', + content: '', + category: null, + color: '#3b82f6', + pinned: false, + files: [], + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), }, }); }); @@ -283,11 +436,22 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 7, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'WS Delete', content: '', category: null, color: '#3b82f6', files: [], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 7, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'WS Delete', + content: '', + category: null, + color: '#3b82f6', + files: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ) ); @@ -307,11 +471,22 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 3, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'My Note', content: 'Some content', category: null, color: '#3b82f6', files: [], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 3, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'My Note', + content: 'Some content', + category: null, + color: '#3b82f6', + files: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ) ); @@ -328,19 +503,43 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 3, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'Old Title', content: '', category: null, color: '#3b82f6', files: [], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 3, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Old Title', + content: '', + category: null, + color: '#3b82f6', + files: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ), http.put('/api/trips/1/collab/notes/3', async () => { putCalled = true; return HttpResponse.json({ - note: { id: 3, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, title: 'New Title', content: '', category: null, color: '#3b82f6', files: [], created_at: '2025-06-01T10:00:00.000Z', updated_at: new Date().toISOString() }, + note: { + id: 3, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'New Title', + content: '', + category: null, + color: '#3b82f6', + files: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: new Date().toISOString(), + }, }); - }), + }) ); render(); await screen.findByText('Old Title'); @@ -356,11 +555,22 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'Markdown Note', content: '**Bold text**', category: null, color: '#3b82f6', files: [], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Markdown Note', + content: '**Bold text**', + category: null, + color: '#3b82f6', + files: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ) ); @@ -444,11 +654,22 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 5, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'Old Title WS', content: '', category: null, color: '#3b82f6', files: [], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 5, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Old Title WS', + content: '', + category: null, + color: '#3b82f6', + files: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ) ); @@ -461,9 +682,18 @@ describe('CollabNotes', () => { listener({ type: 'collab:note:updated', note: { - id: 5, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'Updated WS Title', content: '', category: null, color: '#3b82f6', files: [], - created_at: '2025-06-01T10:00:00.000Z', updated_at: new Date().toISOString(), + id: 5, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Updated WS Title', + content: '', + category: null, + color: '#3b82f6', + files: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: new Date().toISOString(), }, }); }); @@ -476,11 +706,22 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'Expandable Note', content: 'Full content here', category: null, color: '#3b82f6', files: [], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Expandable Note', + content: 'Full content here', + category: null, + color: '#3b82f6', + files: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ) ); @@ -501,11 +742,22 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'View Modal Note', content: 'Content to view', category: null, color: '#3b82f6', files: [], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'View Modal Note', + content: 'Content to view', + category: null, + color: '#3b82f6', + files: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ) ); @@ -534,8 +786,34 @@ describe('CollabNotes', () => { http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ notes: [ - { id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, title: 'Alpha Note', content: '', category: 'Alpha', color: '#3b82f6', files: [], created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z' }, - { id: 2, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, title: 'Beta Note', content: '', category: 'Beta', color: '#ef4444', files: [], created_at: '2025-06-01T10:01:00.000Z', updated_at: '2025-06-01T10:01:00.000Z' }, + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Alpha Note', + content: '', + category: 'Alpha', + color: '#3b82f6', + files: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + { + id: 2, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Beta Note', + content: '', + category: 'Beta', + color: '#ef4444', + files: [], + created_at: '2025-06-01T10:01:00.000Z', + updated_at: '2025-06-01T10:01:00.000Z', + }, ], }) ) @@ -557,11 +835,22 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'Cat Note', content: '', category: 'Food', color: '#ef4444', files: [], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Cat Note', + content: '', + category: 'Food', + color: '#ef4444', + files: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ) ); @@ -580,11 +869,22 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'Existing Note', content: '', category: 'Hotels', color: '#3b82f6', files: [], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Existing Note', + content: '', + category: 'Hotels', + color: '#3b82f6', + files: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ) ); @@ -603,19 +903,45 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 10, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'Pin Me', content: '', category: null, color: '#3b82f6', pinned: false, files: [], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 10, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Pin Me', + content: '', + category: null, + color: '#3b82f6', + pinned: false, + files: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ), http.put('/api/trips/1/collab/notes/10', async () => { patchCalled = true; return HttpResponse.json({ - note: { id: 10, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, title: 'Pin Me', content: '', category: null, color: '#3b82f6', pinned: true, files: [], created_at: '2025-06-01T10:00:00.000Z', updated_at: new Date().toISOString() }, + note: { + id: 10, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Pin Me', + content: '', + category: null, + color: '#3b82f6', + pinned: true, + files: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: new Date().toISOString(), + }, }); - }), + }) ); render(); await screen.findByText('Pin Me'); @@ -627,15 +953,31 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'PDF Note', content: '', category: null, color: '#3b82f6', files: [], - attachments: [{ - id: 1, filename: 'doc.pdf', original_name: 'document.pdf', - mime_type: 'application/pdf', url: '/api/trips/1/files/1/download', - }], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'PDF Note', + content: '', + category: null, + color: '#3b82f6', + files: [], + attachments: [ + { + id: 1, + filename: 'doc.pdf', + original_name: 'document.pdf', + mime_type: 'application/pdf', + url: '/api/trips/1/files/1/download', + }, + ], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ) ); @@ -650,18 +992,34 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'PDF Note Portal', content: '', category: null, color: '#3b82f6', files: [], - attachments: [{ - id: 1, filename: 'doc.pdf', original_name: 'document.pdf', - mime_type: 'application/pdf', url: '/api/trips/1/files/1/download', - }], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'PDF Note Portal', + content: '', + category: null, + color: '#3b82f6', + files: [], + attachments: [ + { + id: 1, + filename: 'doc.pdf', + original_name: 'document.pdf', + mime_type: 'application/pdf', + url: '/api/trips/1/files/1/download', + }, + ], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ), - http.post('/api/auth/resource-token', () => HttpResponse.json({ token: 'test-token' })), + http.post('/api/auth/resource-token', () => HttpResponse.json({ token: 'test-token' })) ); render(); await screen.findByText('PDF Note Portal'); @@ -675,17 +1033,27 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'Website Note', content: '', category: null, color: '#3b82f6', - website: 'https://example.com', files: [], attachments: [], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Website Note', + content: '', + category: null, + color: '#3b82f6', + website: 'https://example.com', + files: [], + attachments: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ), - http.get('/api/trips/1/collab/link-preview', () => - HttpResponse.json({ title: 'Example Domain', image: null }) - ), + http.get('/api/trips/1/collab/link-preview', () => HttpResponse.json({ title: 'Example Domain', image: null })) ); render(); await screen.findByText('Website Note'); @@ -701,24 +1069,54 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'Cat Save Note', content: '', category: 'Travel', color: '#ef4444', files: [], attachments: [], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Cat Save Note', + content: '', + category: 'Travel', + color: '#ef4444', + files: [], + attachments: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ), http.put('/api/trips/1/collab/notes/1', async () => { putCalled = true; - return HttpResponse.json({ note: { id: 1, trip_id: 1, title: 'Cat Save Note', content: '', category: 'Travel', color: '#6366f1', user_id: 1, author_username: 'testuser', author_avatar: null, files: [], attachments: [], created_at: '2025-06-01T10:00:00.000Z', updated_at: new Date().toISOString() } }); - }), + return HttpResponse.json({ + note: { + id: 1, + trip_id: 1, + title: 'Cat Save Note', + content: '', + category: 'Travel', + color: '#6366f1', + user_id: 1, + author_username: 'testuser', + author_avatar: null, + files: [], + attachments: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: new Date().toISOString(), + }, + }); + }) ); render(); await screen.findByText('Cat Save Note'); await user.click(screen.getByTitle('Manage Categories')); await screen.findByText('Manage Categories', { selector: 'h3' }); // Change color: click first color swatch for "Travel" category - const colorSwatches = screen.getAllByRole('button').filter(b => b.style.background && b.style.background.startsWith('#')); + const colorSwatches = screen + .getAllByRole('button') + .filter((b) => b.style.background && b.style.background.startsWith('#')); if (colorSwatches.length > 0) { await user.click(colorSwatches[0]); } @@ -733,9 +1131,24 @@ describe('CollabNotes', () => { let postBody: Record = {}; server.use( http.post('/api/trips/1/collab/notes', async ({ request }) => { - postBody = await request.json() as Record; + postBody = (await request.json()) as Record; return HttpResponse.json({ - note: { id: 99, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, title: 'URL Note', content: '', category: null, color: '#3b82f6', website: 'https://trek.app', files: [], attachments: [], created_at: new Date().toISOString(), updated_at: new Date().toISOString() }, + note: { + id: 99, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'URL Note', + content: '', + category: null, + color: '#3b82f6', + website: 'https://trek.app', + files: [], + attachments: [], + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, }); }) ); @@ -755,16 +1168,44 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'Color Note', content: '', category: 'Food', color: '#ef4444', files: [], attachments: [], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Color Note', + content: '', + category: 'Food', + color: '#ef4444', + files: [], + attachments: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ), http.put('/api/trips/1/collab/notes/1', async () => - HttpResponse.json({ note: { id: 1, trip_id: 1, title: 'Color Note', content: '', category: 'Food', color: '#6366f1', user_id: 1, author_username: 'testuser', author_avatar: null, files: [], attachments: [], created_at: '2025-06-01T10:00:00.000Z', updated_at: new Date().toISOString() } }) - ), + HttpResponse.json({ + note: { + id: 1, + trip_id: 1, + title: 'Color Note', + content: '', + category: 'Food', + color: '#6366f1', + user_id: 1, + author_username: 'testuser', + author_avatar: null, + files: [], + attachments: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: new Date().toISOString(), + }, + }) + ) ); render(); await screen.findByText('Color Note'); @@ -781,18 +1222,34 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'Image Note', content: '', category: null, color: '#3b82f6', files: [], - attachments: [{ - id: 2, filename: 'photo.jpg', original_name: 'photo.jpg', - mime_type: 'image/jpeg', url: '/api/trips/1/files/2/download', - }], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Image Note', + content: '', + category: null, + color: '#3b82f6', + files: [], + attachments: [ + { + id: 2, + filename: 'photo.jpg', + original_name: 'photo.jpg', + mime_type: 'image/jpeg', + url: '/api/trips/1/files/2/download', + }, + ], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ), - http.post('/api/auth/resource-token', () => HttpResponse.json({ token: 'test-token' })), + http.post('/api/auth/resource-token', () => HttpResponse.json({ token: 'test-token' })) ); render(); await screen.findByText('Image Note'); @@ -805,26 +1262,45 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'Image Portal Note', content: '', category: null, color: '#3b82f6', files: [], - attachments: [{ - id: 3, filename: 'photo.jpg', original_name: 'scenery.jpg', - mime_type: 'image/jpeg', url: '/api/trips/1/files/3/download', - }], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Image Portal Note', + content: '', + category: null, + color: '#3b82f6', + files: [], + attachments: [ + { + id: 3, + filename: 'photo.jpg', + original_name: 'scenery.jpg', + mime_type: 'image/jpeg', + url: '/api/trips/1/files/3/download', + }, + ], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ), - http.post('/api/auth/resource-token', () => HttpResponse.json({ token: 'test-token' })), + http.post('/api/auth/resource-token', () => HttpResponse.json({ token: 'test-token' })) ); render(); await screen.findByText('Image Portal Note'); // Wait for AuthedImg to load (it calls getAuthUrl async) - await waitFor(() => { - const imgs = document.querySelectorAll('img[alt="photo.jpg"]'); - return imgs.length > 0; - }, { timeout: 3000 }).catch(() => { + await waitFor( + () => { + const imgs = document.querySelectorAll('img[alt="photo.jpg"]'); + return imgs.length > 0; + }, + { timeout: 3000 } + ).catch(() => { // AuthedImg may not render if token not fetched — still ok }); // The Files section label is visible @@ -836,11 +1312,23 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'Rename Cat Note', content: '', category: 'Transport', color: '#10b981', files: [], attachments: [], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Rename Cat Note', + content: '', + category: 'Transport', + color: '#10b981', + files: [], + attachments: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ) ); @@ -849,7 +1337,9 @@ describe('CollabNotes', () => { await user.click(screen.getByTitle('Manage Categories')); await screen.findByText('Manage Categories', { selector: 'h3' }); // Find the "Transport" category name span and click to edit - const categoryNameSpan = screen.getAllByText('Transport').find(el => el.tagName === 'SPAN' && el.title === 'Click to rename'); + const categoryNameSpan = screen + .getAllByText('Transport') + .find((el) => el.tagName === 'SPAN' && el.title === 'Click to rename'); if (categoryNameSpan) { await user.click(categoryNameSpan); // Now an input with value "Transport" should appear @@ -870,11 +1360,23 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'Remove Cat Note', content: '', category: 'Removable', color: '#8b5cf6', files: [], attachments: [], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Remove Cat Note', + content: '', + category: 'Removable', + color: '#8b5cf6', + files: [], + attachments: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ) ); @@ -883,9 +1385,7 @@ describe('CollabNotes', () => { await user.click(screen.getByTitle('Manage Categories')); await screen.findByText('Manage Categories', { selector: 'h3' }); // Find the Trash2 SVG delete button in the modal — buttons containing lucide-trash-2 SVGs - const trashButtons = [...document.querySelectorAll('button')].filter( - b => b.querySelector('svg.lucide-trash-2') - ); + const trashButtons = [...document.querySelectorAll('button')].filter((b) => b.querySelector('svg.lucide-trash-2')); if (trashButtons.length > 0) { // First trash button in the modal is for the 'Removable' category await user.click(trashButtons[0] as HTMLElement); @@ -893,7 +1393,9 @@ describe('CollabNotes', () => { await waitFor(() => { const fixedEls = document.querySelectorAll('[style*="position: fixed"]'); let found = false; - fixedEls.forEach(el => { if (el.textContent?.includes('Removable') && !el.textContent?.includes('Remove Cat Note')) found = true; }); + fixedEls.forEach((el) => { + if (el.textContent?.includes('Removable') && !el.textContent?.includes('Remove Cat Note')) found = true; + }); expect(found).toBe(false); }); } else { @@ -906,11 +1408,23 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'Full Content Note', content: '# Header\n\nSome **bold** text', category: 'Trip', color: '#3b82f6', files: [], attachments: [], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Full Content Note', + content: '# Header\n\nSome **bold** text', + category: 'Trip', + color: '#3b82f6', + files: [], + attachments: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ) ); @@ -931,11 +1445,23 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'Tagged Note', content: 'Some content here', category: 'Food', color: '#ef4444', files: [], attachments: [], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Tagged Note', + content: 'Some content here', + category: 'Food', + color: '#ef4444', + files: [], + attachments: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ) ); @@ -954,16 +1480,44 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'Rename Flow Note', content: '', category: 'OldCat', color: '#10b981', files: [], attachments: [], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Rename Flow Note', + content: '', + category: 'OldCat', + color: '#10b981', + files: [], + attachments: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ), http.put('/api/trips/1/collab/notes/1', async () => - HttpResponse.json({ note: { id: 1, trip_id: 1, title: 'Rename Flow Note', content: '', category: 'NewCat', color: '#10b981', user_id: 1, author_username: 'testuser', author_avatar: null, files: [], attachments: [], created_at: '2025-06-01T10:00:00.000Z', updated_at: new Date().toISOString() } }) - ), + HttpResponse.json({ + note: { + id: 1, + trip_id: 1, + title: 'Rename Flow Note', + content: '', + category: 'NewCat', + color: '#10b981', + user_id: 1, + author_username: 'testuser', + author_avatar: null, + files: [], + attachments: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: new Date().toISOString(), + }, + }) + ) ); render(); await screen.findByText('Rename Flow Note'); @@ -971,7 +1525,9 @@ describe('CollabNotes', () => { await screen.findByText('Manage Categories', { selector: 'h3' }); // Find and click the "OldCat" category name span to enter edit mode - const oldCatSpan = screen.getAllByText('OldCat').find(el => el.tagName === 'SPAN' && el.title === 'Click to rename'); + const oldCatSpan = screen + .getAllByText('OldCat') + .find((el) => el.tagName === 'SPAN' && el.title === 'Click to rename'); if (oldCatSpan) { await user.click(oldCatSpan); const editInput = screen.getByDisplayValue('OldCat'); @@ -993,15 +1549,34 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'Close Portal Note', content: '', category: null, color: '#3b82f6', files: [], - attachments: [{ id: 5, filename: 'file.pdf', original_name: 'closeable.pdf', mime_type: 'application/pdf', url: '/api/trips/1/files/5/download' }], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Close Portal Note', + content: '', + category: null, + color: '#3b82f6', + files: [], + attachments: [ + { + id: 5, + filename: 'file.pdf', + original_name: 'closeable.pdf', + mime_type: 'application/pdf', + url: '/api/trips/1/files/5/download', + }, + ], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ), - http.post('/api/auth/resource-token', () => HttpResponse.json({ token: 'close-token' })), + http.post('/api/auth/resource-token', () => HttpResponse.json({ token: 'close-token' })) ); render(); await screen.findByText('PDF'); @@ -1009,7 +1584,7 @@ describe('CollabNotes', () => { // FilePreviewPortal is open — closeable.pdf filename shown in header await screen.findByText('closeable.pdf'); // Find and click the X close button in the portal header - const closeButtons = [...document.querySelectorAll('button')].filter(b => b.querySelector('svg.lucide-x')); + const closeButtons = [...document.querySelectorAll('button')].filter((b) => b.querySelector('svg.lucide-x')); // The last X button should be the portal close button const portalCloseBtn = closeButtons[closeButtons.length - 1] as HTMLElement; await user.click(portalCloseBtn); @@ -1023,12 +1598,31 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 4, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'Attachment Note', content: '', category: null, color: '#3b82f6', files: [], - attachments: [{ id: 10, filename: 'doc.pdf', original_name: 'removable.pdf', mime_type: 'application/pdf', url: '/api/trips/1/files/10/download' }], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 4, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Attachment Note', + content: '', + category: null, + color: '#3b82f6', + files: [], + attachments: [ + { + id: 10, + filename: 'doc.pdf', + original_name: 'removable.pdf', + mime_type: 'application/pdf', + url: '/api/trips/1/files/10/download', + }, + ], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ), http.delete('/api/trips/1/collab/notes/4/files/10', () => { @@ -1036,8 +1630,24 @@ describe('CollabNotes', () => { return HttpResponse.json({ success: true }); }), http.put('/api/trips/1/collab/notes/4', async () => - HttpResponse.json({ note: { id: 4, trip_id: 1, title: 'Attachment Note', content: '', category: null, color: '#3b82f6', user_id: 1, author_username: 'testuser', author_avatar: null, files: [], attachments: [], created_at: '2025-06-01T10:00:00.000Z', updated_at: new Date().toISOString() } }) - ), + HttpResponse.json({ + note: { + id: 4, + trip_id: 1, + title: 'Attachment Note', + content: '', + category: null, + color: '#3b82f6', + user_id: 1, + author_username: 'testuser', + author_avatar: null, + files: [], + attachments: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: new Date().toISOString(), + }, + }) + ) ); render(); await screen.findByText('Attachment Note'); @@ -1047,7 +1657,7 @@ describe('CollabNotes', () => { // removable.pdf appears in the existing attachments list in the modal await screen.findByText('removable.pdf'); // Find X button next to the file name - const xButtons = [...document.querySelectorAll('button')].filter(b => b.querySelector('svg.lucide-x')); + const xButtons = [...document.querySelectorAll('button')].filter((b) => b.querySelector('svg.lucide-x')); // In the modal, there's the header X (close modal) + file X buttons // File X buttons appear after the header X if (xButtons.length > 1) { @@ -1061,17 +1671,29 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'OG Image Note', content: '', category: null, color: '#3b82f6', - website: 'https://trek-app.example.com', files: [], attachments: [], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'OG Image Note', + content: '', + category: null, + color: '#3b82f6', + website: 'https://trek-app.example.com', + files: [], + attachments: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ), http.get('/api/trips/1/collab/link-preview', () => HttpResponse.json({ title: 'Trek App', image: 'https://trek-app.example.com/og.jpg' }) - ), + ) ); render(); await screen.findByText('OG Image Note'); @@ -1084,12 +1706,31 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'Attached View Note', content: 'Has attachments', category: null, color: '#3b82f6', files: [], - attachments: [{ id: 20, filename: 'report.pdf', original_name: 'report.pdf', mime_type: 'application/pdf', url: '/api/trips/1/files/20/download' }], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Attached View Note', + content: 'Has attachments', + category: null, + color: '#3b82f6', + files: [], + attachments: [ + { + id: 20, + filename: 'report.pdf', + original_name: 'report.pdf', + mime_type: 'application/pdf', + url: '/api/trips/1/files/20/download', + }, + ], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ) ); @@ -1109,15 +1750,34 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'Image View Note', content: 'See attachments', category: null, color: '#3b82f6', files: [], - attachments: [{ id: 21, filename: 'photo.jpg', original_name: 'photo.jpg', mime_type: 'image/jpeg', url: '/api/trips/1/files/21/download' }], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Image View Note', + content: 'See attachments', + category: null, + color: '#3b82f6', + files: [], + attachments: [ + { + id: 21, + filename: 'photo.jpg', + original_name: 'photo.jpg', + mime_type: 'image/jpeg', + url: '/api/trips/1/files/21/download', + }, + ], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ), - http.post('/api/auth/resource-token', () => HttpResponse.json({ token: 'view-token' })), + http.post('/api/auth/resource-token', () => HttpResponse.json({ token: 'view-token' })) ); render(); await screen.findByText('Image View Note'); @@ -1133,11 +1793,23 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'Transition Note', content: 'Click edit from view', category: null, color: '#3b82f6', files: [], attachments: [], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Transition Note', + content: 'Click edit from view', + category: null, + color: '#3b82f6', + files: [], + attachments: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ) ); @@ -1158,11 +1830,23 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'Hoverable Note', content: '', category: null, color: '#3b82f6', files: [], attachments: [], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Hoverable Note', + content: '', + category: null, + color: '#3b82f6', + files: [], + attachments: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ) ); @@ -1180,12 +1864,23 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', - author_avatar: '/uploads/avatars/avatar1.jpg', - title: 'Avatar Note', content: '', category: null, color: '#3b82f6', files: [], attachments: [], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: '/uploads/avatars/avatar1.jpg', + title: 'Avatar Note', + content: '', + category: null, + color: '#3b82f6', + files: [], + attachments: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ) ); @@ -1201,11 +1896,23 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, - title: 'Escape Cat Note', content: '', category: 'EscapeMe', color: '#6366f1', files: [], attachments: [], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Escape Cat Note', + content: '', + category: 'EscapeMe', + color: '#6366f1', + files: [], + attachments: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ) ); @@ -1214,7 +1921,7 @@ describe('CollabNotes', () => { await user.click(screen.getByTitle('Manage Categories')); await screen.findByText('Manage Categories', { selector: 'h3' }); // Click on the category name to start editing - const catNameSpan = screen.getAllByText('EscapeMe').find(el => el.title === 'Click to rename'); + const catNameSpan = screen.getAllByText('EscapeMe').find((el) => el.title === 'Click to rename'); if (catNameSpan) { await user.click(catNameSpan); const editInput = screen.getByDisplayValue('EscapeMe'); @@ -1231,14 +1938,25 @@ describe('CollabNotes', () => { server.use( http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ - notes: [{ - id: 1, trip_id: 1, user_id: 1, - // NoteCard uses note.author || note.user || { username: note.username, ... } - author: { username: 'alice', avatar: null }, - author_username: 'alice', author_avatar: null, - title: 'Alice Note', content: '', category: null, color: '#3b82f6', files: [], attachments: [], - created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', - }], + notes: [ + { + id: 1, + trip_id: 1, + user_id: 1, + // NoteCard uses note.author || note.user || { username: note.username, ... } + author: { username: 'alice', avatar: null }, + author_username: 'alice', + author_avatar: null, + title: 'Alice Note', + content: '', + category: null, + color: '#3b82f6', + files: [], + attachments: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + ], }) ) ); @@ -1253,8 +1971,36 @@ describe('CollabNotes', () => { http.get('/api/trips/1/collab/notes', () => HttpResponse.json({ notes: [ - { id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, title: 'Unpinned', content: '', category: null, color: '#3b82f6', pinned: false, files: [], created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z' }, - { id: 2, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, title: 'Pinned', content: '', category: null, color: '#3b82f6', pinned: true, files: [], created_at: '2025-06-01T09:00:00.000Z', updated_at: '2025-06-01T09:00:00.000Z' }, + { + id: 1, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Unpinned', + content: '', + category: null, + color: '#3b82f6', + pinned: false, + files: [], + created_at: '2025-06-01T10:00:00.000Z', + updated_at: '2025-06-01T10:00:00.000Z', + }, + { + id: 2, + trip_id: 1, + user_id: 1, + author_username: 'testuser', + author_avatar: null, + title: 'Pinned', + content: '', + category: null, + color: '#3b82f6', + pinned: true, + files: [], + created_at: '2025-06-01T09:00:00.000Z', + updated_at: '2025-06-01T09:00:00.000Z', + }, ], }) ) diff --git a/client/src/components/Collab/CollabNotes.tsx b/client/src/components/Collab/CollabNotes.tsx index 2d6f253c..9c41387d 100644 --- a/client/src/components/Collab/CollabNotes.tsx +++ b/client/src/components/Collab/CollabNotes.tsx @@ -1,170 +1,412 @@ -import ReactDOM from 'react-dom' -import { useState, useEffect, useCallback, useRef, useMemo } from 'react' -import DOM from 'react-dom' -import Markdown from 'react-markdown' -import remarkGfm from 'remark-gfm' -import remarkBreaks from 'remark-breaks' -import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2, Loader2 } from 'lucide-react' -import { collabApi } from '../../api/client' -import { getAuthUrl } from '../../api/authUrl' -import { openFile } from '../../utils/fileDownload' -import { useCanDo } from '../../store/permissionsStore' -import { useTripStore } from '../../store/tripStore' -import { addListener, removeListener } from '../../api/websocket' -import { useTranslation } from '../../i18n' -import type { User } from '../../types' +import { + ExternalLink, + Loader2, + Maximize2, + Pencil, + Pin, + PinOff, + Plus, + Settings, + StickyNote, + Trash2, + X, +} from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import ReactDOM from 'react-dom'; +import Markdown from 'react-markdown'; +import remarkBreaks from 'remark-breaks'; +import remarkGfm from 'remark-gfm'; +import { getAuthUrl } from '../../api/authUrl'; +import { collabApi } from '../../api/client'; +import { addListener, removeListener } from '../../api/websocket'; +import { useTranslation } from '../../i18n'; +import { useCanDo } from '../../store/permissionsStore'; +import { useTripStore } from '../../store/tripStore'; +import type { User } from '../../types'; +import { openFile } from '../../utils/fileDownload'; interface NoteFile { - id: number - filename: string - original_name: string - mime_type: string - url?: string + id: number; + filename: string; + original_name: string; + mime_type: string; + url?: string; } interface CollabNote { - id: number - trip_id: number - title: string - content: string - category: string - website: string | null - pinned: boolean - color: string | null - username: string - avatar_url: string | null - avatar: string | null - user_id: number - created_at: string - author?: { username: string; avatar: string | null } - user?: { username: string; avatar: string | null } - files?: NoteFile[] + id: number; + trip_id: number; + title: string; + content: string; + category: string; + website: string | null; + pinned: boolean; + color: string | null; + username: string; + avatar_url: string | null; + avatar: string | null; + user_id: number; + created_at: string; + author?: { username: string; avatar: string | null }; + user?: { username: string; avatar: string | null }; + files?: NoteFile[]; } interface NoteAuthor { - username: string - avatar?: string | null + username: string; + avatar?: string | null; } -const FONT = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" +const FONT = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif"; // ── Website Thumbnail (fetches OG image) ──────────────────────────────────── -const ogCache = {} +const ogCache = {}; interface WebsiteThumbnailProps { - url: string - tripId: number - color: string + url: string; + tripId: number; + color: string; } function WebsiteThumbnail({ url, tripId, color }: WebsiteThumbnailProps) { - const [data, setData] = useState(ogCache[url] || null) - const [failed, setFailed] = useState(false) + const [data, setData] = useState(ogCache[url] || null); + const [failed, setFailed] = useState(false); useEffect(() => { - if (ogCache[url]) { setData(ogCache[url]); return } - collabApi.linkPreview(tripId, url).then(d => { ogCache[url] = d; setData(d) }).catch(() => setFailed(true)) - }, [url, tripId]) + if (ogCache[url]) { + setData(ogCache[url]); + return; + } + collabApi + .linkPreview(tripId, url) + .then((d) => { + ogCache[url] = d; + setData(d); + }) + .catch(() => setFailed(true)); + }, [url, tripId]); - const domain = (() => { try { return new URL(url).hostname.replace('www.', '') } catch { return 'link' } })() + const domain = (() => { + try { + return new URL(url).hostname.replace('www.', ''); + } catch { + return 'link'; + } + })(); return ( - { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }} - onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}> + onMouseEnter={(e) => { + e.currentTarget.style.transform = 'scale(1.08)'; + e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = 'scale(1)'; + e.currentTarget.style.boxShadow = 'none'; + }} + > {data?.image && !failed ? ( - setFailed(true)} /> + setFailed(true)} + /> ) : ( <> - + {domain} )} - ) + ); } // ── File Preview Portal ───────────────────────────────────────────────────── interface FilePreviewPortalProps { - file: NoteFile | null - onClose: () => void + file: NoteFile | null; + onClose: () => void; } function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) { - const [authUrl, setAuthUrl] = useState('') - const rawUrl = file?.url || '' + const [authUrl, setAuthUrl] = useState(''); + const rawUrl = file?.url || ''; useEffect(() => { - setAuthUrl('') - if (!rawUrl) return - getAuthUrl(rawUrl, 'download').then(setAuthUrl) - }, [rawUrl]) + setAuthUrl(''); + if (!rawUrl) return; + getAuthUrl(rawUrl, 'download').then(setAuthUrl); + }, [rawUrl]); - if (!file) return null - const isImage = file.mime_type?.startsWith('image/') - const isPdf = file.mime_type === 'application/pdf' - const isTxt = file.mime_type?.startsWith('text/') + if (!file) return null; + const isImage = file.mime_type?.startsWith('image/'); + const isPdf = file.mime_type === 'application/pdf'; + const isTxt = file.mime_type?.startsWith('text/'); - const openInNewTab = () => openFile(rawUrl).catch(() => {}) + const openInNewTab = () => openFile(rawUrl).catch(() => {}); return ReactDOM.createPortal( -
+
{isImage ? ( /* Image lightbox — floating controls */ -
e.stopPropagation()}> - {authUrl - ? {file.original_name} - : - } -
- {file.original_name} +
e.stopPropagation()}> + {authUrl ? ( + {file.original_name} + ) : ( + + )} +
+ + {file.original_name} +
- - + +
) : ( /* Document viewer — card with header */ -
e.stopPropagation()}> -
- {file.original_name} +
e.stopPropagation()} + > +
+ + {file.original_name} +
- - + +
- {(isPdf || isTxt) ? ( - + {isPdf || isTxt ? ( +

- +

) : (
- +
)} )} , document.body - ) + ); } -function AuthedImg({ src, style, onClick, onMouseEnter, onMouseLeave, alt }: { src: string; style?: React.CSSProperties; onClick?: () => void; onMouseEnter?: React.MouseEventHandler; onMouseLeave?: React.MouseEventHandler; alt?: string }) { - const [authSrc, setAuthSrc] = useState('') +function AuthedImg({ + src, + style, + onClick, + onMouseEnter, + onMouseLeave, + alt, +}: { + src: string; + style?: React.CSSProperties; + onClick?: () => void; + onMouseEnter?: React.MouseEventHandler; + onMouseLeave?: React.MouseEventHandler; + alt?: string; +}) { + const [authSrc, setAuthSrc] = useState(''); useEffect(() => { - getAuthUrl(src, 'download').then(setAuthSrc) - }, [src]) - return authSrc ? {alt} : null + getAuthUrl(src, 'download').then(setAuthSrc); + }, [src]); + return authSrc ? ( + {alt} + ) : null; } const NOTE_COLORS = [ @@ -174,31 +416,31 @@ const NOTE_COLORS = [ { value: '#10b981', label: 'Emerald' }, { value: '#3b82f6', label: 'Blue' }, { value: '#8b5cf6', label: 'Violet' }, -] +]; const formatTimestamp = (ts, t, locale) => { - if (!ts) return '' - const d = new Date(ts.endsWith?.('Z') ? ts : ts + 'Z') - const now = new Date() - const diffMs = now - d - const diffMins = Math.floor(diffMs / 60000) - if (diffMins < 1) return t('collab.chat.justNow') || 'just now' - if (diffMins < 60) return t('collab.chat.minutesAgo', { n: diffMins }) || `${diffMins}m ago` - const diffHrs = Math.floor(diffMins / 60) - if (diffHrs < 24) return t('collab.chat.hoursAgo', { n: diffHrs }) || `${diffHrs}h ago` - const diffDays = Math.floor(diffHrs / 24) - if (diffDays < 7) return t('collab.notes.daysAgo', { n: diffDays }) || `${diffDays}d ago` - return d.toLocaleDateString(locale || undefined, { month: 'short', day: 'numeric' }) -} + if (!ts) return ''; + const d = new Date(ts.endsWith?.('Z') ? ts : ts + 'Z'); + const now = new Date(); + const diffMs = now - d; + const diffMins = Math.floor(diffMs / 60000); + if (diffMins < 1) return t('collab.chat.justNow') || 'just now'; + if (diffMins < 60) return t('collab.chat.minutesAgo', { n: diffMins }) || `${diffMins}m ago`; + const diffHrs = Math.floor(diffMins / 60); + if (diffHrs < 24) return t('collab.chat.hoursAgo', { n: diffHrs }) || `${diffHrs}h ago`; + const diffDays = Math.floor(diffHrs / 24); + if (diffDays < 7) return t('collab.notes.daysAgo', { n: diffDays }) || `${diffDays}d ago`; + return d.toLocaleDateString(locale || undefined, { month: 'short', day: 'numeric' }); +}; // ── Avatar ────────────────────────────────────────────────────────────────── interface UserAvatarProps { - user: NoteAuthor | null - size?: number + user: NoteAuthor | null; + size?: number; } function UserAvatar({ user, size = 14 }: UserAvatarProps) { - if (!user) return null + if (!user) return null; if (user.avatar) { return ( - ) + ); } - const initials = (user.username || '?').slice(0, 1) + const initials = (user.username || '?').slice(0, 1); return ( -
+
{initials}
- ) + ); } // ── New Note Modal (portal to body) ───────────────────────────────────────── interface NoteFormModalProps { - onClose: () => void - onSubmit: (data: { title: string; content: string; category: string; website: string; files?: File[] }) => Promise - onDeleteFile?: (noteId: number, fileId: number) => Promise - existingCategories: string[] - categoryColors: Record - getCategoryColor: (category: string) => string - note: CollabNote | null - tripId: number - t: (key: string) => string + onClose: () => void; + onSubmit: (data: { + title: string; + content: string; + category: string; + website: string; + files?: File[]; + }) => Promise; + onDeleteFile?: (noteId: number, fileId: number) => Promise; + existingCategories: string[]; + categoryColors: Record; + getCategoryColor: (category: string) => string; + note: CollabNote | null; + tripId: number; + t: (key: string) => string; } -function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, categoryColors, getCategoryColor, note, tripId, t }: NoteFormModalProps) { - const can = useCanDo() - const tripObj = useTripStore((s) => s.trip) - const canUploadFiles = can('file_upload', tripObj) - const isEdit = !!note - const allCategories = [...new Set([...existingCategories, ...Object.keys(categoryColors || {})])].filter(Boolean) +function NoteFormModal({ + onClose, + onSubmit, + onDeleteFile, + existingCategories, + categoryColors, + getCategoryColor, + note, + tripId, + t, +}: NoteFormModalProps) { + const can = useCanDo(); + const tripObj = useTripStore((s) => s.trip); + const canUploadFiles = can('file_upload', tripObj); + const isEdit = !!note; + const allCategories = [...new Set([...existingCategories, ...Object.keys(categoryColors || {})])].filter(Boolean); - const [title, setTitle] = useState(note?.title || '') - const [content, setContent] = useState(note?.content || '') - const [category, setCategory] = useState(note?.category || allCategories[0] || '') - const [website, setWebsite] = useState(note?.website || '') - const [pendingFiles, setPendingFiles] = useState([]) - const [existingAttachments, setExistingAttachments] = useState(note?.attachments || []) - const [submitting, setSubmitting] = useState(false) - const fileRef = useRef(null) + const [title, setTitle] = useState(note?.title || ''); + const [content, setContent] = useState(note?.content || ''); + const [category, setCategory] = useState(note?.category || allCategories[0] || ''); + const [website, setWebsite] = useState(note?.website || ''); + const [pendingFiles, setPendingFiles] = useState([]); + const [existingAttachments, setExistingAttachments] = useState(note?.attachments || []); + const [submitting, setSubmitting] = useState(false); + const fileRef = useRef(null); - const finalCategory = category + const finalCategory = category; const handleSubmit = async (e) => { - e.preventDefault() - if (!title.trim()) return - setSubmitting(true) + e.preventDefault(); + if (!title.trim()) return; + setSubmitting(true); try { await onSubmit({ title: title.trim(), @@ -280,22 +540,22 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca color: getCategoryColor(finalCategory), website: website.trim() || null, _pendingFiles: pendingFiles, - }) - onClose() + }); + onClose(); } catch { } finally { - setSubmitting(false) + setSubmitting(false); } - } + }; const handleDeleteAttachment = async (fileId) => { if (onDeleteFile && note) { - await onDeleteFile(note.id, fileId) - setExistingAttachments(prev => prev.filter(a => a.id !== fileId)) + await onDeleteFile(note.id, fileId); + setExistingAttachments((prev) => prev.filter((a) => a.id !== fileId)); } - } + }; - const canSubmit = title.trim() && !submitting + const canSubmit = title.trim() && !submitting; return ReactDOM.createPortal(
e.stopPropagation()} - onPaste={e => { - if (!canUploadFiles) return - const items = e.clipboardData?.items - if (!items) return + onClick={(e) => e.stopPropagation()} + onPaste={(e) => { + if (!canUploadFiles) return; + const items = e.clipboardData?.items; + if (!items) return; for (const item of Array.from(items)) { if (item.type.startsWith('image/') || item.type === 'application/pdf') { - e.preventDefault() - const file = item.getAsFile() - if (file) setPendingFiles(prev => [...prev, file]) - return + e.preventDefault(); + const file = item.getAsFile(); + if (file) setPendingFiles((prev) => [...prev, file]); + return; } } }} onSubmit={handleSubmit} > {/* Modal header */} -
-

+
+

{isEdit ? t('collab.notes.edit') : t('collab.notes.new')}