mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 06:11:45 +00:00
Migrate TREK 3 to NestJS + React 19 (shared Zod contracts) (#1087)
* Migrate TREK 3 to NestJS + React 19 with a shared Zod contract layer
Brownfield strangler migration of the backend onto NestJS modules
(auth, trips, days, places, assignments, packing, todo, budget,
reservations, collab, files, photos, journey, share, settings, backup,
oidc, oauth, admin, atlas, vacay, weather, airports, maps, categories,
tags, notifications, system-notices) served through a per-prefix
dispatcher, keeping the existing SQLite/better-sqlite3 DB and JWT
httpOnly cookie auth, with behavioural parity for every route.
Client: React 19 upgrade, "page = wiring container + data hook"
pattern across all pages, per-domain Zustand stores bound to
@trek/shared contracts, and decomposition of the large components
(DayPlanSidebar, PackingListPanel, CollabNotes, FileManager,
MemoriesPanel, PlacesSidebar, CollabChat, SystemNoticeModal,
BudgetPanel, PlaceFormModal, ...) into focused render units backed by
in-file hooks.
Apply the shared global request pipeline (helmet/CSP, CORS, HSTS,
forced HTTPS, the global MFA policy and request logging) to the NestJS
instance as well, so a migrated route is protected identically to the
legacy fallback rather than bypassing it.
* Finish the NestJS migration — drop the legacy Express app
NestJS now serves the whole surface: every /api domain plus the platform
routes (uploads, /mcp, the OAuth/MCP SDK + /.well-known metadata and the
production SPA fallback). Removed server/src/app.ts, all of
server/src/routes/* and the strangler dispatcher; index.ts and the
integration suite share a single buildApp() bootstrap so prod and tests
can't drift.
- Platform/transport routes extracted to nest/platform/platform.routes.ts
and mounted before app.init() — Nest's router answers an unmatched
request with a 404, so a route registered after init is never reached.
The SPA fallback is a NotFoundException filter and the catch-all uses a
RegExp (Express 5's path-to-regexp rejects a bare '*').
- New modules: memories (/api/integrations/memories — the Journey
gallery's Immich/Synology proxy), addons (GET /api/addons) and the
cross-trip GET /api/reservations/upcoming.
- TrekExceptionFilter reproduces the old multer / err.statusCode handling
so upload rejections keep their 400/413 { error } body and non-ASCII
filenames survive (defParamCharset).
- addTripToJourney and the MCP get_journey_share_link tool gained the
trip-access check they were missing.
- Re-pointed the 34 integration tests + the websocket test onto the Nest
app; removed the now-meaningless Express-vs-Nest parity tests and a few
orphaned client components.
* Restore the reset-password rate limit and fix copyTrip reservation links
Two correctness/security gaps the NestJS migration introduced:
- POST /api/auth/reset-password lost its per-IP rate limiter. Restore it
(5 attempts / 15 min on a dedicated bucket, same as the old resetLimiter)
so reset tokens can't be brute-forced unthrottled. Covered by AUTH-019.
- copyTripById did not copy reservations.end_day_id (a day reference — now
remapped through dayMap like day_id) or needs_review, so a duplicated trip
lost multi-day transport end-day links and reset the review flag.
* Clean up dead code, dedupe helpers, fix the reset-password contract
- Remove server exports orphaned by the Express removal: the immich
album-link helpers, seven route-only service exports, getFileByIdFull;
de-export internal-only helpers (utcSuffix).
- De-duplicate verifyTripAccess (9 identical copies -> services/tripAccess.ts)
and avatarUrl (3 -> services/avatarUrl.ts); name the bcrypt cost
(BCRYPT_COST) and the email regex (EMAIL_REGEX). Public API unchanged.
- resetPasswordRequestSchema declared `password`, but the client sends and
the service reads `new_password` — rename it so the contract matches and
the client types resolve.
- Make ATLAS-013 deterministic: stub the admin-1 GeoJSON download instead of
fetching ~4600 features from GitHub during the test (it hung the suite).
* Make the client typecheck runnable (vitest/vite ambient types)
The client had no `typecheck` script and tsc couldn't even start (the
baseUrl deprecation errored out, same as server/shared already silence).
Add `ignoreDeprecations: "6.0"` to match the other workspaces, a `typecheck`
npm script, and a src/vite-env.d.ts referencing vite/client + vitest/globals
so tsc knows the test globals (describe/it/expect/vi). This turns ~3600
phantom "Cannot find name" errors into a real, measurable count (~590 actual
type errors remain, to be worked down). Type-only; no runtime change.
* Derive client domain types from the shared schema contracts
Add entity/response Zod schemas to @trek/shared (place, trip, assignment, day, budget, packing, reservation), each matched against the producing server service, and re-export them from client types.ts instead of the hand-written duplicates that had drifted (name/title, amount/total_price, owner_id/user_id, cover_url/cover_image, ...). Updates the call sites and test fixtures the corrected types surfaced; type-only, no runtime behaviour change.
* chore(db): log swallowed errors in addon-disable migration + guard against destructive migrations
The migration that disables the legacy "memories" addon swallowed any
error in an empty catch, as did ~30 other catch blocks in the migration
runner (column adds, the journey rebuild, index probes). Replace each
silent catch with the existing console.warn('[migrations] ...') log so
failures are visible. Control flow is unchanged: every step stays
non-fatal, nothing new is thrown.
Add a static guardrail test that scans the migration source and fails
when a new destructive statement (DROP TABLE / DROP COLUMN / TRUNCATE /
DELETE FROM / ALTER ... DROP) appears outside a reviewed allowlist, and
when an empty/silent catch block is reintroduced. The existing
destructive statements are all legitimate table rebuilds or
bounded cleanups and are recorded in the allowlist with a reason.
* Re-check SSRF on every redirect hop when resolving short links
Replace the one-shot checkSsrf + fetch(redirect:'follow') in the maps and place short-link resolvers with safeFetchFollow, which follows redirects manually and re-runs checkSsrf against the DNS-pinned IP of each hop (max 5). A redirect to an internal/loopback address is now blocked even when the initial URL is public, while legitimate cross-host redirects (goo.gl -> maps.google.com) still resolve.
* Reject WebSocket tokens minted before a password change
Stamp the user's password_version onto the ephemeral ws token and verify it on connect, closing the socket (4001) when it no longer matches, so a token issued before a password reset can't be replayed. Tokens minted without a version are treated as version 0, matching the JWT pv-claim semantics.
* fix(i18n): guard locale key parity and finish the OAuth consent page strings
Every non-en locale now exposes the exact same flat key set as en. Keys that
had drifted out of sync are backfilled with the English source value (tagged
en-fallback) so t() resolves a real string instead of relying on the silent
runtime fallback; no existing translation was touched and no key was removed.
Add a parity test that imports each aggregated locale bundle and asserts its
key set matches en, with a diagnostic listing of any missing/extra keys. This
complements the file-level check in shared/scripts by guarding the merged
export the app actually serves.
Finish internationalising OAuthAuthorizePage: the ~15 remaining hardcoded
English chrome strings now go through oauth.authorize.* keys (English source
in en, en-fallback placeholders elsewhere). Markup and behaviour are unchanged.
* Add semantic theme color tokens to Tailwind
Map the CSS theme variables from src/index.css (:root light / .dark dark) to named Tailwind utilities — bg-surface, text-content, border-edge, bg-accent and their variants. This gives components a Tailwind-native target for the theme colors so we can replace inline `style={{ ... 'var(--...)' }}` with utility classes without changing the rendered values.
* Surface silent store failures to the user and validate API responses in dev
Reservation toggle, todo/packing toggle and budget reorder were swallowing API errors after rolling back, so the user saw the change silently snap back with no explanation. Route those failures through the existing toast channel (new store/notify.ts bridges to window.__addToast, the same channel SystemNoticeBanner uses); the reservation toggle re-throws so ReservationsPanel's own translated toast finally fires. Also wire the existing parseInDev/checkInDev response validation into the maps and notification-test endpoints to catch contract drift in dev.
* Migrate static theme inline styles to Tailwind utilities and extract page sub-components
Replace the static, color-only inline `style={{ ... 'var(--bg-primary)' ... }}` props with the new semantic Tailwind utilities (bg-surface, text-content, border-edge, ...) wherever the result is byte-identical; dynamic/conditional theme styles and hardcoded status colors are left inline. Extract the Atlas country-search autocomplete, the Admin update banner, and two Journey dialogs into their own presentational components to shrink the oversized page files, keeping behaviour and markup identical.
* Remove the unrouted photos page and its dead photo components
PhotosPage was never wired into the router and its usePhotos hook read a tripStore photos slice that was never implemented; the Photos gallery, lightbox and upload components were only reachable through it. Per-trip photos now live in the Journey gallery (Immich/Synology). Removed the dead page, hook and components — the live Journey PhotoLightbox is a separate component and stays.
* Resolve the remaining client type errors and the trip.title navbar bug
Drive the client typecheck to zero without any/ts-ignore: convert the tripId route param to a number once at the page boundary so it matches the numeric props and store actions it feeds, fix trip.name -> trip.title (the wire field is title, so the old read rendered blank in the files/offline views), and tighten the scattered handler-arity, DOM-cast and untyped-payload sites. No runtime behaviour change.
* Convert the remaining dynamic and hardcoded inline styles to Tailwind utilities
Second styling pass over the components and pages: move conditional theme colors into className ternaries (bg-accent / bg-surface-hover etc.), turn reused CSSProperties constants into className constants, and express static hardcoded hex/rgba colors as Tailwind arbitrary values so the exact rendered colour is preserved. Truly dynamic styling (computed geometry, gradients, multi-part shadows, data-driven colours, the undefined --sidebar/--nav layout vars) stays inline as it cannot be expressed as a static class. Updated three component tests that asserted the old inline active-state styles to assert the equivalent utility class instead.
Verified: client typecheck 0, full client suite green, and a live light/dark render check in the dev server confirms the semantic theme tokens resolve correctly (the earlier 'transparent popups' were a stale dev server that pre-dated the tailwind.config token addition, not a code issue).
* Add eslint flat-config for client and server and gate typecheck, lint and pages in CI
client and server had lint scripts but no eslint config (only shared was linted in CI). Add flat configs mirroring shared's stack (js + typescript-eslint recommended + eslint-config-prettier) plus the client's react-hooks/react-refresh plugins. Pre-existing patterns in this never-linted code (explicit any, require() in the CommonJS server, empty catches, exhaustive-deps) are set to 'warn' rather than 'error' so the gate passes at 0 errors without a repo-wide reformat — these can be ratcheted to errors over time. Wire blocking typecheck + lint + lint:pages steps into the client and server CI jobs (now that both typechecks are clean) and promote the server typecheck from informational to blocking.
* Decompose the remaining God Components into hooks, helpers and sub-components
FE6: split the oversized page and panel components into thin layout shells plus colocated use<Component> hooks, .constants.ts, .helpers.ts (with tests) and presentational sub-components, following the established 'logic in a hook, render in slices' pattern. Behaviour, markup, classes and effect order are unchanged. Largest reductions: PackingListPanel 1598->42, FileManager 1055->36, AdminPage 1525->167, BudgetPanel 1266->146, JourneyDetailPage 2822->547, PlacesSidebar 945->66, CollabChat 861->106, CollabNotes 1417->532. DayPlanSidebar's drag-and-drop render body was left intact (ref-identity sensitive) and only its toolbar/modals/constants were extracted.
* Fix duplicate React keys in the file-assign place list
When a place is assigned to the same day more than once it appeared twice in a day's list, so the place-button key={p.id} collided and React warned about duplicate keys. Key by place id + render index so siblings stay unique. Pre-existing in the old FileManager; behaviour unchanged.
* Format the shared package and drop an unused import to satisfy the lint gate
The i18n and schema changes added code that wasn't prettier-formatted, and place.schema.ts imported categorySchema without using it. Run prettier over shared and remove the import so 'npm run lint' + 'format:check' pass.
* Install all workspaces in the server CI job so SWC's native binary is present
The server vitest config transforms via unplugin-swc, which needs @swc/core's platform-specific native binary. A workspace-scoped 'npm ci --workspace server' skips that optional dependency, so vitest failed to load the config on the Linux runner. Use a full 'npm ci'.
* Re-resolve dependencies with npm install in the server CI job for SWC
Full 'npm ci' still skipped @swc/core's Linux native binary because the committed lockfile was generated on Windows and lacks the Linux optional-dep install metadata. 'npm install' re-resolves and fetches the platform-matching binary, which the server's unplugin-swc transform needs to load vitest.config.ts.
* Install @swc/core's Linux binary explicitly in the server CI job
Neither npm ci nor npm install fetched @swc/core-linux-x64-gnu on the Linux runner because the lockfile was generated on Windows and lacks the Linux optional-dep metadata. Add a step that installs the matching @swc/core-linux-x64-gnu version (no-save, no-lockfile) so unplugin-swc can load the server's vitest config.
* Use legacy-peer-deps when installing the SWC Linux binary in CI
The explicit @swc/core-linux-x64-gnu install re-resolved the tree and hit the pre-existing lucide-react/react-19 peer conflict that the lockfile was generated around. Add --legacy-peer-deps so the step matches the project's resolution and installs the binary.
* Keep the lockfile when installing the SWC binary so other deps stay pinned
Dropping --no-package-lock made npm re-resolve the whole tree and upgrade eslint, whose newer recommended config flagged no-useless-assignment as an error in the server lint step. Keep the lockfile so only @swc/core-linux-x64-gnu is added and every other dependency (incl. eslint) stays at its locked version.
This commit is contained in:
+162
-81
@@ -1,7 +1,82 @@
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
import type { WeatherResult } from '@trek/shared'
|
||||
import type { z } from 'zod'
|
||||
import {
|
||||
weatherResultSchema, type WeatherResult,
|
||||
inAppListResultSchema, type InAppListResult,
|
||||
unreadCountResultSchema, type UnreadCountResult,
|
||||
channelTestResultSchema,
|
||||
mapsSearchResultSchema, mapsAutocompleteResultSchema, mapsPlaceDetailsResultSchema,
|
||||
mapsPlacePhotoResultSchema, mapsReverseResultSchema, mapsResolveUrlResultSchema,
|
||||
type NotificationRespondRequest,
|
||||
type SettingUpsertRequest, type SettingsBulkRequest,
|
||||
type JourneyCreateRequest, type JourneyAddTripRequest,
|
||||
type JourneyReorderEntriesRequest, type JourneyProviderPhotosRequest,
|
||||
type JourneyShareLinkRequest,
|
||||
type RegisterRequest, type LoginRequest, type ForgotPasswordRequest,
|
||||
type ResetPasswordRequest, type ChangePasswordRequest,
|
||||
type MfaVerifyLoginRequest, type MfaEnableRequest, type McpTokenCreateRequest,
|
||||
type TripAddMemberRequest, type AssignmentReorderRequest,
|
||||
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
|
||||
type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest,
|
||||
type DayCreateRequest, type DayUpdateRequest,
|
||||
type PlaceCreateRequest, type PlaceUpdateRequest,
|
||||
type ReservationCreateRequest, type ReservationUpdateRequest,
|
||||
type AccommodationCreateRequest, type AccommodationUpdateRequest,
|
||||
type BudgetCreateItemRequest, type BudgetUpdateItemRequest,
|
||||
type PackingCreateItemRequest, type PackingUpdateItemRequest,
|
||||
type TodoCreateItemRequest, type TodoUpdateItemRequest,
|
||||
type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest,
|
||||
type PlaceBulkDeleteRequest,
|
||||
type DayNoteCreateRequest, type DayNoteUpdateRequest,
|
||||
type PackingImportRequest, type PackingBagMembersRequest, type PackingUpdateBagRequest,
|
||||
type PackingCategoryAssigneesRequest,
|
||||
type BudgetUpdateMembersRequest, type BudgetToggleMemberPaidRequest, type BudgetReorderCategoriesRequest,
|
||||
type TodoCategoryAssigneesRequest,
|
||||
type CollabNoteCreateRequest, type CollabNoteUpdateRequest, type CollabPollCreateRequest,
|
||||
type CollabPollVoteRequest, type CollabMessageCreateRequest, type CollabReactionRequest,
|
||||
type FileUpdateRequest, type FileLinkRequest,
|
||||
type CreateTagRequest, type UpdateTagRequest,
|
||||
type CreateCategoryRequest, type UpdateCategoryRequest,
|
||||
type PlaceImportListRequest,
|
||||
} from '@trek/shared'
|
||||
import { getSocketId } from './websocket'
|
||||
import { isReachable, probeNow } from '../sync/connectivity'
|
||||
|
||||
/**
|
||||
* Validate a response payload against its @trek/shared Zod schema — but only in
|
||||
* dev, and never throwing. A drift between the server contract and the client's
|
||||
* expected shape is surfaced as a console warning during development; in
|
||||
* production (and on any mismatch) the data passes through untouched, so adding
|
||||
* validation can never break a working call. This is the typed-request helper
|
||||
* the FE adopts per domain as each backend module lands on @trek/shared.
|
||||
*/
|
||||
const API_DEV = Boolean((import.meta as { env?: { DEV?: boolean } }).env?.DEV)
|
||||
export function parseInDev<S extends z.ZodTypeAny>(schema: S, data: unknown, label: string): z.infer<S> {
|
||||
if (API_DEV) {
|
||||
const result = schema.safeParse(data)
|
||||
if (!result.success) {
|
||||
console.warn(`[api] ${label}: response did not match the @trek/shared schema`, result.error.issues)
|
||||
}
|
||||
}
|
||||
return data as z.infer<S>
|
||||
}
|
||||
|
||||
/**
|
||||
* Same dev-only drift check as parseInDev, but passes the payload straight
|
||||
* through with its original inferred type instead of the schema type. Use this
|
||||
* for endpoints whose existing consumers rely on the loose `r.data` type — it
|
||||
* adds the development contract-drift warning without retyping the public
|
||||
* surface (so it can never break a consumer that worked before).
|
||||
*/
|
||||
function checkInDev<T>(schema: z.ZodTypeAny, data: T, label: string): T {
|
||||
if (API_DEV) {
|
||||
const result = schema.safeParse(data)
|
||||
if (!result.success) {
|
||||
console.warn(`[api] ${label}: response did not match the @trek/shared schema`, result.error.issues)
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
const RATE_LIMIT_MESSAGES: Record<string, string> = {
|
||||
en: 'Too many attempts. Please try again later.',
|
||||
de: 'Zu viele Versuche. Bitte versuchen Sie es später erneut.',
|
||||
@@ -154,12 +229,12 @@ apiClient.interceptors.response.use(
|
||||
)
|
||||
|
||||
export const authApi = {
|
||||
register: (data: { username: string; email: string; password: string; invite_token?: string }) => apiClient.post('/auth/register', data).then(r => r.data),
|
||||
register: (data: RegisterRequest) => apiClient.post('/auth/register', data).then(r => r.data),
|
||||
validateInvite: (token: string) => apiClient.get(`/auth/invite/${token}`).then(r => r.data),
|
||||
login: (data: { email: string; password: string }) => apiClient.post('/auth/login', data).then(r => r.data),
|
||||
verifyMfaLogin: (data: { mfa_token: string; code: string }) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data),
|
||||
login: (data: LoginRequest) => apiClient.post('/auth/login', data).then(r => r.data),
|
||||
verifyMfaLogin: (data: MfaVerifyLoginRequest) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data),
|
||||
mfaSetup: () => apiClient.post('/auth/mfa/setup', {}).then(r => r.data),
|
||||
mfaEnable: (data: { code: string }) => apiClient.post('/auth/mfa/enable', data).then(r => r.data as { success: boolean; mfa_enabled: boolean; backup_codes?: string[] }),
|
||||
mfaEnable: (data: MfaEnableRequest) => apiClient.post('/auth/mfa/enable', data).then(r => r.data as { success: boolean; mfa_enabled: boolean; backup_codes?: string[] }),
|
||||
mfaDisable: (data: { password: string; code: string }) => apiClient.post('/auth/mfa/disable', data).then(r => r.data),
|
||||
me: () => apiClient.get('/auth/me').then(r => r.data),
|
||||
updateMapsKey: (key: string | null) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data),
|
||||
@@ -173,14 +248,14 @@ export const authApi = {
|
||||
updateAppSettings: (data: Record<string, unknown>) => apiClient.put('/auth/app-settings', data).then(r => r.data),
|
||||
validateKeys: () => apiClient.get('/auth/validate-keys').then(r => r.data),
|
||||
travelStats: () => apiClient.get('/auth/travel-stats').then(r => r.data),
|
||||
changePassword: (data: { current_password: string; new_password: string }) => apiClient.put('/auth/me/password', data).then(r => r.data),
|
||||
forgotPassword: (data: { email: string }) => apiClient.post('/auth/forgot-password', data).then(r => r.data as { ok: true }),
|
||||
resetPassword: (data: { token: string; new_password: string; mfa_code?: string }) => apiClient.post('/auth/reset-password', data).then(r => r.data as { success?: true; mfa_required?: true }),
|
||||
changePassword: (data: ChangePasswordRequest) => apiClient.put('/auth/me/password', data).then(r => r.data),
|
||||
forgotPassword: (data: ForgotPasswordRequest) => apiClient.post('/auth/forgot-password', data).then(r => r.data as { ok: true }),
|
||||
resetPassword: (data: ResetPasswordRequest) => apiClient.post('/auth/reset-password', data).then(r => r.data as { success?: true; mfa_required?: true }),
|
||||
deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data),
|
||||
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
|
||||
mcpTokens: {
|
||||
list: () => apiClient.get('/auth/mcp-tokens').then(r => r.data),
|
||||
create: (name: string) => apiClient.post('/auth/mcp-tokens', { name }).then(r => r.data),
|
||||
create: (name: string) => apiClient.post('/auth/mcp-tokens', { name } satisfies McpTokenCreateRequest).then(r => r.data),
|
||||
delete: (id: number) => apiClient.delete(`/auth/mcp-tokens/${id}`).then(r => r.data),
|
||||
},
|
||||
}
|
||||
@@ -226,32 +301,32 @@ export const oauthApi = {
|
||||
|
||||
export const tripsApi = {
|
||||
list: (params?: Record<string, unknown>) => apiClient.get('/trips', { params }).then(r => r.data),
|
||||
create: (data: Record<string, unknown>) => apiClient.post('/trips', data).then(r => r.data),
|
||||
create: (data: TripCreateRequest) => apiClient.post('/trips', data).then(r => r.data),
|
||||
get: (id: number | string) => apiClient.get(`/trips/${id}`).then(r => r.data),
|
||||
update: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${id}`, data).then(r => r.data),
|
||||
update: (id: number | string, data: TripUpdateRequest) => apiClient.put(`/trips/${id}`, data).then(r => r.data),
|
||||
delete: (id: number | string) => apiClient.delete(`/trips/${id}`).then(r => r.data),
|
||||
uploadCover: (id: number | string, formData: FormData) => apiClient.post(`/trips/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
|
||||
archive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data),
|
||||
unarchive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data),
|
||||
getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data),
|
||||
addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data),
|
||||
addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier } satisfies TripAddMemberRequest).then(r => r.data),
|
||||
removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data),
|
||||
copy: (id: number | string, data?: { title?: string }) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data),
|
||||
copy: (id: number | string, data?: TripCopyRequest) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data),
|
||||
bundle: (id: number | string) => apiClient.get(`/trips/${id}/bundle`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const daysApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data),
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
|
||||
update: (tripId: number | string, dayId: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
|
||||
create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
|
||||
update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const placesApi = {
|
||||
list: (tripId: number | string, params?: Record<string, unknown>) => apiClient.get(`/trips/${tripId}/places`, { params }).then(r => r.data),
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
|
||||
create: (tripId: number | string, data: PlaceCreateRequest) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
|
||||
get: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
||||
update: (tripId: number | string, id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number | string, data: PlaceUpdateRequest) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
||||
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
|
||||
importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean }) => {
|
||||
@@ -270,64 +345,64 @@ export const placesApi = {
|
||||
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||
},
|
||||
importGoogleList: (tripId: number | string, url: string) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url } satisfies PlaceImportListRequest).then(r => r.data),
|
||||
importNaverList: (tripId: number | string, url: string) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
|
||||
bulkDelete: (tripId: number | string, ids: number[]) =>
|
||||
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids }).then(r => r.data),
|
||||
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data),
|
||||
}
|
||||
|
||||
export const assignmentsApi = {
|
||||
list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/assignments`).then(r => r.data),
|
||||
create: (tripId: number | string, dayId: number | string, data: { place_id: number | string }) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data),
|
||||
create: (tripId: number | string, dayId: number | string, data: AssignmentCreateRequest) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/assignments/${id}`).then(r => r.data),
|
||||
reorder: (tripId: number | string, dayId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds }).then(r => r.data),
|
||||
reorder: (tripId: number | string, dayId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds } satisfies AssignmentReorderRequest).then(r => r.data),
|
||||
move: (tripId: number | string, assignmentId: number, newDayId: number | string, orderIndex: number | null) => apiClient.put(`/trips/${tripId}/assignments/${assignmentId}/move`, { new_day_id: newDayId, order_index: orderIndex }).then(r => r.data),
|
||||
update: (tripId: number | string, dayId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/${id}`, data).then(r => r.data),
|
||||
getParticipants: (tripId: number | string, id: number) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data),
|
||||
setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds }).then(r => r.data),
|
||||
updateTime: (tripId: number | string, id: number, times: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data),
|
||||
setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds } satisfies AssignmentParticipantsRequest).then(r => r.data),
|
||||
updateTime: (tripId: number | string, id: number, times: AssignmentTimeRequest) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data),
|
||||
}
|
||||
|
||||
export const packingApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data),
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data),
|
||||
bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items }).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
|
||||
create: (tripId: number | string, data: PackingCreateItemRequest) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data),
|
||||
bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items } satisfies PackingImportRequest).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: PackingUpdateItemRequest) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data),
|
||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds }).then(r => r.data),
|
||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds } satisfies PackingReorderRequest).then(r => r.data),
|
||||
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data),
|
||||
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data),
|
||||
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies PackingCategoryAssigneesRequest).then(r => r.data),
|
||||
applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data),
|
||||
saveAsTemplate: (tripId: number | string, name: string) => apiClient.post(`/trips/${tripId}/packing/save-as-template`, { name }).then(r => r.data),
|
||||
setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds }).then(r => r.data),
|
||||
setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds } satisfies PackingBagMembersRequest).then(r => r.data),
|
||||
listBags: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/bags`).then(r => r.data),
|
||||
createBag: (tripId: number | string, data: { name: string; color?: string }) => apiClient.post(`/trips/${tripId}/packing/bags`, data).then(r => r.data),
|
||||
updateBag: (tripId: number | string, bagId: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}`, data).then(r => r.data),
|
||||
createBag: (tripId: number | string, data: PackingCreateBagRequest) => apiClient.post(`/trips/${tripId}/packing/bags`, data).then(r => r.data),
|
||||
updateBag: (tripId: number | string, bagId: number, data: PackingUpdateBagRequest) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}`, data).then(r => r.data),
|
||||
deleteBag: (tripId: number | string, bagId: number) => apiClient.delete(`/trips/${tripId}/packing/bags/${bagId}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const todoApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo`).then(r => r.data),
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/todo`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/todo/${id}`, data).then(r => r.data),
|
||||
create: (tripId: number | string, data: TodoCreateItemRequest) => apiClient.post(`/trips/${tripId}/todo`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: TodoUpdateItemRequest) => apiClient.put(`/trips/${tripId}/todo/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/todo/${id}`).then(r => r.data),
|
||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/todo/reorder`, { orderedIds }).then(r => r.data),
|
||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/todo/reorder`, { orderedIds } satisfies TodoReorderRequest).then(r => r.data),
|
||||
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo/category-assignees`).then(r => r.data),
|
||||
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/todo/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data),
|
||||
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/todo/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies TodoCategoryAssigneesRequest).then(r => r.data),
|
||||
}
|
||||
|
||||
export const tagsApi = {
|
||||
list: () => apiClient.get('/tags').then(r => r.data),
|
||||
create: (data: Record<string, unknown>) => apiClient.post('/tags', data).then(r => r.data),
|
||||
update: (id: number, data: Record<string, unknown>) => apiClient.put(`/tags/${id}`, data).then(r => r.data),
|
||||
create: (data: CreateTagRequest) => apiClient.post('/tags', data).then(r => r.data),
|
||||
update: (id: number, data: UpdateTagRequest) => apiClient.put(`/tags/${id}`, data).then(r => r.data),
|
||||
delete: (id: number) => apiClient.delete(`/tags/${id}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const categoriesApi = {
|
||||
list: () => apiClient.get('/categories').then(r => r.data),
|
||||
create: (data: Record<string, unknown>) => apiClient.post('/categories', data).then(r => r.data),
|
||||
update: (id: number, data: Record<string, unknown>) => apiClient.put(`/categories/${id}`, data).then(r => r.data),
|
||||
create: (data: CreateCategoryRequest) => apiClient.post('/categories', data).then(r => r.data),
|
||||
update: (id: number, data: UpdateCategoryRequest) => apiClient.put(`/categories/${id}`, data).then(r => r.data),
|
||||
delete: (id: number) => apiClient.delete(`/categories/${id}`).then(r => r.data),
|
||||
}
|
||||
|
||||
@@ -390,7 +465,7 @@ export const addonsApi = {
|
||||
|
||||
export const journeyApi = {
|
||||
list: () => apiClient.get('/journeys').then(r => r.data),
|
||||
create: (data: { title: string; subtitle?: string; trip_ids?: number[] }) => apiClient.post('/journeys', data).then(r => r.data),
|
||||
create: (data: JourneyCreateRequest) => apiClient.post('/journeys', data).then(r => r.data),
|
||||
get: (id: number) => apiClient.get(`/journeys/${id}`).then(r => r.data),
|
||||
update: (id: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/${id}`, data).then(r => r.data),
|
||||
delete: (id: number) => apiClient.delete(`/journeys/${id}`).then(r => r.data),
|
||||
@@ -399,7 +474,7 @@ export const journeyApi = {
|
||||
availableTrips: () => apiClient.get('/journeys/available-trips').then(r => r.data),
|
||||
|
||||
// Trips (sync sources)
|
||||
addTrip: (id: number, tripId: number) => apiClient.post(`/journeys/${id}/trips`, { trip_id: tripId }).then(r => r.data),
|
||||
addTrip: (id: number, tripId: number) => apiClient.post(`/journeys/${id}/trips`, { trip_id: tripId } satisfies JourneyAddTripRequest).then(r => r.data),
|
||||
removeTrip: (id: number, tripId: number) => apiClient.delete(`/journeys/${id}/trips/${tripId}`).then(r => r.data),
|
||||
|
||||
// Entries
|
||||
@@ -407,7 +482,7 @@ export const journeyApi = {
|
||||
createEntry: (id: number, data: Record<string, unknown>) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data),
|
||||
updateEntry: (entryId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/entries/${entryId}`, data).then(r => r.data),
|
||||
deleteEntry: (entryId: number) => apiClient.delete(`/journeys/entries/${entryId}`).then(r => r.data),
|
||||
reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds }).then(r => r.data),
|
||||
reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds } satisfies JourneyReorderEntriesRequest).then(r => r.data),
|
||||
|
||||
// Photos
|
||||
uploadPhotos: (entryId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
|
||||
@@ -424,7 +499,7 @@ export const journeyApi = {
|
||||
onUploadProgress: opts?.onUploadProgress,
|
||||
signal: opts?.signal,
|
||||
}).then(r => r.data),
|
||||
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data),
|
||||
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).then(r => r.data),
|
||||
@@ -446,19 +521,19 @@ export const journeyApi = {
|
||||
|
||||
// Share
|
||||
getShareLink: (id: number) => apiClient.get(`/journeys/${id}/share-link`).then(r => r.data),
|
||||
createShareLink: (id: number, perms: { share_timeline?: boolean; share_gallery?: boolean; share_map?: boolean }) => apiClient.post(`/journeys/${id}/share-link`, perms).then(r => r.data),
|
||||
createShareLink: (id: number, perms: JourneyShareLinkRequest) => apiClient.post(`/journeys/${id}/share-link`, perms).then(r => r.data),
|
||||
deleteShareLink: (id: number) => apiClient.delete(`/journeys/${id}/share-link`).then(r => r.data),
|
||||
getPublicJourney: (token: string) => apiClient.get(`/public/journey/${token}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const mapsApi = {
|
||||
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
|
||||
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
|
||||
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal) =>
|
||||
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => r.data),
|
||||
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data),
|
||||
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data),
|
||||
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data),
|
||||
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => r.data),
|
||||
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
|
||||
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details')),
|
||||
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => checkInDev(mapsPlacePhotoResultSchema, r.data, 'maps.placePhoto')),
|
||||
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => checkInDev(mapsReverseResultSchema, r.data, 'maps.reverse')),
|
||||
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => checkInDev(mapsResolveUrlResultSchema, r.data, 'maps.resolveUrl')),
|
||||
}
|
||||
|
||||
export const airportsApi = {
|
||||
@@ -468,15 +543,15 @@ export const airportsApi = {
|
||||
|
||||
export const budgetApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget`).then(r => r.data),
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/budget/${id}`, data).then(r => r.data),
|
||||
create: (tripId: number | string, data: BudgetCreateItemRequest) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: BudgetUpdateItemRequest) => apiClient.put(`/trips/${tripId}/budget/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/budget/${id}`).then(r => r.data),
|
||||
setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds }).then(r => r.data),
|
||||
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data),
|
||||
setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds } satisfies BudgetUpdateMembersRequest).then(r => r.data),
|
||||
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid } satisfies BudgetToggleMemberPaidRequest).then(r => r.data),
|
||||
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
|
||||
settlement: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/settlement`).then(r => r.data),
|
||||
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
|
||||
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories }).then(r => r.data),
|
||||
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
|
||||
}
|
||||
|
||||
export const filesApi = {
|
||||
@@ -484,13 +559,13 @@ export const filesApi = {
|
||||
upload: (tripId: number | string, formData: FormData) => apiClient.post(`/trips/${tripId}/files`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
}).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: FileUpdateRequest) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}`).then(r => r.data),
|
||||
toggleStar: (tripId: number | string, id: number) => apiClient.patch(`/trips/${tripId}/files/${id}/star`).then(r => r.data),
|
||||
restore: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/files/${id}/restore`).then(r => r.data),
|
||||
permanentDelete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}/permanent`).then(r => r.data),
|
||||
emptyTrash: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/files/trash/empty`).then(r => r.data),
|
||||
addLink: (tripId: number | string, fileId: number, data: { reservation_id?: number; assignment_id?: number }) => apiClient.post(`/trips/${tripId}/files/${fileId}/link`, data).then(r => r.data),
|
||||
addLink: (tripId: number | string, fileId: number, data: FileLinkRequest) => apiClient.post(`/trips/${tripId}/files/${fileId}/link`, data).then(r => r.data),
|
||||
removeLink: (tripId: number | string, fileId: number, linkId: number) => apiClient.delete(`/trips/${tripId}/files/${fileId}/link/${linkId}`).then(r => r.data),
|
||||
getLinks: (tripId: number | string, fileId: number) => apiClient.get(`/trips/${tripId}/files/${fileId}/links`).then(r => r.data),
|
||||
}
|
||||
@@ -498,15 +573,15 @@ export const filesApi = {
|
||||
export const reservationsApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/reservations`).then(r => r.data),
|
||||
upcoming: () => apiClient.get('/reservations/upcoming').then(r => r.data),
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
|
||||
create: (tripId: number | string, data: ReservationCreateRequest) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: ReservationUpdateRequest) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
|
||||
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const weatherApi = {
|
||||
get: (lat: number, lng: number, date: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data),
|
||||
getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise<WeatherResult> => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
|
||||
get: (lat: number, lng: number, date: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')),
|
||||
getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise<WeatherResult> => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getDetailed')),
|
||||
}
|
||||
|
||||
export const configApi = {
|
||||
@@ -516,40 +591,46 @@ export const configApi = {
|
||||
|
||||
export const settingsApi = {
|
||||
get: () => apiClient.get('/settings').then(r => r.data),
|
||||
set: (key: string, value: unknown) => apiClient.put('/settings', { key, value }).then(r => r.data),
|
||||
setBulk: (settings: Record<string, unknown>) => apiClient.post('/settings/bulk', { settings }).then(r => r.data),
|
||||
set: (key: string, value: unknown) => {
|
||||
const body: SettingUpsertRequest = { key, value }
|
||||
return apiClient.put('/settings', body).then(r => r.data)
|
||||
},
|
||||
setBulk: (settings: Record<string, unknown>) => {
|
||||
const body: SettingsBulkRequest = { settings }
|
||||
return apiClient.post('/settings/bulk', body).then(r => r.data)
|
||||
},
|
||||
}
|
||||
|
||||
export const accommodationsApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/accommodations`).then(r => r.data),
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data),
|
||||
create: (tripId: number | string, data: AccommodationCreateRequest) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: AccommodationUpdateRequest) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/accommodations/${id}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const dayNotesApi = {
|
||||
list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/notes`).then(r => r.data),
|
||||
create: (tripId: number | string, dayId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data),
|
||||
update: (tripId: number | string, dayId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}/notes/${id}`, data).then(r => r.data),
|
||||
create: (tripId: number | string, dayId: number | string, data: DayNoteCreateRequest) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data),
|
||||
update: (tripId: number | string, dayId: number | string, id: number, data: DayNoteUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}/notes/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/notes/${id}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const collabApi = {
|
||||
getNotes: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/notes`).then(r => r.data),
|
||||
createNote: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data),
|
||||
updateNote: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/collab/notes/${id}`, data).then(r => r.data),
|
||||
createNote: (tripId: number | string, data: CollabNoteCreateRequest) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data),
|
||||
updateNote: (tripId: number | string, id: number, data: CollabNoteUpdateRequest) => apiClient.put(`/trips/${tripId}/collab/notes/${id}`, data).then(r => r.data),
|
||||
deleteNote: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${id}`).then(r => r.data),
|
||||
uploadNoteFile: (tripId: number | string, noteId: number, formData: FormData) => apiClient.post(`/trips/${tripId}/collab/notes/${noteId}/files`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
|
||||
deleteNoteFile: (tripId: number | string, noteId: number, fileId: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${noteId}/files/${fileId}`).then(r => r.data),
|
||||
getPolls: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data),
|
||||
createPoll: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data),
|
||||
votePoll: (tripId: number | string, id: number, optionIndex: number) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex }).then(r => r.data),
|
||||
createPoll: (tripId: number | string, data: CollabPollCreateRequest) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data),
|
||||
votePoll: (tripId: number | string, id: number, optionIndex: number) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex } satisfies CollabPollVoteRequest).then(r => r.data),
|
||||
closePoll: (tripId: number | string, id: number) => apiClient.put(`/trips/${tripId}/collab/polls/${id}/close`).then(r => r.data),
|
||||
deletePoll: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/polls/${id}`).then(r => r.data),
|
||||
getMessages: (tripId: number | string, before?: string) => apiClient.get(`/trips/${tripId}/collab/messages${before ? `?before=${before}` : ''}`).then(r => r.data),
|
||||
sendMessage: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
|
||||
sendMessage: (tripId: number | string, data: CollabMessageCreateRequest) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
|
||||
deleteMessage: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/messages/${id}`).then(r => r.data),
|
||||
reactMessage: (tripId: number | string, id: number, emoji: string) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji }).then(r => r.data),
|
||||
reactMessage: (tripId: number | string, id: number, emoji: string) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji } satisfies CollabReactionRequest).then(r => r.data),
|
||||
linkPreview: (tripId: number | string, url: string) => apiClient.get(`/trips/${tripId}/collab/link-preview?url=${encodeURIComponent(url)}`).then(r => r.data),
|
||||
}
|
||||
|
||||
@@ -590,16 +671,16 @@ export const shareApi = {
|
||||
export const notificationsApi = {
|
||||
getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data),
|
||||
updatePreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
|
||||
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => r.data),
|
||||
testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => r.data),
|
||||
testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload).then(r => r.data),
|
||||
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testSmtp')),
|
||||
testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testWebhook')),
|
||||
testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testNtfy')),
|
||||
}
|
||||
|
||||
export const inAppNotificationsApi = {
|
||||
list: (params?: { limit?: number; offset?: number; unread_only?: boolean }) =>
|
||||
apiClient.get('/notifications/in-app', { params }).then(r => r.data),
|
||||
unreadCount: () =>
|
||||
apiClient.get('/notifications/in-app/unread-count').then(r => r.data),
|
||||
list: (params?: { limit?: number; offset?: number; unread_only?: boolean }): Promise<InAppListResult> =>
|
||||
apiClient.get('/notifications/in-app', { params }).then(r => parseInDev(inAppListResultSchema, r.data, 'notifications.list')),
|
||||
unreadCount: (): Promise<UnreadCountResult> =>
|
||||
apiClient.get('/notifications/in-app/unread-count').then(r => parseInDev(unreadCountResultSchema, r.data, 'notifications.unreadCount')),
|
||||
markRead: (id: number) =>
|
||||
apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data),
|
||||
markUnread: (id: number) =>
|
||||
@@ -610,7 +691,7 @@ export const inAppNotificationsApi = {
|
||||
apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data),
|
||||
deleteAll: () =>
|
||||
apiClient.delete('/notifications/in-app/all').then(r => r.data),
|
||||
respond: (id: number, response: 'positive' | 'negative') =>
|
||||
respond: (id: number, response: NotificationRespondRequest['response']) =>
|
||||
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
|
||||
}
|
||||
|
||||
|
||||
@@ -158,16 +158,16 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="px-6 py-4 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.addons.title')}</h2>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
||||
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
||||
<div className="px-6 py-4 border-b border-edge-secondary">
|
||||
<h2 className="font-semibold text-content">{t('admin.addons.title')}</h2>
|
||||
<p className="text-xs mt-1 text-content-muted" style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
||||
{t('admin.addons.subtitleBefore')}<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 11, display: 'inline', verticalAlign: 'middle', opacity: 0.7 }} />{t('admin.addons.subtitleAfter')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{addons.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm" style={{ color: 'var(--text-faint)' }}>
|
||||
<div className="p-8 text-center text-sm text-content-faint">
|
||||
{t('admin.addons.noAddons')}
|
||||
</div>
|
||||
) : (
|
||||
@@ -175,9 +175,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
{/* Trip Addons */}
|
||||
{tripAddons.length > 0 && (
|
||||
<div>
|
||||
<div className="px-6 py-2.5 border-b flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}>
|
||||
<Briefcase size={13} style={{ color: 'var(--text-muted)' }} />
|
||||
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
|
||||
<div className="px-6 py-2.5 border-b flex items-center gap-2 bg-surface-secondary border-edge-secondary">
|
||||
<Briefcase size={13} className="text-content-muted" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
|
||||
{t('admin.addons.type.trip')} — {t('admin.addons.tripHint')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -185,14 +185,14 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
<div key={addon.id}>
|
||||
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
|
||||
<div className="flex items-center gap-4 px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
|
||||
<Luggage size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<div className="flex items-center gap-4 px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
|
||||
<Luggage size={14} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('admin.bagTracking.title')}</div>
|
||||
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('admin.bagTracking.subtitle')}</div>
|
||||
<div className="text-sm font-medium text-content-secondary">{t('admin.bagTracking.title')}</div>
|
||||
<div className="text-xs mt-0.5 text-content-faint">{t('admin.bagTracking.subtitle')}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="hidden sm:inline text-xs font-medium" style={{ color: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||
<span className={`hidden sm:inline text-xs font-medium ${bagTrackingEnabled ? 'text-content' : 'text-content-faint'}`}>
|
||||
{bagTrackingEnabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button onClick={onToggleBagTracking}
|
||||
@@ -205,20 +205,20 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
</div>
|
||||
)}
|
||||
{addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && (
|
||||
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
|
||||
<div className="px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
|
||||
<div className="space-y-2">
|
||||
{COLLAB_SUB_FEATURES.map(feat => {
|
||||
const enabled = collabFeatures[feat.key]
|
||||
const Icon = feat.icon
|
||||
return (
|
||||
<div key={feat.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
|
||||
<Icon size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<Icon size={14} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t(feat.titleKey)}</div>
|
||||
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t(feat.subtitleKey)}</div>
|
||||
<div className="text-sm font-medium text-content-secondary">{t(feat.titleKey)}</div>
|
||||
<div className="text-xs mt-0.5 text-content-faint">{t(feat.subtitleKey)}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="hidden sm:inline text-xs font-medium" style={{ color: enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||
<span className={`hidden sm:inline text-xs font-medium ${enabled ? 'text-content' : 'text-content-faint'}`}>
|
||||
{enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button onClick={() => onToggleCollabFeature(feat.key)}
|
||||
@@ -242,9 +242,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
{/* Global Addons */}
|
||||
{globalAddons.length > 0 && (
|
||||
<div>
|
||||
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}>
|
||||
<Globe size={13} style={{ color: 'var(--text-muted)' }} />
|
||||
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
|
||||
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2 bg-surface-secondary border-edge-secondary">
|
||||
<Globe size={13} className="text-content-muted" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
|
||||
{t('admin.addons.type.global')} — {t('admin.addons.globalHint')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -253,19 +253,19 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||
{/* Memories providers as sub-items under Journey addon */}
|
||||
{addon.id === 'journey' && providerOptions.length > 0 && (
|
||||
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
|
||||
<div className="px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
|
||||
<div className="space-y-2">
|
||||
{providerOptions.map(provider => {
|
||||
const ProviderIcon = PROVIDER_ICONS[provider.key]
|
||||
return (
|
||||
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
|
||||
{ProviderIcon && <span style={{ color: 'var(--text-faint)' }}><ProviderIcon size={14} /></span>}
|
||||
{ProviderIcon && <span className="text-content-faint"><ProviderIcon size={14} /></span>}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{provider.label}</div>
|
||||
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{provider.description}</div>
|
||||
<div className="text-sm font-medium text-content-secondary">{provider.label}</div>
|
||||
<div className="text-xs mt-0.5 text-content-faint">{provider.description}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="hidden sm:inline text-xs font-medium" style={{ color: provider.enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||
<span className={`hidden sm:inline text-xs font-medium ${provider.enabled ? 'text-content' : 'text-content-faint'}`}>
|
||||
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button
|
||||
@@ -291,9 +291,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
{/* Integration Addons */}
|
||||
{integrationAddons.length > 0 && (
|
||||
<div>
|
||||
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}>
|
||||
<Link2 size={13} style={{ color: 'var(--text-muted)' }} />
|
||||
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
|
||||
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2 bg-surface-secondary border-edge-secondary">
|
||||
<Link2 size={13} className="text-content-muted" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
|
||||
{t('admin.addons.type.integration')} — {t('admin.addons.integrationHint')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -336,31 +336,31 @@ function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statu
|
||||
const displayDescription = descriptionOverride || label.description
|
||||
const enabledState = statusOverride ?? addon.enabled
|
||||
return (
|
||||
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95" style={{ borderColor: 'var(--border-secondary)', opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
|
||||
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95 border-edge-secondary" style={{ opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
|
||||
{/* Icon */}
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}>
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0 bg-surface-secondary text-content">
|
||||
<AddonIcon name={addon.icon} size={20} />
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{displayName}</span>
|
||||
<span className="text-sm font-semibold text-content">{displayName}</span>
|
||||
{isComingSoon && (
|
||||
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full" style={{ background: 'var(--bg-tertiary)', color: 'var(--text-faint)' }}>
|
||||
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full text-content-faint bg-surface-tertiary">
|
||||
Coming Soon
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full" style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}>
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-surface-secondary text-content-muted">
|
||||
{addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{displayDescription}</p>
|
||||
<p className="text-xs mt-0.5 text-content-muted">{displayDescription}</p>
|
||||
</div>
|
||||
|
||||
{/* Toggle */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="hidden sm:inline text-xs font-medium" style={{ color: (enabledState && !isComingSoon) ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||
<span className={`hidden sm:inline text-xs font-medium ${(enabledState && !isComingSoon) ? 'text-content' : 'text-content-faint'}`}>
|
||||
{isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
{!hideToggle && (
|
||||
@@ -371,9 +371,8 @@ function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statu
|
||||
style={{ background: (enabledState && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-4 w-4 transform rounded-full transition-transform"
|
||||
className="inline-block h-4 w-4 transform rounded-full transition-transform bg-surface-card"
|
||||
style={{
|
||||
background: 'var(--bg-card)',
|
||||
transform: (enabledState && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -83,14 +83,14 @@ export default function AdminMcpTokensPanel() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.mcpTokens.title')}</h2>
|
||||
<h2 className="text-lg font-semibold text-content">{t('admin.mcpTokens.title')}</h2>
|
||||
<p className="text-sm mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* OAuth Sessions */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{t('admin.oauthSessions.sectionTitle')}</h3>
|
||||
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
|
||||
<h3 className="text-sm font-semibold mb-2 text-content-secondary">{t('admin.oauthSessions.sectionTitle')}</h3>
|
||||
<div className="rounded-xl border overflow-hidden border-edge bg-surface-card">
|
||||
{sessionsLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||
@@ -102,8 +102,8 @@ export default function AdminMcpTokensPanel() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-x-6 px-4 py-2.5 text-xs font-medium border-b"
|
||||
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-x-6 px-4 py-2.5 text-xs font-medium border-b border-edge bg-surface-secondary"
|
||||
style={{ color: 'var(--text-tertiary)' }}>
|
||||
<span>{t('admin.oauthSessions.clientName')}</span>
|
||||
<span>{t('admin.oauthSessions.owner')}</span>
|
||||
<span className="text-right">{t('admin.oauthSessions.created')}</span>
|
||||
@@ -115,34 +115,31 @@ export default function AdminMcpTokensPanel() {
|
||||
const hidden = session.scopes.length - SCOPES_PREVIEW
|
||||
return (
|
||||
<div key={session.id}
|
||||
className="grid grid-cols-[1fr_auto_auto_auto] items-start gap-x-6 px-4 py-3"
|
||||
style={{ borderBottom: i < sessions.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
|
||||
className={`grid grid-cols-[1fr_auto_auto_auto] items-start gap-x-6 px-4 py-3 ${i < sessions.length - 1 ? 'border-b border-edge' : ''}`}>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{session.client_name}</p>
|
||||
<p className="text-sm font-medium truncate text-content">{session.client_name}</p>
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{visible.map(scope => (
|
||||
<span key={scope} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-tertiary)', border: '1px solid var(--border-primary)' }}>
|
||||
<span key={scope} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono bg-surface-secondary border border-edge"
|
||||
style={{ color: 'var(--text-tertiary)' }}>
|
||||
{scope}
|
||||
</span>
|
||||
))}
|
||||
{!expanded && hidden > 0 && (
|
||||
<button onClick={() => toggleScopes(session.id)}
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)' }}>
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80 bg-surface-secondary text-content-secondary border border-edge">
|
||||
+{hidden} more
|
||||
</button>
|
||||
)}
|
||||
{expanded && hidden > 0 && (
|
||||
<button onClick={() => toggleScopes(session.id)}
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)' }}>
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80 bg-surface-secondary text-content-secondary border border-edge">
|
||||
show less
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm pt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
||||
<div className="flex items-center gap-1.5 text-sm pt-0.5 text-content-secondary">
|
||||
<User className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">{session.username}</span>
|
||||
</div>
|
||||
@@ -164,8 +161,8 @@ export default function AdminMcpTokensPanel() {
|
||||
|
||||
{/* MCP Tokens */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{t('admin.mcpTokens.sectionTitle')}</h3>
|
||||
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
|
||||
<h3 className="text-sm font-semibold mb-2 text-content-secondary">{t('admin.mcpTokens.sectionTitle')}</h3>
|
||||
<div className="rounded-xl border overflow-hidden border-edge bg-surface-card">
|
||||
{tokensLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||
@@ -177,8 +174,8 @@ export default function AdminMcpTokensPanel() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 px-4 py-2.5 text-xs font-medium border-b"
|
||||
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 px-4 py-2.5 text-xs font-medium border-b border-edge bg-surface-secondary"
|
||||
style={{ color: 'var(--text-tertiary)' }}>
|
||||
<span>{t('admin.mcpTokens.tokenName')}</span>
|
||||
<span>{t('admin.mcpTokens.owner')}</span>
|
||||
<span className="text-right">{t('admin.mcpTokens.created')}</span>
|
||||
@@ -187,13 +184,12 @@ export default function AdminMcpTokensPanel() {
|
||||
</div>
|
||||
{tokens.map((token, i) => (
|
||||
<div key={token.id}
|
||||
className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3"
|
||||
style={{ borderBottom: i < tokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
|
||||
className={`grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3 ${i < tokens.length - 1 ? 'border-b border-edge' : ''}`}>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
|
||||
<p className="text-sm font-medium truncate text-content">{token.name}</p>
|
||||
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{token.token_prefix}...</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
<div className="flex items-center gap-1.5 text-sm text-content-secondary">
|
||||
<User className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">{token.username}</span>
|
||||
</div>
|
||||
@@ -217,14 +213,14 @@ export default function AdminMcpTokensPanel() {
|
||||
|
||||
{/* Revoke OAuth session modal */}
|
||||
{revokeConfirmId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
|
||||
onClick={e => { if (e.target === e.currentTarget) setRevokeConfirmId(null) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.oauthSessions.revokeTitle')}</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('admin.oauthSessions.revokeMessage')}</p>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
|
||||
<h3 className="text-base font-semibold text-content">{t('admin.oauthSessions.revokeTitle')}</h3>
|
||||
<p className="text-sm text-content-secondary">{t('admin.oauthSessions.revokeMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setRevokeConfirmId(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={() => handleRevoke(revokeConfirmId)}
|
||||
@@ -238,14 +234,14 @@ export default function AdminMcpTokensPanel() {
|
||||
|
||||
{/* Delete MCP token modal */}
|
||||
{deleteConfirmId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
|
||||
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.mcpTokens.deleteTitle')}</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('admin.mcpTokens.deleteMessage')}</p>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
|
||||
<h3 className="text-base font-semibold text-content">{t('admin.mcpTokens.deleteTitle')}</h3>
|
||||
<p className="text-sm text-content-secondary">{t('admin.mcpTokens.deleteMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setDeleteConfirmId(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={() => handleDelete(deleteConfirmId)}
|
||||
|
||||
@@ -100,54 +100,53 @@ export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): R
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg m-0 flex items-center gap-2" style={{ color: 'var(--text-primary)' }}>
|
||||
<h2 className="font-semibold text-lg m-0 flex items-center gap-2 text-content">
|
||||
<ClipboardList size={20} />
|
||||
{t('admin.tabs.audit')}
|
||||
</h2>
|
||||
<p className="text-sm m-0 mt-1" style={{ color: 'var(--text-muted)' }}>{t('admin.audit.subtitle')}</p>
|
||||
<p className="text-sm m-0 mt-1 text-content-muted">{t('admin.audit.subtitle')}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => loadFirstPage()}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-opacity disabled:opacity-50"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-primary)', background: 'var(--bg-card)' }}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-opacity disabled:opacity-50 border-edge text-content bg-surface-card"
|
||||
>
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
{t('admin.audit.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs m-0" style={{ color: 'var(--text-faint)' }}>
|
||||
<p className="text-xs m-0 text-content-faint">
|
||||
{t('admin.audit.showing', { count: entries.length, total })}
|
||||
</p>
|
||||
|
||||
{loading && entries.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>{t('common.loading')}</div>
|
||||
<div className="py-12 text-center text-sm text-content-muted">{t('common.loading')}</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>{t('admin.audit.empty')}</div>
|
||||
<div className="py-12 text-center text-sm text-content-muted">{t('admin.audit.empty')}</div>
|
||||
) : (
|
||||
<div className="rounded-xl border overflow-x-auto" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
|
||||
<div className="rounded-xl border overflow-x-auto border-edge bg-surface-card">
|
||||
<table className="w-full text-sm border-collapse min-w-[720px]">
|
||||
<thead>
|
||||
<tr className="border-b text-left" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.time')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.user')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.action')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.resource')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.ip')}</th>
|
||||
<th className="p-3 font-semibold" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.details')}</th>
|
||||
<tr className="border-b text-left border-edge-secondary">
|
||||
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.time')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.user')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.action')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.resource')}</th>
|
||||
<th className="p-3 font-semibold whitespace-nowrap text-content-secondary">{t('admin.audit.col.ip')}</th>
|
||||
<th className="p-3 font-semibold text-content-secondary">{t('admin.audit.col.details')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e) => (
|
||||
<tr key={e.id} className="border-b align-top" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<td className="p-3 whitespace-nowrap font-mono text-xs" style={{ color: 'var(--text-primary)' }}>{fmtTime(e.created_at)}</td>
|
||||
<td className="p-3" style={{ color: 'var(--text-primary)' }}>{userLabel(e)}</td>
|
||||
<td className="p-3 font-mono text-xs" style={{ color: 'var(--text-primary)' }}>{e.action}</td>
|
||||
<td className="p-3 font-mono text-xs break-all max-w-[140px]" style={{ color: 'var(--text-muted)' }}>{e.resource || '—'}</td>
|
||||
<td className="p-3 font-mono text-xs whitespace-nowrap" style={{ color: 'var(--text-muted)' }}>{e.ip || '—'}</td>
|
||||
<td className="p-3 font-mono text-xs break-all max-w-[280px]" style={{ color: 'var(--text-faint)' }}>{fmtDetails(e.details)}</td>
|
||||
<tr key={e.id} className="border-b align-top border-edge-secondary">
|
||||
<td className="p-3 whitespace-nowrap font-mono text-xs text-content">{fmtTime(e.created_at)}</td>
|
||||
<td className="p-3 text-content">{userLabel(e)}</td>
|
||||
<td className="p-3 font-mono text-xs text-content">{e.action}</td>
|
||||
<td className="p-3 font-mono text-xs break-all max-w-[140px] text-content-muted">{e.resource || '—'}</td>
|
||||
<td className="p-3 font-mono text-xs whitespace-nowrap text-content-muted">{e.ip || '—'}</td>
|
||||
<td className="p-3 font-mono text-xs break-all max-w-[280px] text-content-faint">{fmtDetails(e.details)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -160,8 +159,7 @@ export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): R
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => loadMore()}
|
||||
className="text-sm font-medium underline-offset-2 hover:underline disabled:opacity-50"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
className="text-sm font-medium underline-offset-2 hover:underline disabled:opacity-50 text-content-secondary"
|
||||
>
|
||||
{t('admin.audit.loadMore')}
|
||||
</button>
|
||||
|
||||
@@ -186,8 +186,8 @@ export default function BackupPanel() {
|
||||
<div className="flex items-center gap-3">
|
||||
<HardDrive className="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('backup.title')}</h2>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('backup.subtitle')}</p>
|
||||
<h2 className="font-semibold text-content">{t('backup.title')}</h2>
|
||||
<p className="text-xs mt-1 text-content-muted">{t('backup.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -310,8 +310,8 @@ export default function BackupPanel() {
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Clock className="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('backup.auto.title')}</h2>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('backup.auto.subtitle')}</p>
|
||||
<h2 className="font-semibold text-content">{t('backup.auto.title')}</h2>
|
||||
<p className="text-xs mt-1 text-content-muted">{t('backup.auto.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -360,7 +360,7 @@ export default function BackupPanel() {
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.hour')}</label>
|
||||
<CustomSelect
|
||||
value={String(autoSettings.hour)}
|
||||
onChange={v => handleAutoSettingsChange('hour', parseInt(v, 10))}
|
||||
onChange={v => handleAutoSettingsChange('hour', parseInt(String(v), 10))}
|
||||
size="sm"
|
||||
options={HOURS.map(h => {
|
||||
let label: string
|
||||
@@ -408,7 +408,7 @@ export default function BackupPanel() {
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfMonth')}</label>
|
||||
<CustomSelect
|
||||
value={String(autoSettings.day_of_month)}
|
||||
onChange={v => handleAutoSettingsChange('day_of_month', parseInt(v, 10))}
|
||||
onChange={v => handleAutoSettingsChange('day_of_month', parseInt(String(v), 10))}
|
||||
size="sm"
|
||||
options={DAYS_OF_MONTH.map(d => ({ value: String(d), label: String(d) }))}
|
||||
/>
|
||||
@@ -458,7 +458,8 @@ export default function BackupPanel() {
|
||||
{/* Restore Warning Modal */}
|
||||
{restoreConfirm && (
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
className="bg-[rgba(0,0,0,0.5)]"
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 9999, backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
onClick={() => setRestoreConfirm(null)}
|
||||
>
|
||||
<div
|
||||
@@ -468,14 +469,14 @@ export default function BackupPanel() {
|
||||
>
|
||||
{/* Red header */}
|
||||
<div style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<AlertTriangle size={20} style={{ color: 'white' }} />
|
||||
<div className="bg-[rgba(255,255,255,0.2)]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<AlertTriangle size={20} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>
|
||||
<h3 className="text-white" style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>
|
||||
{t('backup.restoreConfirmTitle')}
|
||||
</h3>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'rgba(255,255,255,0.8)' }}>
|
||||
<p className="text-[rgba(255,255,255,0.8)]" style={{ margin: '2px 0 0', fontSize: 12 }}>
|
||||
{restoreConfirm.filename}
|
||||
</p>
|
||||
</div>
|
||||
@@ -505,7 +506,8 @@ export default function BackupPanel() {
|
||||
</button>
|
||||
<button
|
||||
onClick={executeRestore}
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit', background: '#dc2626', color: 'white' }}
|
||||
className="bg-[#dc2626] text-white"
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = '#b91c1c'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = '#dc2626'}
|
||||
>
|
||||
|
||||
@@ -191,8 +191,8 @@ export default function CategoryManager() {
|
||||
<div className="bg-white rounded-2xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('categories.title')}</h2>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('categories.subtitle')}</p>
|
||||
<h2 className="font-semibold text-content">{t('categories.title')}</h2>
|
||||
<p className="text-xs mt-1 text-content-muted">{t('categories.subtitle')}</p>
|
||||
</div>
|
||||
<button onClick={handleStartCreate}
|
||||
className="flex items-center gap-2 bg-slate-900 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
|
||||
|
||||
@@ -35,10 +35,10 @@ function OptionRow({
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
<label className="block text-sm font-medium mb-2 text-content-secondary">
|
||||
{label}
|
||||
</label>
|
||||
{hint && <p className="text-xs mb-2" style={{ color: 'var(--text-faint)' }}>{hint}</p>}
|
||||
{hint && <p className="text-xs mb-2 text-content-faint">{hint}</p>}
|
||||
<div className="flex gap-3 flex-wrap">{children}</div>
|
||||
</div>
|
||||
)
|
||||
@@ -113,8 +113,8 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
isSet(field) ? (
|
||||
<button
|
||||
onClick={() => reset(field)}
|
||||
className="text-xs ml-2"
|
||||
style={{ color: 'var(--text-faint)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer' }}
|
||||
className="text-xs ml-2 text-content-faint underline"
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
{t('admin.defaultSettings.resetToBuiltIn')}
|
||||
</button>
|
||||
@@ -130,7 +130,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
lng: 2.3522,
|
||||
address: null,
|
||||
category_id: null,
|
||||
icon: null,
|
||||
price: null,
|
||||
currency: null,
|
||||
image_url: null,
|
||||
@@ -147,14 +146,14 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
}], [])
|
||||
|
||||
if (!loaded) {
|
||||
return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic', padding: 16 }}>Loading…</p>
|
||||
return <p className="text-content-faint" style={{ fontSize: 12, fontStyle: 'italic', padding: 16 }}>Loading…</p>
|
||||
}
|
||||
|
||||
const darkMode = defaults.dark_mode
|
||||
|
||||
return (
|
||||
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
||||
<p className="text-sm" style={{ color: 'var(--text-faint)', marginTop: -8 }}>
|
||||
<p className="text-sm text-content-faint" style={{ marginTop: -8 }}>
|
||||
{t('admin.defaultSettings.description')}
|
||||
</p>
|
||||
|
||||
@@ -225,7 +224,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
|
||||
{/* Map Tile URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('settings.mapTemplate')}
|
||||
<ResetButton field="map_tile_url" />
|
||||
</label>
|
||||
@@ -245,7 +244,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-faint)' }}>{t('settings.mapDefaultHint')}</p>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.mapDefaultHint')}</p>
|
||||
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{React.createElement(MapView as any, {
|
||||
|
||||
@@ -68,8 +68,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={sending !== null}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left w-full"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left w-full border-edge bg-surface-card"
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)' }}
|
||||
>
|
||||
@@ -78,8 +77,8 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p>
|
||||
<p className="text-xs truncate" style={{ color: 'var(--text-faint)' }}>{sub}</p>
|
||||
<p className="text-sm font-medium text-content">{label}</p>
|
||||
<p className="text-xs truncate text-content-faint">{sub}</p>
|
||||
</div>
|
||||
{sending === id && (
|
||||
<div className="w-4 h-4 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin flex-shrink-0" />
|
||||
@@ -88,15 +87,14 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
)
|
||||
|
||||
const SectionTitle = ({ children }: { children: React.ReactNode }) => (
|
||||
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>{children}</h3>
|
||||
<h3 className="text-sm font-semibold mb-3 text-content-secondary">{children}</h3>
|
||||
)
|
||||
|
||||
const TripSelector = () => (
|
||||
<select
|
||||
value={selectedTripId ?? ''}
|
||||
onChange={e => setSelectedTripId(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 rounded-lg border text-sm mb-3"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
className="w-full px-3 py-2 rounded-lg border text-sm mb-3 border-edge bg-surface-card text-content"
|
||||
>
|
||||
{trips.map(trip => <option key={trip.id} value={trip.id}>{trip.title}</option>)}
|
||||
</select>
|
||||
@@ -106,8 +104,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
<select
|
||||
value={selectedUserId ?? ''}
|
||||
onChange={e => setSelectedUserId(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 rounded-lg border text-sm mb-3"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
className="w-full px-3 py-2 rounded-lg border text-sm mb-3 border-edge bg-surface-card text-content"
|
||||
>
|
||||
{users.map(u => <option key={u.id} value={u.id}>{u.username} ({u.email})</option>)}
|
||||
</select>
|
||||
@@ -116,10 +113,10 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="px-2 py-0.5 rounded text-xs font-mono font-bold" style={{ background: '#fbbf24', color: '#000' }}>
|
||||
<div className="px-2 py-0.5 rounded text-xs font-mono font-bold bg-[#fbbf24] text-[#000]">
|
||||
DEV ONLY
|
||||
</div>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
<span className="text-sm font-medium text-content">
|
||||
Notification Testing
|
||||
</span>
|
||||
</div>
|
||||
@@ -127,7 +124,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
{/* ── Type Testing ─────────────────────────────────────────────────── */}
|
||||
<div>
|
||||
<SectionTitle>Type Testing</SectionTitle>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
|
||||
<p className="text-xs mb-3 text-content-muted">
|
||||
Test how each in-app notification type renders, sent to yourself.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
@@ -175,7 +172,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
{trips.length > 0 && (
|
||||
<div>
|
||||
<SectionTitle>Trip-Scoped Events</SectionTitle>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
|
||||
<p className="text-xs mb-3 text-content-muted">
|
||||
Fires each trip event to all members of the selected trip (excluding yourself).
|
||||
</p>
|
||||
<TripSelector />
|
||||
@@ -228,7 +225,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
{users.length > 0 && (
|
||||
<div>
|
||||
<SectionTitle>User-Scoped Events</SectionTitle>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
|
||||
<p className="text-xs mb-3 text-content-muted">
|
||||
Fires each user event to the selected recipient.
|
||||
</p>
|
||||
<UserSelector />
|
||||
@@ -266,7 +263,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
{/* ── Admin-Scoped Events ──────────────────────────────────────────── */}
|
||||
<div>
|
||||
<SectionTitle>Admin-Scoped Events</SectionTitle>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
|
||||
<p className="text-xs mb-3 text-content-muted">
|
||||
Fires to all admin users.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
|
||||
@@ -9,6 +9,12 @@ const PER_PAGE = 10
|
||||
interface GithubRelease {
|
||||
id: number
|
||||
prerelease: boolean
|
||||
tag_name: string
|
||||
name: string | null
|
||||
body: string | null
|
||||
published_at: string | null
|
||||
created_at: string
|
||||
author: { login: string } | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
@@ -67,7 +73,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
elements.push(
|
||||
<ul key={`ul-${elements.length}`} className="space-y-1 my-2">
|
||||
{listItems.map((item, i) => (
|
||||
<li key={i} className="flex gap-2 text-xs" style={{ color: 'var(--text-muted)' }}>
|
||||
<li key={i} className="flex gap-2 text-xs text-content-muted">
|
||||
<span className="mt-1.5 w-1 h-1 rounded-full flex-shrink-0" style={{ background: 'var(--text-faint)' }} />
|
||||
<span dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
|
||||
</li>
|
||||
@@ -96,14 +102,14 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
if (trimmed.startsWith('### ')) {
|
||||
flushList()
|
||||
elements.push(
|
||||
<h4 key={elements.length} className="text-xs font-semibold mt-3 mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||
<h4 key={elements.length} className="text-xs font-semibold mt-3 mb-1 text-content">
|
||||
{trimmed.slice(4)}
|
||||
</h4>
|
||||
)
|
||||
} else if (trimmed.startsWith('## ')) {
|
||||
flushList()
|
||||
elements.push(
|
||||
<h3 key={elements.length} className="text-sm font-semibold mt-3 mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||
<h3 key={elements.length} className="text-sm font-semibold mt-3 mb-1 text-content">
|
||||
{trimmed.slice(3)}
|
||||
</h3>
|
||||
)
|
||||
@@ -112,7 +118,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
} else {
|
||||
flushList()
|
||||
elements.push(
|
||||
<p key={elements.length} className="text-xs my-1" style={{ color: 'var(--text-muted)' }}
|
||||
<p key={elements.length} className="text-xs my-1 text-content-muted"
|
||||
dangerouslySetInnerHTML={{ __html: inlineFormat(trimmed) }}
|
||||
/>
|
||||
)
|
||||
@@ -130,55 +136,52 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
href="https://ko-fi.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ff5e5b15', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Coffee size={20} style={{ color: '#ff5e5b' }} />
|
||||
<div className="bg-[#ff5e5b15]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Coffee size={20} className="text-[#ff5e5b]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Ko-fi</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div>
|
||||
<div className="text-sm font-semibold text-content">Ko-fi</div>
|
||||
<div className="text-xs text-content-faint">{t('admin.github.support')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
<a
|
||||
href="https://buymeacoffee.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ffdd0015', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Heart size={20} style={{ color: '#ffdd00' }} />
|
||||
<div className="bg-[#ffdd0015]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Heart size={20} className="text-[#ffdd00]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Buy Me a Coffee</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div>
|
||||
<div className="text-sm font-semibold text-content">Buy Me a Coffee</div>
|
||||
<div className="text-xs text-content-faint">{t('admin.github.support')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.gg/NhZBDSd4qW"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#5865F215', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<div className="bg-[#5865F215]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Discord</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>Join the community</div>
|
||||
<div className="text-sm font-semibold text-content">Discord</div>
|
||||
<div className="text-xs text-content-faint">Join the community</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -187,85 +190,81 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ef444415', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Bug size={20} style={{ color: '#ef4444' }} />
|
||||
<div className="bg-[#ef444415]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Bug size={20} className="text-[#ef4444]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.about.reportBug')}</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.reportBugHint')}</div>
|
||||
<div className="text-sm font-semibold text-content">{t('settings.about.reportBug')}</div>
|
||||
<div className="text-xs text-content-faint">{t('settings.about.reportBugHint')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#f59e0b15', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Lightbulb size={20} style={{ color: '#f59e0b' }} />
|
||||
<div className="bg-[#f59e0b15]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Lightbulb size={20} className="text-[#f59e0b]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.about.featureRequest')}</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.featureRequestHint')}</div>
|
||||
<div className="text-sm font-semibold text-content">{t('settings.about.featureRequest')}</div>
|
||||
<div className="text-xs text-content-faint">{t('settings.about.featureRequestHint')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/mauriceboe/TREK/wiki"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#6366f115', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<BookOpen size={20} style={{ color: '#6366f1' }} />
|
||||
<div className="bg-[#6366f115]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<BookOpen size={20} className="text-[#6366f1]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Wiki</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.wikiHint')}</div>
|
||||
<div className="text-sm font-semibold text-content">Wiki</div>
|
||||
<div className="text-xs text-content-faint">{t('settings.about.wikiHint')}</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Loading / Error / Releases */}
|
||||
{loading ? (
|
||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
||||
<div className="p-8 flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin" style={{ color: 'var(--text-muted)' }} />
|
||||
<Loader2 className="w-6 h-6 animate-spin text-content-muted" />
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
||||
<div className="p-6 text-center">
|
||||
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('admin.github.error')}</p>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-faint)' }}>{error}</p>
|
||||
<p className="text-sm text-content-muted">{t('admin.github.error')}</p>
|
||||
<p className="text-xs mt-1 text-content-faint">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
||||
<div className="px-5 py-4 border-b flex items-center justify-between border-edge-secondary">
|
||||
<div>
|
||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.github.title')}</h2>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('admin.github.subtitle').replace('{repo}', REPO)}</p>
|
||||
<h2 className="font-semibold text-content">{t('admin.github.title')}</h2>
|
||||
<p className="text-xs mt-0.5 text-content-faint">{t('admin.github.subtitle').replace('{repo}', REPO)}</p>
|
||||
</div>
|
||||
<a
|
||||
href={`https://github.com/${REPO}/releases`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-surface-secondary text-content-muted"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
GitHub
|
||||
@@ -299,36 +298,34 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
{/* Release content */}
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
<span className="text-sm font-semibold text-content">
|
||||
{release.tag_name}
|
||||
</span>
|
||||
{isLatest && (
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full"
|
||||
style={{ background: 'rgba(34,197,94,0.12)', color: '#16a34a' }}>
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-[rgba(34,197,94,0.12)] text-[#16a34a]">
|
||||
{t('admin.github.latest')}
|
||||
</span>
|
||||
)}
|
||||
{release.prerelease && (
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full"
|
||||
style={{ background: 'rgba(245,158,11,0.12)', color: '#d97706' }}>
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-[rgba(245,158,11,0.12)] text-[#d97706]">
|
||||
{t('admin.github.prerelease')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{release.name && release.name !== release.tag_name && (
|
||||
<p className="text-xs font-medium mt-0.5" style={{ color: 'var(--text-muted)' }}>
|
||||
<p className="text-xs font-medium mt-0.5 text-content-muted">
|
||||
{release.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="flex items-center gap-1 text-[11px]" style={{ color: 'var(--text-faint)' }}>
|
||||
<span className="flex items-center gap-1 text-[11px] text-content-faint">
|
||||
<Calendar size={10} />
|
||||
{formatDate(release.published_at || release.created_at)}
|
||||
</span>
|
||||
{release.author && (
|
||||
<span className="text-[11px]" style={{ color: 'var(--text-faint)' }}>
|
||||
<span className="text-[11px] text-content-faint">
|
||||
{t('admin.github.by')} {release.author.login}
|
||||
</span>
|
||||
)}
|
||||
@@ -339,15 +336,14 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => toggleExpand(release.id)}
|
||||
className="flex items-center gap-1 text-[11px] font-medium transition-colors"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
className="flex items-center gap-1 text-[11px] font-medium transition-colors text-content-muted"
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-2 p-3 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<div className="mt-2 p-3 rounded-lg bg-surface-secondary">
|
||||
{renderBody(release.body)}
|
||||
</div>
|
||||
)}
|
||||
@@ -366,8 +362,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-medium transition-colors bg-surface-secondary text-content-muted"
|
||||
>
|
||||
{loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
|
||||
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
|
||||
|
||||
@@ -500,7 +500,8 @@ describe('PackingTemplateManager', () => {
|
||||
|
||||
// Find the X (cancel) button in the create row — it's the last button in the create row
|
||||
const createRow = screen.getByPlaceholderText('Template name (e.g. Beach Holiday)').closest('div')!;
|
||||
const cancelBtn = Array.from(createRow.querySelectorAll('button')).at(-1) as HTMLElement;
|
||||
const createRowButtons = Array.from(createRow.querySelectorAll('button'));
|
||||
const cancelBtn = createRowButtons[createRowButtons.length - 1] as HTMLElement;
|
||||
await user.click(cancelBtn);
|
||||
|
||||
await waitFor(() =>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export const CURRENCIES = [
|
||||
'EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK',
|
||||
'TRY', 'THB', 'AUD', 'CAD', 'NZD', 'BRL', 'MXN', 'INR', 'IDR', 'MYR',
|
||||
'PHP', 'SGD', 'KRW', 'CNY', 'HKD', 'TWD', 'ZAR', 'AED', 'SAR', 'ILS',
|
||||
'EGP', 'MAD', 'HUF', 'RON', 'BGN', 'HRK', 'ISK', 'RUB', 'UAH', 'BDT',
|
||||
'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS',
|
||||
]
|
||||
|
||||
export const SYMBOLS: Record<string, string> = {
|
||||
EUR: '€', USD: '$', GBP: '£', JPY: '¥', CHF: 'CHF', CZK: 'Kč', PLN: 'zł',
|
||||
SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$',
|
||||
NZD: 'NZ$', BRL: 'R$', MXN: 'MX$', INR: '₹', IDR: 'Rp', MYR: 'RM',
|
||||
PHP: '₱', SGD: 'S$', KRW: '₩', CNY: '¥', HKD: 'HK$', TWD: 'NT$',
|
||||
ZAR: 'R', AED: 'د.إ', SAR: '﷼', ILS: '₪', EGP: 'E£', MAD: 'MAD',
|
||||
HUF: 'Ft', RON: 'lei', BGN: 'лв', HRK: 'kn', ISK: 'kr', RUB: '₽',
|
||||
UAH: '₴', BDT: '৳', LKR: 'Rs', VND: '₫', CLP: 'CL$', COP: 'CO$',
|
||||
PEN: 'S/.', ARS: 'AR$',
|
||||
}
|
||||
|
||||
export const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7']
|
||||
|
||||
export const SPLIT_COLORS = [
|
||||
{ solid: '#6366f1', gradient: 'linear-gradient(135deg, #6366f1, #8b5cf6)' },
|
||||
{ solid: '#ec4899', gradient: 'linear-gradient(135deg, #ec4899, #f43f5e)' },
|
||||
{ solid: '#10b981', gradient: 'linear-gradient(135deg, #10b981, #22c55e)' },
|
||||
{ solid: '#f59e0b', gradient: 'linear-gradient(135deg, #f59e0b, #f97316)' },
|
||||
{ solid: '#06b6d4', gradient: 'linear-gradient(135deg, #06b6d4, #3b82f6)' },
|
||||
{ solid: '#a855f7', gradient: 'linear-gradient(135deg, #a855f7, #d946ef)' },
|
||||
]
|
||||
@@ -0,0 +1,73 @@
|
||||
import { currencyDecimals } from '../../utils/formatters'
|
||||
import { SYMBOLS, SPLIT_COLORS } from './BudgetPanel.constants'
|
||||
|
||||
export function widgetTheme(dark: boolean) {
|
||||
if (dark) return {
|
||||
bg: 'linear-gradient(180deg, #17171d 0%, #0d0d12 100%)',
|
||||
border: 'rgba(255,255,255,0.07)',
|
||||
text: '#ffffff',
|
||||
sub: 'rgba(255,255,255,0.6)',
|
||||
faint: 'rgba(255,255,255,0.4)',
|
||||
track: 'rgba(255,255,255,0.04)',
|
||||
divider: 'rgba(255,255,255,0.07)',
|
||||
iconBg: 'rgba(255,255,255,0.08)',
|
||||
iconBorder: 'rgba(255,255,255,0.12)',
|
||||
iconColor: 'rgba(255,255,255,0.9)',
|
||||
centerBg: '#17171d',
|
||||
flowBg: 'rgba(255,255,255,0.05)',
|
||||
flowBorder: 'rgba(255,255,255,0.07)',
|
||||
flowHoverBg: 'rgba(255,255,255,0.08)',
|
||||
flowHoverBorder: 'rgba(255,255,255,0.12)',
|
||||
rowHover: 'rgba(255,255,255,0.03)',
|
||||
shadow: '0 20px 50px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.04)',
|
||||
donutShadow: 'drop-shadow(0 0 20px rgba(0,0,0,0.3))',
|
||||
}
|
||||
return {
|
||||
bg: 'linear-gradient(180deg, #ffffff 0%, #f9fafb 100%)',
|
||||
border: 'rgba(15,23,42,0.08)',
|
||||
text: '#111827',
|
||||
sub: 'rgba(17,24,39,0.6)',
|
||||
faint: 'rgba(17,24,39,0.4)',
|
||||
track: 'rgba(15,23,42,0.05)',
|
||||
divider: 'rgba(15,23,42,0.08)',
|
||||
iconBg: 'rgba(15,23,42,0.05)',
|
||||
iconBorder: 'rgba(15,23,42,0.1)',
|
||||
iconColor: 'rgba(17,24,39,0.75)',
|
||||
centerBg: '#ffffff',
|
||||
flowBg: 'rgba(15,23,42,0.03)',
|
||||
flowBorder: 'rgba(15,23,42,0.08)',
|
||||
flowHoverBg: 'rgba(15,23,42,0.06)',
|
||||
flowHoverBorder: 'rgba(15,23,42,0.14)',
|
||||
rowHover: 'rgba(15,23,42,0.04)',
|
||||
shadow: '0 12px 32px rgba(15,23,42,0.08), 0 2px 6px rgba(0,0,0,0.04)',
|
||||
donutShadow: 'drop-shadow(0 4px 18px rgba(15,23,42,0.12))',
|
||||
}
|
||||
}
|
||||
|
||||
export function hexLighten(hex: string, amount: number): string {
|
||||
const m = hex.replace('#', '').match(/.{2}/g)
|
||||
if (!m || m.length !== 3) return hex
|
||||
const mix = (c: number) => Math.min(255, Math.round(c + (255 - c) * amount))
|
||||
const [r, g, b] = m.map(x => parseInt(x, 16))
|
||||
return `#${[mix(r), mix(g), mix(b)].map(v => v.toString(16).padStart(2, '0')).join('')}`
|
||||
}
|
||||
|
||||
export const fmtNum = (v: number | null | undefined, locale: string, cur: string) => {
|
||||
if (v == null || isNaN(v)) return '-'
|
||||
const d = currencyDecimals(cur)
|
||||
return Number(v).toLocaleString(locale, { minimumFractionDigits: d, maximumFractionDigits: d }) + ' ' + (SYMBOLS[cur] || cur)
|
||||
}
|
||||
|
||||
type NumOrNull = number | null | undefined
|
||||
|
||||
export const calcPP = (p: NumOrNull, n: NumOrNull) => (n! > 0 ? (p as number) / (n as number) : null)
|
||||
export const calcPD = (p: NumOrNull, d: NumOrNull) => (d! > 0 ? (p as number) / (d as number) : null)
|
||||
export const calcPPD = (p: NumOrNull, n: NumOrNull, d: NumOrNull) => (n! > 0 && d! > 0 ? (p as number) / ((n as number) * (d as number)) : null)
|
||||
|
||||
export function splitColorFor(userId: number, order: number) {
|
||||
return SPLIT_COLORS[order % SPLIT_COLORS.length]
|
||||
}
|
||||
|
||||
export function colorForUserId(userId: number) {
|
||||
return SPLIT_COLORS[((userId | 0) - 1 + SPLIT_COLORS.length * 1000) % SPLIT_COLORS.length]
|
||||
}
|
||||
@@ -66,7 +66,8 @@ describe('BudgetPanel', () => {
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Transport');
|
||||
// 'Transport' appears in the category section header and the spend breakdown chart.
|
||||
expect((await screen.findAllByText('Transport')).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-006: renders budget table headers', async () => {
|
||||
@@ -76,7 +77,8 @@ describe('BudgetPanel', () => {
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Name');
|
||||
await screen.findByText('Total');
|
||||
// 'Total' appears both as a table header and in the chart total label.
|
||||
expect((await screen.findAllByText('Total')).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-007: shows Budget title heading', async () => {
|
||||
@@ -169,8 +171,9 @@ describe('BudgetPanel', () => {
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Transport');
|
||||
await screen.findByText('Hotels');
|
||||
// Each category appears in its section header and again in the breakdown chart.
|
||||
expect((await screen.findAllByText('Transport')).length).toBeGreaterThan(0);
|
||||
expect((await screen.findAllByText('Hotels')).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-015: currency from settings store is used for default_currency display', async () => {
|
||||
@@ -200,7 +203,8 @@ describe('BudgetPanel', () => {
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('ToDelete');
|
||||
// 'ToDelete' appears in the category header and the breakdown chart.
|
||||
expect((await screen.findAllByText('ToDelete')).length).toBeGreaterThan(0);
|
||||
expect(screen.getByTitle('Delete Category')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -390,7 +394,7 @@ describe('BudgetPanel', () => {
|
||||
const item = {
|
||||
...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Shared Dinner' }),
|
||||
total_price: 75,
|
||||
members: [{ user_id: 1, username: 'testuser', avatar_url: null, paid: false }],
|
||||
members: [{ user_id: 1, username: 'testuser', avatar_url: null, paid: 0 }],
|
||||
};
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
@@ -425,7 +429,7 @@ describe('BudgetPanel', () => {
|
||||
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
|
||||
// Use a user with id != 1 so they're not the owner
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 9999 }) });
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Read Only Item' }), total_price: 50 };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
@@ -439,7 +443,7 @@ describe('BudgetPanel', () => {
|
||||
it('FE-COMP-BUDGET-034: read-only mode shows expense_date as text span', async () => {
|
||||
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 9999 }) });
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Train' }), total_price: 30, expense_date: '2025-06-15' };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
@@ -484,7 +488,7 @@ describe('BudgetPanel', () => {
|
||||
it('FE-COMP-BUDGET-036: expense_date shows dash when not set in read-only mode', async () => {
|
||||
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 9999 }) });
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Snack' }), total_price: 5, expense_date: null };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
|
||||
interface AddItemRowProps {
|
||||
onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null; expense_date: string | null }) => void
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
export default function AddItemRow({ onAdd, t }: AddItemRowProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [price, setPrice] = useState('')
|
||||
const [persons, setPersons] = useState('')
|
||||
const [days, setDays] = useState('')
|
||||
const [note, setNote] = useState('')
|
||||
const [expenseDate, setExpenseDate] = useState('')
|
||||
const nameRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!name.trim()) return
|
||||
onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null, expense_date: expenseDate || null })
|
||||
setName(''); setPrice(''); setPersons(''); setDays(''); setNote(''); setExpenseDate('')
|
||||
setTimeout(() => nameRef.current?.focus(), 50)
|
||||
}
|
||||
|
||||
const inp = { border: '1px solid var(--border-primary)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', fontFamily: 'inherit', width: '100%', background: 'var(--bg-input)', color: 'var(--text-primary)' }
|
||||
|
||||
return (
|
||||
<tr className="bg-surface-secondary">
|
||||
<td style={{ padding: '4px 6px' }}>
|
||||
<input ref={nameRef} value={name} onChange={e => setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
placeholder={t('budget.newEntry')} style={inp} />
|
||||
</td>
|
||||
<td style={{ padding: '4px 6px' }}>
|
||||
<input value={price} onChange={e => 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' }} />
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<input value={persons} onChange={e => setPersons(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} />
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<input value={days} onChange={e => setDays(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} />
|
||||
</td>
|
||||
<td className="hidden md:table-cell text-content-faint" style={{ padding: '4px 6px', fontSize: 12, textAlign: 'center' }}>-</td>
|
||||
<td className="hidden md:table-cell text-content-faint" style={{ padding: '4px 6px', fontSize: 12, textAlign: 'center' }}>-</td>
|
||||
<td className="hidden lg:table-cell text-content-faint" style={{ padding: '4px 6px', fontSize: 12, textAlign: 'center' }}>-</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<div style={{ maxWidth: 90, margin: '0 auto' }}>
|
||||
<CustomDatePicker value={expenseDate} onChange={setExpenseDate} placeholder="-" compact />
|
||||
</div>
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px' }}>
|
||||
<input value={note} onChange={e => setNote(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder={t('budget.table.note')} style={inp} />
|
||||
</td>
|
||||
<td style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<button onClick={handleAdd} disabled={!name.trim()} title={t('reservations.add')}
|
||||
style={{ background: name.trim() ? 'var(--text-primary)' : 'var(--border-primary)', border: 'none', borderRadius: 4, color: 'var(--bg-primary)',
|
||||
cursor: name.trim() ? 'pointer' : 'default', padding: '4px 8px', display: 'inline-flex', alignItems: 'center' }}>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import type { CSSProperties, Dispatch, SetStateAction } from 'react'
|
||||
import { Trash2, Pencil, GripVertical } from 'lucide-react'
|
||||
import type { BudgetItem } from '../../types'
|
||||
import { currencyDecimals } from '../../utils/formatters'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import { calcPP, calcPD, calcPPD } from './BudgetPanel.helpers'
|
||||
import InlineEditCell from './BudgetPanelInlineEditCell'
|
||||
import AddItemRow from './BudgetPanelAddItemRow'
|
||||
import BudgetMemberChips, { type TripMember } from './BudgetPanelMemberChips'
|
||||
import type { EditingCat, AddItemData } from './useBudgetPanel'
|
||||
|
||||
interface BudgetCategoryTableProps {
|
||||
cat: string
|
||||
grouped: Map<string, BudgetItem[]>
|
||||
categoryColor: (cat: string) => string
|
||||
canEdit: boolean
|
||||
editingCat: EditingCat | null
|
||||
setEditingCat: Dispatch<SetStateAction<EditingCat | null>>
|
||||
dragCat: string | null
|
||||
setDragCat: Dispatch<SetStateAction<string | null>>
|
||||
dragOverCat: string | null
|
||||
setDragOverCat: Dispatch<SetStateAction<string | null>>
|
||||
dragItem: number | null
|
||||
setDragItem: Dispatch<SetStateAction<number | null>>
|
||||
dragOverItem: number | null
|
||||
setDragOverItem: Dispatch<SetStateAction<number | null>>
|
||||
dragItemCat: string | null
|
||||
setDragItemCat: Dispatch<SetStateAction<string | null>>
|
||||
categoryNames: string[]
|
||||
reorderBudgetCategories: (tripId: number | string, orderedCategories: string[]) => Promise<void>
|
||||
reorderBudgetItems: (tripId: number | string, orderedIds: number[]) => Promise<void>
|
||||
handleRenameCategory: (oldName: string, newName: string) => Promise<void>
|
||||
handleDeleteCategory: (cat: string) => Promise<void>
|
||||
handleDeleteItem: (id: number) => Promise<void>
|
||||
handleUpdateField: (id: number, field: string, value: unknown) => Promise<void>
|
||||
handleAddItem: (category: string, data: AddItemData) => Promise<void>
|
||||
tripId: number
|
||||
currency: string
|
||||
locale: string
|
||||
t: (key: string) => string
|
||||
fmt: (v: number | null | undefined, cur: string) => string
|
||||
hasMultipleMembers: boolean
|
||||
tripMembers: TripMember[]
|
||||
setBudgetItemMembers: (tripId: number | string, itemId: number, userIds: number[]) => Promise<{ members: unknown; item: unknown }>
|
||||
toggleBudgetMemberPaid: (tripId: number | string, itemId: number, userId: number, paid: boolean) => Promise<void>
|
||||
th: CSSProperties
|
||||
td: CSSProperties
|
||||
}
|
||||
|
||||
export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEdit, editingCat, setEditingCat,
|
||||
dragCat, setDragCat, dragOverCat, setDragOverCat, dragItem, setDragItem, dragOverItem, setDragOverItem,
|
||||
dragItemCat, setDragItemCat, categoryNames, reorderBudgetCategories, reorderBudgetItems,
|
||||
handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleUpdateField, handleAddItem,
|
||||
tripId, currency, locale, t, fmt, hasMultipleMembers, tripMembers, setBudgetItemMembers, toggleBudgetMemberPaid, th, td }: BudgetCategoryTableProps) {
|
||||
const items = grouped.get(cat) || []
|
||||
const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0)
|
||||
const color = categoryColor(cat)
|
||||
return (
|
||||
<div key={cat} data-drag-cat={cat} style={{
|
||||
marginBottom: 16, opacity: dragCat === cat ? 0.4 : 1,
|
||||
transition: 'opacity 0.15s',
|
||||
position: 'relative',
|
||||
}}
|
||||
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)
|
||||
}}
|
||||
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)
|
||||
}
|
||||
setDragCat(null); setDragOverCat(null)
|
||||
}}
|
||||
>
|
||||
{dragOverCat === cat && <div style={{ position: 'absolute', top: -2, left: 0, right: 0, height: 4, background: 'var(--accent)', borderRadius: 2, zIndex: 10 }} />}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#000000', color: '#fff',
|
||||
borderRadius: '10px 10px 0 0', padding: '9px 14px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}>
|
||||
{canEdit && (
|
||||
<div draggable onDragStart={e => { 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 }}>
|
||||
<GripVertical size={14} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 }} />
|
||||
{canEdit && editingCat?.name === cat ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={editingCat.value}
|
||||
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%' }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ fontWeight: 600, fontSize: 13 }}>{cat}</span>
|
||||
{canEdit && (
|
||||
<button onClick={() => setEditingCat({ name: cat, value: cat })}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}>
|
||||
<Pencil size={10} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
|
||||
{canEdit && (
|
||||
<button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
|
||||
style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 4, color: '#fff', cursor: 'pointer', padding: '3px 6px', display: 'flex', alignItems: 'center', opacity: 0.6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.6'}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto', border: '1px solid var(--border-primary)', borderTop: 'none', borderRadius: '0 0 10px 10px' }}
|
||||
onDragOver={e => { if (dragCat) { e.preventDefault(); e.dataTransfer.dropEffect = 'move' } }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ ...th, textAlign: 'left', minWidth: 120 }}>{t('budget.table.name')}</th>
|
||||
<th style={{ ...th, minWidth: 75 }}>{t('budget.table.total')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 160 }}>{t('budget.table.persons')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 55 }}>{t('budget.table.days')}</th>
|
||||
<th className="hidden md:table-cell" style={{ ...th, minWidth: 100 }}>{t('budget.table.perPerson')}</th>
|
||||
<th className="hidden md:table-cell" style={{ ...th, minWidth: 90 }}>{t('budget.table.perDay')}</th>
|
||||
<th className="hidden lg:table-cell" style={{ ...th, minWidth: 95 }}>{t('budget.table.perPersonDay')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, width: 90, maxWidth: 90 }}>{t('budget.table.date')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 150 }}>{t('budget.table.note')}</th>
|
||||
<th style={{ ...th, width: 36 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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) > 0
|
||||
return (
|
||||
<tr key={item.id}
|
||||
style={{
|
||||
transition: 'background 0.1s, opacity 0.15s',
|
||||
opacity: dragItem === item.id ? 0.4 : 1,
|
||||
boxShadow: dragOverItem === item.id ? 'inset 4px 0 0 0 var(--accent)' : 'none',
|
||||
}}
|
||||
onDragOver={e => {
|
||||
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 => {
|
||||
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'}>
|
||||
<td style={td}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{canEdit && (
|
||||
<div draggable onDragStart={e => { 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 }}>
|
||||
<GripVertical size={12} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<InlineEditCell value={item.name} onSave={v => 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 && (
|
||||
<div className="sm:hidden" style={{ marginTop: 4 }}>
|
||||
<BudgetMemberChips
|
||||
members={item.members || []}
|
||||
tripMembers={tripMembers}
|
||||
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
||||
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
|
||||
compact={false}
|
||||
readOnly={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ ...td, textAlign: 'center' }}>
|
||||
<InlineEditCell value={item.total_price} type="number" decimals={currencyDecimals(currency)} onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center', position: 'relative' }}>
|
||||
{hasMultipleMembers ? (
|
||||
<BudgetMemberChips
|
||||
members={item.members || []}
|
||||
tripMembers={tripMembers}
|
||||
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
||||
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
|
||||
readOnly={!canEdit}
|
||||
/>
|
||||
) : (
|
||||
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||
)}
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}>
|
||||
<InlineEditCell value={item.days} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'days', v != null ? parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||
</td>
|
||||
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pp != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pp != null ? fmt(pp, currency) : '-'}</td>
|
||||
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pd != null ? fmt(pd, currency) : '-'}</td>
|
||||
<td className="hidden lg:table-cell" style={{ ...td, textAlign: 'center', color: ppd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{ppd != null ? fmt(ppd, currency) : '-'}</td>
|
||||
<td className="hidden sm:table-cell" style={{ ...td, padding: '2px 6px', width: 90, maxWidth: 90, textAlign: 'center' }}>
|
||||
{canEdit ? (
|
||||
<div style={{ maxWidth: 90, margin: '0 auto' }}>
|
||||
<CustomDatePicker value={item.expense_date || ''} onChange={v => handleUpdateField(item.id, 'expense_date', v || null)} placeholder="—" compact borderless />
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: 11, color: item.expense_date ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{item.expense_date || '—'}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={td}><InlineEditCell value={item.note} onSave={v => handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /></td>
|
||||
<td style={{ ...td, textAlign: 'center' }}>
|
||||
{canEdit && (
|
||||
<button onClick={() => handleDeleteItem(item.id)} title={t('common.delete')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, color: 'var(--text-faint)', borderRadius: 4, display: 'inline-flex', transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#d1d5db'}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{canEdit && <AddItemRow onAdd={data => handleAddItem(cat, data)} t={t} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
interface InlineEditCellProps {
|
||||
value: string | number | null | undefined
|
||||
onSave: (value: string | number | null) => void
|
||||
type?: 'text' | 'number'
|
||||
style?: React.CSSProperties
|
||||
placeholder?: string
|
||||
decimals?: number
|
||||
locale: string
|
||||
editTooltip?: string
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
export default function InlineEditCell({ value, onSave, type = 'text', style = {} as React.CSSProperties, placeholder = '', decimals = 2, locale, editTooltip, readOnly = false }: InlineEditCellProps) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editValue, setEditValue] = useState<string | number>(value ?? '')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => { if (editing && inputRef.current) { inputRef.current.focus(); inputRef.current.select() } }, [editing])
|
||||
|
||||
const save = () => {
|
||||
setEditing(false)
|
||||
let v: string | number | null = editValue
|
||||
if (type === 'number') { const p = parseFloat(String(editValue).replace(',', '.')); v = isNaN(p) ? null : p }
|
||||
if (v !== value) onSave(v)
|
||||
}
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
if (type !== 'number') return
|
||||
e.preventDefault()
|
||||
let text = e.clipboardData.getData('text').trim()
|
||||
// Strip everything except digits, dots, commas, minus
|
||||
text = text.replace(/[^\d.,-]/g, '')
|
||||
// Remove all thousand separators (dots or commas before 3-digit groups), keep last separator as decimal
|
||||
const lastComma = text.lastIndexOf(',')
|
||||
const lastDot = text.lastIndexOf('.')
|
||||
const decimalPos = Math.max(lastComma, lastDot)
|
||||
if (decimalPos > -1) {
|
||||
const intPart = text.substring(0, decimalPos).replace(/[.,]/g, '')
|
||||
const decPart = text.substring(decimalPos + 1)
|
||||
text = intPart + '.' + decPart
|
||||
} else {
|
||||
text = text.replace(/[.,]/g, '')
|
||||
}
|
||||
setEditValue(text)
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return <input ref={inputRef} type="text" inputMode={type === 'number' ? 'decimal' : 'text'} value={editValue}
|
||||
onChange={e => setEditValue(e.target.value)} onBlur={save} onPaste={handlePaste}
|
||||
onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setEditValue(value ?? ''); setEditing(false) } }}
|
||||
style={{ width: '100%', border: '1px solid var(--accent)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', ...style }}
|
||||
placeholder={placeholder} />
|
||||
}
|
||||
|
||||
const display = type === 'number' && value != null
|
||||
? Number(value).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
|
||||
: (value || '')
|
||||
|
||||
return (
|
||||
<div onClick={() => { if (readOnly) return; setEditValue(value ?? ''); setEditing(true) }} title={readOnly ? undefined : editTooltip}
|
||||
style={{ cursor: readOnly ? 'default' : 'pointer', padding: '2px 4px', borderRadius: 4, minHeight: 22, display: 'flex', alignItems: 'center',
|
||||
justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s',
|
||||
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 13, ...style }}
|
||||
onMouseEnter={e => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!readOnly) e.currentTarget.style.background = 'transparent' }}>
|
||||
{display || placeholder || '-'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Pencil, Users, Check } from 'lucide-react'
|
||||
import type { BudgetItemMember } from '../../types'
|
||||
|
||||
export interface TripMember {
|
||||
id: number
|
||||
username: string
|
||||
avatar_url?: string | null
|
||||
}
|
||||
|
||||
// ── Chip with custom tooltip ─────────────────────────────────────────────────
|
||||
interface ChipWithTooltipProps {
|
||||
label: string
|
||||
avatarUrl: string | null
|
||||
size?: number
|
||||
paid?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export 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<HTMLDivElement>(null)
|
||||
|
||||
const onEnter = () => {
|
||||
if (ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect()
|
||||
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
|
||||
}
|
||||
setHover(true)
|
||||
}
|
||||
|
||||
const borderColor = paid ? '#22c55e' : 'var(--border-primary)'
|
||||
const bg = paid ? 'rgba(34,197,94,0.15)' : 'var(--bg-tertiary)'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={ref} onMouseEnter={onEnter} onMouseLeave={() => 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',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
}}>
|
||||
{avatarUrl
|
||||
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: label?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
{hover && ReactDOM.createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
||||
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
}}>
|
||||
{label}
|
||||
{paid && (
|
||||
<span style={{
|
||||
fontSize: 9, fontWeight: 700, padding: '1px 5px', borderRadius: 4,
|
||||
background: 'rgba(34,197,94,0.15)', color: '#16a34a',
|
||||
textTransform: 'uppercase', letterSpacing: '0.03em',
|
||||
}}>Paid</span>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Budget Member Chips (for Persons column) ────────────────────────────────
|
||||
interface BudgetMemberChipsProps {
|
||||
members?: BudgetItemMember[]
|
||||
tripMembers?: TripMember[]
|
||||
onSetMembers: (memberIds: number[]) => void
|
||||
onTogglePaid?: (userId: number, paid: boolean) => void
|
||||
compact?: boolean
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
export default 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<HTMLButtonElement>(null)
|
||||
const dropRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const openDropdown = useCallback(() => {
|
||||
if (btnRef.current) {
|
||||
const rect = btnRef.current.getBoundingClientRect()
|
||||
setDropPos({ top: rect.bottom + 4, left: rect.left + rect.width / 2 })
|
||||
}
|
||||
setShowDropdown(v => !v)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showDropdown) return
|
||||
const close = (e: MouseEvent) => {
|
||||
if (dropRef.current && dropRef.current.contains(e.target as Node)) return
|
||||
if (btnRef.current && btnRef.current.contains(e.target as Node)) return
|
||||
setShowDropdown(false)
|
||||
}
|
||||
document.addEventListener('mousedown', close)
|
||||
return () => document.removeEventListener('mousedown', close)
|
||||
}, [showDropdown])
|
||||
|
||||
const memberIds = members.map(m => m.user_id)
|
||||
|
||||
const toggleMember = (userId: number) => {
|
||||
const newIds = memberIds.includes(userId)
|
||||
? memberIds.filter(id => id !== userId)
|
||||
: [...memberIds, userId]
|
||||
onSetMembers(newIds)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||
{members.map(m => (
|
||||
<ChipWithTooltip key={m.user_id} label={m.username} avatarUrl={m.avatar_url} size={chipSize}
|
||||
paid={!!m.paid}
|
||||
onClick={!readOnly && onTogglePaid ? () => onTogglePaid(m.user_id, !m.paid) : undefined}
|
||||
/>
|
||||
))}
|
||||
{!readOnly && (
|
||||
<button ref={btnRef} onClick={openDropdown}
|
||||
style={{
|
||||
width: btnSize, height: btnSize, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
|
||||
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--text-faint)', padding: 0, flexShrink: 0,
|
||||
}}>
|
||||
{members.length > 0 ? <Pencil size={iconSize} /> : <Users size={iconSize} />}
|
||||
</button>
|
||||
)}
|
||||
{showDropdown && ReactDOM.createPortal(
|
||||
<div ref={dropRef} style={{
|
||||
position: 'fixed', top: dropPos.top, left: dropPos.left, transform: 'translateX(-50%)', zIndex: 10000,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 150,
|
||||
}}>
|
||||
{tripMembers.map(tm => {
|
||||
const isActive = memberIds.includes(tm.id)
|
||||
return (
|
||||
<button key={tm.id} onClick={() => toggleMember(tm.id)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '5px 8px',
|
||||
borderRadius: 6, border: 'none', background: isActive ? 'var(--bg-hover)' : 'none', cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 11, color: 'var(--text-primary)', textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'none' }}
|
||||
>
|
||||
<div style={{
|
||||
width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 8, fontWeight: 700,
|
||||
color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
{tm.avatar_url
|
||||
? <img src={tm.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: tm.username?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
<span style={{ flex: 1 }}>{tm.username}</span>
|
||||
{isActive && <Check size={12} color="var(--text-primary)" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { budgetApi } from '../../api/client'
|
||||
import type { BudgetItem } from '../../types'
|
||||
import { fmtNum, colorForUserId, widgetTheme } from './BudgetPanel.helpers'
|
||||
import RingAvatar from './BudgetPanelRingAvatar'
|
||||
|
||||
interface PerPersonSummaryEntry {
|
||||
user_id: number
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
total_assigned: number
|
||||
}
|
||||
|
||||
interface PerPersonInlineProps {
|
||||
tripId: number
|
||||
budgetItems: BudgetItem[]
|
||||
currency: string
|
||||
locale: string
|
||||
}
|
||||
|
||||
export default function PerPersonInline({ tripId, budgetItems, currency, locale, grandTotal, theme }: PerPersonInlineProps & { grandTotal: number; theme: ReturnType<typeof widgetTheme> }) {
|
||||
const [data, setData] = useState<PerPersonSummaryEntry[] | null>(null)
|
||||
const fmt = (v: number) => fmtNum(v, locale, currency)
|
||||
|
||||
useEffect(() => {
|
||||
budgetApi.perPersonSummary(tripId).then(d => setData(d.summary)).catch(() => {})
|
||||
}, [tripId, budgetItems])
|
||||
|
||||
if (!data || data.length === 0) return null
|
||||
|
||||
const people = data.map(p => ({ ...p, color: colorForUserId(p.user_id) }))
|
||||
|
||||
return (
|
||||
<>
|
||||
{grandTotal > 0 && (
|
||||
<div style={{ display: 'flex', height: 6, borderRadius: 999, overflow: 'hidden', marginTop: 8, marginBottom: 4, gap: 3 }}>
|
||||
{people.map(p => (
|
||||
<div key={p.user_id} style={{
|
||||
height: '100%', borderRadius: 999,
|
||||
flex: Math.max(p.total_assigned || 0, 0.01),
|
||||
background: p.color.gradient,
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 14, paddingTop: 14, borderTop: `1px solid ${theme.divider}`, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{people.map(p => {
|
||||
const percent = grandTotal > 0 ? Math.round((p.total_assigned / grandTotal) * 100) : 0
|
||||
return (
|
||||
<div key={p.user_id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '6px 0' }}>
|
||||
<RingAvatar userId={p.user_id} username={p.username} avatarUrl={p.avatar_url} size={34} innerBg={theme.centerBg} textColor={theme.text} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 500, letterSpacing: '-0.01em', color: theme.text }}>{p.username}</div>
|
||||
<div style={{ fontSize: 11, color: theme.faint, marginTop: 1 }}>{percent}%</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600, color: theme.text, letterSpacing: '-0.01em' }}>{fmt(p.total_assigned)}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Wallet } from 'lucide-react'
|
||||
|
||||
interface PieSegment {
|
||||
label: string
|
||||
value: number
|
||||
color: string
|
||||
}
|
||||
|
||||
// ── Pie Chart (pure CSS conic-gradient) ──────────────────────────────────────
|
||||
interface PieChartProps {
|
||||
segments: PieSegment[]
|
||||
size?: number
|
||||
totalLabel: string
|
||||
}
|
||||
|
||||
export default function PieChart({ segments, size = 200, totalLabel }: PieChartProps) {
|
||||
if (!segments.length) 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(', ')
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: size, height: size, margin: '0 auto' }}>
|
||||
<div
|
||||
className="trek-pie-reveal"
|
||||
style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
background: `conic-gradient(${stops})`,
|
||||
boxShadow: '0 4px 24px rgba(0,0,0,0.08)',
|
||||
}}
|
||||
/>
|
||||
<div style={{
|
||||
position: 'absolute', top: '50%', left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: size * 0.55, height: size * 0.55,
|
||||
borderRadius: '50%', background: 'var(--bg-card)',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: 'inset 0 0 12px rgba(0,0,0,0.04)',
|
||||
}}>
|
||||
<Wallet size={18} color="var(--text-faint)" style={{ marginBottom: 2 }} />
|
||||
<span style={{ fontSize: 10, color: 'var(--text-faint)', fontWeight: 500 }}>{totalLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { colorForUserId } from './BudgetPanel.helpers'
|
||||
|
||||
export default 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 (
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: '50%', flexShrink: 0,
|
||||
padding: 2, background: color.gradient,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '100%', height: '100%', borderRadius: '50%',
|
||||
background: innerBg,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
fontSize: size < 28 ? 10 : 12, fontWeight: 600, color: textColor,
|
||||
}}>
|
||||
{avatarUrl ? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : username?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import { Wallet, Info, ChevronDown, ChevronRight, TrendingUp, TrendingDown, PieChart as PieChartIcon } from 'lucide-react'
|
||||
import type { BudgetItem } from '../../types'
|
||||
import { currencyDecimals } from '../../utils/formatters'
|
||||
import { SYMBOLS } from './BudgetPanel.constants'
|
||||
import { hexLighten, widgetTheme } from './BudgetPanel.helpers'
|
||||
import RingAvatar from './BudgetPanelRingAvatar'
|
||||
import PerPersonInline from './BudgetPanelPerPersonInline'
|
||||
import type { SettlementData, PieSegment } from './useBudgetPanel'
|
||||
|
||||
interface BudgetSummaryProps {
|
||||
theme: ReturnType<typeof widgetTheme>
|
||||
currency: string
|
||||
locale: string
|
||||
grandTotal: number
|
||||
hasMultipleMembers: boolean
|
||||
budgetItems: BudgetItem[]
|
||||
settlement: SettlementData | null
|
||||
settlementOpen: boolean
|
||||
setSettlementOpen: Dispatch<SetStateAction<boolean>>
|
||||
pieSegments: PieSegment[]
|
||||
isDark: boolean
|
||||
tripId: number
|
||||
t: (key: string) => string
|
||||
fmt: (v: number | null | undefined, cur: string) => string
|
||||
}
|
||||
|
||||
export default function BudgetSummary({ theme, currency, locale, grandTotal, hasMultipleMembers, budgetItems,
|
||||
settlement, settlementOpen, setSettlementOpen, pieSegments, isDark, tripId, t, fmt }: BudgetSummaryProps) {
|
||||
return (
|
||||
<div className="w-full md:w-[320px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
|
||||
|
||||
<div style={{
|
||||
background: theme.bg,
|
||||
borderRadius: 20, padding: 20, color: theme.text, marginBottom: 16,
|
||||
border: `1px solid ${theme.border}`,
|
||||
boxShadow: theme.shadow,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}>
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: 12,
|
||||
background: theme.iconBg,
|
||||
border: `1px solid ${theme.iconBorder}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: theme.iconColor, flexShrink: 0,
|
||||
}}>
|
||||
<Wallet size={20} strokeWidth={2} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 11, color: theme.faint, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.09em' }}>{t('budget.totalBudget')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
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 (
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, letterSpacing: '-0.03em', lineHeight: 1 }}>
|
||||
<span style={{ fontSize: 38, fontWeight: 700 }}>{integerPart}</span>
|
||||
{decimalPart && <span style={{ fontSize: 22, fontWeight: 500, color: theme.sub }}>{sep}{decimalPart}</span>}
|
||||
<span style={{ fontSize: 22, fontWeight: 500, color: theme.sub, marginLeft: 2 }}>{SYMBOLS[currency] || currency}</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
<div style={{ color: theme.faint, fontSize: 12, marginTop: 8, fontWeight: 500, letterSpacing: '0.04em', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span>{currency}</span>
|
||||
</div>
|
||||
|
||||
{hasMultipleMembers && (budgetItems || []).some(i => (i.members?.length ?? 0) > 0) && (
|
||||
<PerPersonInline tripId={tripId} budgetItems={budgetItems} currency={currency} locale={locale} grandTotal={grandTotal} theme={theme} />
|
||||
)}
|
||||
|
||||
{/* Settlement dropdown inside the total card */}
|
||||
{hasMultipleMembers && settlement && settlement.flows.length > 0 && (
|
||||
<div style={{ marginTop: 16, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}>
|
||||
<button onClick={() => setSettlementOpen(v => !v)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit',
|
||||
color: theme.sub, fontSize: 11, fontWeight: 600, letterSpacing: 0.5,
|
||||
}}>
|
||||
{settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||
{t('budget.settlement')}
|
||||
<span style={{ position: 'relative', display: 'inline-flex', marginLeft: 2 }}>
|
||||
<span style={{ display: 'flex', cursor: 'help' }}
|
||||
onMouseEnter={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'block' }}
|
||||
onMouseLeave={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'none' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Info size={11} strokeWidth={2} />
|
||||
</span>
|
||||
<div style={{
|
||||
display: 'none', position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)',
|
||||
marginTop: 6, width: 220, padding: '10px 12px', borderRadius: 10, zIndex: 100,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-faint)',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
|
||||
fontSize: 11, fontWeight: 400, color: 'var(--text-secondary)', lineHeight: 1.5, textAlign: 'left',
|
||||
}}>
|
||||
{t('budget.settlementInfo')}
|
||||
</div>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{settlementOpen && (
|
||||
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{settlement.flows.map((flow, i) => (
|
||||
<div key={i} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14,
|
||||
padding: '12px 14px', borderRadius: 14,
|
||||
background: theme.flowBg,
|
||||
border: `1px solid ${theme.flowBorder}`,
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = theme.flowHoverBg; e.currentTarget.style.borderColor = theme.flowHoverBorder }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = theme.flowBg; e.currentTarget.style.borderColor = theme.flowBorder }}
|
||||
>
|
||||
<RingAvatar userId={flow.from.user_id} username={flow.from.username} avatarUrl={flow.from.avatar_url} size={32} innerBg={theme.centerBg} textColor={theme.text} />
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 5 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: '#ef4444', letterSpacing: '-0.01em' }}>
|
||||
{fmt(flow.amount, currency)}
|
||||
</span>
|
||||
<div style={{ width: '100%', height: 2, borderRadius: 2, background: 'linear-gradient(90deg, rgba(239,68,68,0.1), rgba(239,68,68,0.55), rgba(239,68,68,0.3))', position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', right: -1, top: '50%', transform: 'translateY(-50%)', width: 0, height: 0, borderLeft: '6px solid rgba(239,68,68,0.55)', borderTop: '4px solid transparent', borderBottom: '4px solid transparent' }} />
|
||||
</div>
|
||||
</div>
|
||||
<RingAvatar userId={flow.to.user_id} username={flow.to.username} avatarUrl={flow.to.avatar_url} size={32} innerBg={theme.centerBg} textColor={theme.text} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && (
|
||||
<div style={{ marginTop: 8, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.11em', marginBottom: 10 }}>
|
||||
{t('budget.netBalances')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).map(b => {
|
||||
const positive = b.balance > 0
|
||||
const Trend = positive ? TrendingUp : TrendingDown
|
||||
return (
|
||||
<div key={b.user_id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '5px 0' }}>
|
||||
<RingAvatar userId={b.user_id} username={b.username} avatarUrl={b.avatar_url} size={26} innerBg={theme.centerBg} textColor={theme.text} />
|
||||
<span style={{ flex: 1, fontSize: 13, color: theme.text, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{b.username}
|
||||
</span>
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
padding: '4px 10px', borderRadius: 8,
|
||||
fontSize: 12, fontWeight: 700, letterSpacing: '-0.01em',
|
||||
background: positive ? 'rgba(16,185,129,0.13)' : 'rgba(239,68,68,0.13)',
|
||||
color: positive ? '#10b981' : '#ef4444',
|
||||
}}>
|
||||
<Trend size={11} strokeWidth={3} />
|
||||
{positive ? '+' : ''}{fmt(b.balance, currency)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pieSegments.length > 0 && (() => {
|
||||
const decimals = currencyDecimals(currency)
|
||||
const total = pieSegments.reduce((s, x) => s + x.value, 0)
|
||||
const totalFmt = Number(total).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
|
||||
const decimalSep = (0.1).toLocaleString(locale).replace(/\d/g, '')
|
||||
const [totalInt, totalDec] = decimals > 0 ? totalFmt.split(decimalSep) : [totalFmt, '']
|
||||
const R = 80
|
||||
const CIRC = 2 * Math.PI * R
|
||||
let dashOffset = 0
|
||||
return (
|
||||
<div style={{
|
||||
background: theme.bg,
|
||||
borderRadius: 20, padding: 20, color: theme.text, marginBottom: 16,
|
||||
border: `1px solid ${theme.border}`,
|
||||
boxShadow: theme.shadow,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}>
|
||||
<div style={{
|
||||
width: 38, height: 38, borderRadius: 11,
|
||||
background: theme.iconBg,
|
||||
border: `1px solid ${theme.iconBorder}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: theme.iconColor, flexShrink: 0,
|
||||
}}>
|
||||
<PieChartIcon size={18} strokeWidth={2} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 11, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.09em', fontWeight: 600 }}>{t('budget.byCategory')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ position: 'relative', display: 'flex', justifyContent: 'center', margin: '4px 0 16px' }}>
|
||||
<svg width={200} height={200} viewBox="0 0 200 200" style={{ transform: 'rotate(-90deg)', filter: theme.donutShadow }}>
|
||||
<defs>
|
||||
{pieSegments.map((seg, i) => {
|
||||
const c2 = hexLighten(seg.color, 0.2)
|
||||
return (
|
||||
<linearGradient key={`grad-${i}`} id={`cat-grad-${i}`} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor={seg.color} />
|
||||
<stop offset="100%" stopColor={c2} />
|
||||
</linearGradient>
|
||||
)
|
||||
})}
|
||||
</defs>
|
||||
<circle cx={100} cy={100} r={R} fill="none" stroke={theme.track} strokeWidth={22} />
|
||||
{pieSegments.map((seg, i) => {
|
||||
const segLen = total > 0 ? (seg.value / total) * CIRC : 0
|
||||
const circle = (
|
||||
<circle key={i}
|
||||
cx={100} cy={100} r={R}
|
||||
fill="none" strokeLinecap="round" strokeWidth={22}
|
||||
stroke={`url(#cat-grad-${i})`}
|
||||
strokeDasharray={`${segLen} ${CIRC}`}
|
||||
strokeDashoffset={-dashOffset}
|
||||
/>
|
||||
)
|
||||
dashOffset += segLen
|
||||
return circle
|
||||
})}
|
||||
</svg>
|
||||
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', textAlign: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, pointerEvents: 'none' }}>
|
||||
<div style={{ fontSize: 10.5, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 700 }}>{t('budget.total')}</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, display: 'flex', alignItems: 'baseline', gap: 2 }}>
|
||||
<span>{totalInt}</span>
|
||||
{totalDec && <span style={{ fontSize: 13, fontWeight: 500, color: theme.sub }}>{decimalSep}{totalDec}</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: 10.5, color: theme.faint, fontWeight: 500, marginTop: 2 }}>{currency}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: `1px solid ${theme.divider}`, paddingTop: 10, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{pieSegments.map((seg, i) => {
|
||||
const pct = total > 0 ? (seg.value / total) * 100 : 0
|
||||
const pctLabel = pct.toFixed(1).replace('.', decimalSep) + '%'
|
||||
const c2 = hexLighten(seg.color, 0.2)
|
||||
const chipColor = isDark ? hexLighten(seg.color, 0.35) : seg.color
|
||||
return (
|
||||
<div key={seg.name} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '10px 8px', borderRadius: 12,
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = theme.rowHover}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<div style={{
|
||||
width: 10, height: 10, borderRadius: 3, flexShrink: 0,
|
||||
background: `linear-gradient(135deg, ${seg.color}, ${c2})`,
|
||||
boxShadow: `0 0 12px ${seg.color}80`,
|
||||
}} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 500, letterSpacing: '-0.01em', color: theme.text, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{seg.name}</div>
|
||||
<div style={{ fontSize: 11.5, color: theme.sub, fontWeight: 500, marginTop: 1 }}>{fmt(seg.value, currency)}</div>
|
||||
</div>
|
||||
<span style={{
|
||||
flexShrink: 0,
|
||||
padding: '4px 9px', borderRadius: 7,
|
||||
fontSize: 11, fontWeight: 700, letterSpacing: '-0.01em',
|
||||
background: `${seg.color}26`,
|
||||
border: `1px solid ${seg.color}40`,
|
||||
color: chipColor,
|
||||
}}>{pctLabel}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { budgetApi } from '../../api/client'
|
||||
import type { BudgetItem } from '../../types'
|
||||
import { currencyDecimals } from '../../utils/formatters'
|
||||
import { widgetTheme, fmtNum, calcPP, calcPD, calcPPD } from './BudgetPanel.helpers'
|
||||
import { PIE_COLORS } from './BudgetPanel.constants'
|
||||
import type { TripMember } from './BudgetPanelMemberChips'
|
||||
|
||||
function useIsDark(): boolean {
|
||||
const [dark, setDark] = useState<boolean>(() => typeof document !== 'undefined' && document.documentElement.classList.contains('dark'))
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return
|
||||
const mo = new MutationObserver(() => setDark(document.documentElement.classList.contains('dark')))
|
||||
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||
return () => mo.disconnect()
|
||||
}, [])
|
||||
return dark
|
||||
}
|
||||
|
||||
export interface EditingCat {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface SettlementPerson {
|
||||
user_id: number
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
}
|
||||
|
||||
interface SettlementFlow {
|
||||
from: SettlementPerson
|
||||
to: SettlementPerson
|
||||
amount: number
|
||||
}
|
||||
|
||||
interface SettlementBalance {
|
||||
user_id: number
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
balance: number
|
||||
}
|
||||
|
||||
export interface SettlementData {
|
||||
balances: SettlementBalance[]
|
||||
flows: SettlementFlow[]
|
||||
}
|
||||
|
||||
export interface PieSegment {
|
||||
name: string
|
||||
value: number
|
||||
color: string
|
||||
}
|
||||
|
||||
export interface AddItemData {
|
||||
name: string
|
||||
total_price: number
|
||||
persons: number | null
|
||||
days: number | null
|
||||
note: string | null
|
||||
expense_date: string | null
|
||||
}
|
||||
|
||||
export function useBudgetPanel(tripId: number, tripMembers: TripMember[]) {
|
||||
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories } = useTripStore()
|
||||
const can = useCanDo()
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
const isDark = useIsDark()
|
||||
const theme = useMemo(() => widgetTheme(isDark), [isDark])
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [editingCat, setEditingCat] = useState<EditingCat | null>(null) // { name, value }
|
||||
const [settlement, setSettlement] = useState<SettlementData | null>(null)
|
||||
const [settlementOpen, setSettlementOpen] = useState(false)
|
||||
const currency = trip?.currency || 'EUR'
|
||||
const canEdit = can('budget_edit', trip)
|
||||
|
||||
const fmt = (v: number | null | undefined, cur: string) => fmtNum(v, locale, cur)
|
||||
const hasMultipleMembers = tripMembers.length > 1
|
||||
|
||||
// Drag state for categories
|
||||
const [dragCat, setDragCat] = useState<string | null>(null)
|
||||
const [dragOverCat, setDragOverCat] = useState<string | null>(null)
|
||||
// Drag state for items within a category
|
||||
const [dragItem, setDragItem] = useState<number | null>(null)
|
||||
const [dragOverItem, setDragOverItem] = useState<number | null>(null)
|
||||
const [dragItemCat, setDragItemCat] = useState<string | null>(null)
|
||||
|
||||
// Load settlement data whenever budget items change
|
||||
useEffect(() => {
|
||||
if (!hasMultipleMembers) return
|
||||
budgetApi.settlement(tripId).then(setSettlement).catch(() => {})
|
||||
}, [tripId, budgetItems, hasMultipleMembers])
|
||||
|
||||
const setCurrency = (cur: string) => {
|
||||
if (tripId) updateTrip(tripId, { currency: cur })
|
||||
}
|
||||
|
||||
useEffect(() => { if (tripId) loadBudgetItems(tripId) }, [tripId])
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, BudgetItem[]>()
|
||||
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])
|
||||
|
||||
const categoryNames = Array.from(grouped.keys())
|
||||
|
||||
// Stable color mapping: assign index-based colors once, never reassign on reorder
|
||||
const colorMapRef = useRef(new Map<string, string>())
|
||||
const categoryColor = useCallback((cat: string) => {
|
||||
const map = colorMapRef.current
|
||||
if (!map.has(cat)) {
|
||||
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)
|
||||
|
||||
const pieSegments = useMemo<PieSegment[]>(() =>
|
||||
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: string, data: AddItemData) => { try { await addBudgetItem(tripId, { ...data, category }) } catch { toast.error(t('common.error')) } }
|
||||
const handleUpdateField = async (id: number, field: string, value: unknown) => { try { await updateBudgetItem(tripId, id, { [field]: value } as Partial<BudgetItem>) } catch { toast.error(t('common.error')) } }
|
||||
const handleDeleteItem = async (id: number) => { try { await deleteBudgetItem(tripId, id) } catch { toast.error(t('common.error')) } }
|
||||
const handleDeleteCategory = async (cat: string) => {
|
||||
const items = grouped.get(cat) || []
|
||||
try { for (const item of Array.from(items)) await deleteBudgetItem(tripId, item.id) }
|
||||
catch { toast.error(t('common.error')) }
|
||||
}
|
||||
const handleRenameCategory = async (oldName: string, newName: string) => {
|
||||
if (!newName.trim() || newName.trim() === oldName) return
|
||||
const items = grouped.get(oldName) || []
|
||||
try { for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() }) }
|
||||
catch { toast.error(t('common.error')) }
|
||||
}
|
||||
const handleAddCategory = () => {
|
||||
if (!newCategoryName.trim()) return
|
||||
Promise.resolve(addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 }))
|
||||
.catch(() => toast.error(t('common.error')))
|
||||
setNewCategoryName('')
|
||||
}
|
||||
|
||||
const handleExportCsv = () => {
|
||||
const sep = ';'
|
||||
const esc = (v: unknown) => { 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)]
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
const bom = ''
|
||||
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À-ɏ _-]/g, '').trim()
|
||||
a.download = `budget-${safeName}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const th: CSSProperties = { 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: CSSProperties = { padding: '2px 6px', borderBottom: '1px solid var(--border-secondary)', fontSize: 13, verticalAlign: 'middle', color: 'var(--text-primary)' }
|
||||
|
||||
return {
|
||||
trip, budgetItems,
|
||||
setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories,
|
||||
t, locale, isDark, theme,
|
||||
newCategoryName, setNewCategoryName,
|
||||
editingCat, setEditingCat,
|
||||
settlement, settlementOpen, setSettlementOpen,
|
||||
currency, canEdit, fmt, hasMultipleMembers,
|
||||
dragCat, setDragCat, dragOverCat, setDragOverCat,
|
||||
dragItem, setDragItem, dragOverItem, setDragOverItem, dragItemCat, setDragItemCat,
|
||||
setCurrency,
|
||||
grouped, categoryNames, categoryColor, grandTotal, pieSegments,
|
||||
handleAddItem, handleUpdateField, handleDeleteItem, handleDeleteCategory, handleRenameCategory, handleAddCategory, handleExportCsv,
|
||||
th, td,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export const EMOJI_CATEGORIES = {
|
||||
'Smileys': ['😀','😂','🥹','😍','🤩','😎','🥳','😭','🤔','👀','🙈','🫠','😴','🤯','🥺','😤','💀','👻','🫡','🤝'],
|
||||
'Reactions': ['❤️','🔥','👍','👎','👏','🎉','💯','✨','⭐','💪','🙏','😱','😂','💖','💕','🤞','✅','❌','⚡','🏆'],
|
||||
'Travel': ['✈️','🏖️','🗺️','🧳','🏔️','🌅','🌴','🚗','🚂','🛳️','🏨','🍽️','🍕','🍹','📸','🎒','⛱️','🌍','🗼','🎌'],
|
||||
}
|
||||
|
||||
// Reaction Quick Menu (right-click)
|
||||
export const QUICK_REACTIONS = ['❤️', '😂', '👍', '😮', '😢', '🔥', '👏', '🎉']
|
||||
|
||||
export const URL_REGEX = /https?:\/\/[^\s<>"']+/g
|
||||
@@ -0,0 +1,42 @@
|
||||
// ── Twemoji helper (Apple-style emojis via CDN) ──
|
||||
export function emojiToCodepoint(emoji) {
|
||||
const codepoints = []
|
||||
for (const c of emoji) {
|
||||
const cp = c.codePointAt(0)
|
||||
if (cp !== 0xfe0f) codepoints.push(cp.toString(16)) // skip variation selector
|
||||
}
|
||||
return codepoints.join('-')
|
||||
}
|
||||
|
||||
// SQLite stores UTC without 'Z' suffix — append it so JS parses as UTC
|
||||
export function parseUTC(s) { return new Date(s && !s.endsWith('Z') ? s + 'Z' : s) }
|
||||
|
||||
export function formatTime(isoString, is12h) {
|
||||
const d = parseUTC(isoString)
|
||||
const h = d.getHours()
|
||||
const mm = String(d.getMinutes()).padStart(2, '0')
|
||||
if (is12h) {
|
||||
const period = h >= 12 ? 'PM' : 'AM'
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||
return `${h12}:${mm} ${period}`
|
||||
}
|
||||
return `${String(h).padStart(2, '0')}:${mm}`
|
||||
}
|
||||
|
||||
export function formatDateSeparator(isoString, t) {
|
||||
const d = parseUTC(isoString)
|
||||
const now = new Date()
|
||||
const yesterday = new Date(); yesterday.setDate(now.getDate() - 1)
|
||||
|
||||
if (d.toDateString() === now.toDateString()) return t('collab.chat.today') || 'Today'
|
||||
if (d.toDateString() === yesterday.toDateString()) return t('collab.chat.yesterday') || 'Yesterday'
|
||||
|
||||
return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
export function shouldShowDateSeparator(msg, prevMsg) {
|
||||
if (!prevMsg) return true
|
||||
const d1 = parseUTC(msg.created_at).toDateString()
|
||||
const d2 = parseUTC(prevMsg.created_at).toDateString()
|
||||
return d1 !== d2
|
||||
}
|
||||
@@ -1,350 +1,10 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { ArrowUp, Trash2, Reply, ChevronUp, MessageCircle, Smile, X } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { ArrowUp, Reply, Smile, X } from 'lucide-react'
|
||||
import type { User } from '../../types'
|
||||
|
||||
interface ChatReaction {
|
||||
emoji: string
|
||||
count: number
|
||||
users: { id: number; username: string }[]
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
id: number
|
||||
trip_id: number
|
||||
user_id: number
|
||||
text: string
|
||||
reply_to_id: number | null
|
||||
reactions: ChatReaction[]
|
||||
created_at: string
|
||||
user?: { username: string; avatar_url: string | null }
|
||||
reply_to?: ChatMessage | null
|
||||
}
|
||||
|
||||
// ── Twemoji helper (Apple-style emojis via CDN) ──
|
||||
function emojiToCodepoint(emoji) {
|
||||
const codepoints = []
|
||||
for (const c of emoji) {
|
||||
const cp = c.codePointAt(0)
|
||||
if (cp !== 0xfe0f) codepoints.push(cp.toString(16)) // skip variation selector
|
||||
}
|
||||
return codepoints.join('-')
|
||||
}
|
||||
|
||||
function TwemojiImg({ emoji, size = 20, style = {} }) {
|
||||
const cp = emojiToCodepoint(emoji)
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
if (failed) {
|
||||
return <span style={{ fontSize: size, lineHeight: 1, display: 'inline-block', verticalAlign: 'middle', ...style }}>{emoji}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={`https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/${cp}.png`}
|
||||
alt={emoji}
|
||||
draggable={false}
|
||||
style={{ width: size, height: size, display: 'inline-block', verticalAlign: 'middle', ...style }}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const EMOJI_CATEGORIES = {
|
||||
'Smileys': ['😀','😂','🥹','😍','🤩','😎','🥳','😭','🤔','👀','🙈','🫠','😴','🤯','🥺','😤','💀','👻','🫡','🤝'],
|
||||
'Reactions': ['❤️','🔥','👍','👎','👏','🎉','💯','✨','⭐','💪','🙏','😱','😂','💖','💕','🤞','✅','❌','⚡','🏆'],
|
||||
'Travel': ['✈️','🏖️','🗺️','🧳','🏔️','🌅','🌴','🚗','🚂','🛳️','🏨','🍽️','🍕','🍹','📸','🎒','⛱️','🌍','🗼','🎌'],
|
||||
}
|
||||
|
||||
// SQLite stores UTC without 'Z' suffix — append it so JS parses as UTC
|
||||
function parseUTC(s) { return new Date(s && !s.endsWith('Z') ? s + 'Z' : s) }
|
||||
|
||||
function formatTime(isoString, is12h) {
|
||||
const d = parseUTC(isoString)
|
||||
const h = d.getHours()
|
||||
const mm = String(d.getMinutes()).padStart(2, '0')
|
||||
if (is12h) {
|
||||
const period = h >= 12 ? 'PM' : 'AM'
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||
return `${h12}:${mm} ${period}`
|
||||
}
|
||||
return `${String(h).padStart(2, '0')}:${mm}`
|
||||
}
|
||||
|
||||
function formatDateSeparator(isoString, t) {
|
||||
const d = parseUTC(isoString)
|
||||
const now = new Date()
|
||||
const yesterday = new Date(); yesterday.setDate(now.getDate() - 1)
|
||||
|
||||
if (d.toDateString() === now.toDateString()) return t('collab.chat.today') || 'Today'
|
||||
if (d.toDateString() === yesterday.toDateString()) return t('collab.chat.yesterday') || 'Yesterday'
|
||||
|
||||
return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
function shouldShowDateSeparator(msg, prevMsg) {
|
||||
if (!prevMsg) return true
|
||||
const d1 = parseUTC(msg.created_at).toDateString()
|
||||
const d2 = parseUTC(prevMsg.created_at).toDateString()
|
||||
return d1 !== d2
|
||||
}
|
||||
|
||||
/* ── Emoji Picker ── */
|
||||
interface EmojiPickerProps {
|
||||
onSelect: (emoji: string) => void
|
||||
onClose: () => void
|
||||
anchorRef: React.RefObject<HTMLElement | null>
|
||||
containerRef: React.RefObject<HTMLElement | null>
|
||||
}
|
||||
|
||||
function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: EmojiPickerProps) {
|
||||
const [cat, setCat] = useState(Object.keys(EMOJI_CATEGORIES)[0])
|
||||
const ref = useRef(null)
|
||||
|
||||
const getPos = () => {
|
||||
const container = containerRef?.current
|
||||
const anchor = anchorRef?.current
|
||||
if (container && anchor) {
|
||||
const cRect = container.getBoundingClientRect()
|
||||
const aRect = anchor.getBoundingClientRect()
|
||||
return { bottom: window.innerHeight - aRect.top + 16, left: cRect.left + cRect.width / 2 - 140 }
|
||||
}
|
||||
return { bottom: 80, left: 0 }
|
||||
}
|
||||
const pos = getPos()
|
||||
|
||||
useEffect(() => {
|
||||
const close = (e) => {
|
||||
if (ref.current && ref.current.contains(e.target)) return
|
||||
if (anchorRef?.current && anchorRef.current.contains(e.target)) return
|
||||
onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', close)
|
||||
return () => document.removeEventListener('mousedown', close)
|
||||
}, [onClose, anchorRef])
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div ref={ref} style={{
|
||||
position: 'fixed', bottom: pos.bottom, left: pos.left, zIndex: 10000,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-faint)', borderRadius: 16,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.18)', width: 280, overflow: 'hidden',
|
||||
}}>
|
||||
{/* Category tabs */}
|
||||
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-faint)', padding: '6px 8px', gap: 2 }}>
|
||||
{Object.keys(EMOJI_CATEGORIES).map(c => (
|
||||
<button key={c} onClick={() => setCat(c)} style={{
|
||||
flex: 1, padding: '4px 0', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||
background: cat === c ? 'var(--bg-hover)' : 'transparent',
|
||||
color: 'var(--text-primary)', fontSize: 10, fontWeight: 600, fontFamily: 'inherit',
|
||||
}}>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Emoji grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(10, 1fr)', gap: 2, padding: 8 }}>
|
||||
{EMOJI_CATEGORIES[cat].map((emoji, i) => (
|
||||
<button key={i} onClick={() => onSelect(emoji)} style={{
|
||||
width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'none', border: 'none', cursor: 'pointer', borderRadius: 6,
|
||||
padding: 2, transition: 'transform 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.transform = 'scale(1.2)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'none'; e.currentTarget.style.transform = 'scale(1)' }}
|
||||
>
|
||||
<TwemojiImg emoji={emoji} size={20} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Reaction Quick Menu (right-click) ── */
|
||||
const QUICK_REACTIONS = ['❤️', '😂', '👍', '😮', '😢', '🔥', '👏', '🎉']
|
||||
|
||||
interface ReactionMenuProps {
|
||||
x: number
|
||||
y: number
|
||||
onReact: (emoji: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function ReactionMenu({ x, y, onReact, onClose }: ReactionMenuProps) {
|
||||
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])
|
||||
|
||||
// Clamp to viewport
|
||||
const menuWidth = 156
|
||||
const clampedLeft = Math.max(menuWidth / 2 + 8, Math.min(x, window.innerWidth - menuWidth / 2 - 8))
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{
|
||||
position: 'fixed', top: y - 80, left: clampedLeft, transform: 'translateX(-50%)', zIndex: 10000,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-faint)', borderRadius: 16,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.18)', padding: '6px 8px',
|
||||
display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 2, width: menuWidth,
|
||||
}}>
|
||||
{QUICK_REACTIONS.map(emoji => (
|
||||
<button key={emoji} onClick={() => onReact(emoji)} style={{
|
||||
width: 30, height: 30, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'none', border: 'none', cursor: 'pointer', borderRadius: '50%',
|
||||
padding: 3, transition: 'transform 0.1s, background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)'; e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = 'none' }}
|
||||
>
|
||||
<TwemojiImg emoji={emoji} size={18} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Message Text with clickable URLs ── */
|
||||
interface MessageTextProps {
|
||||
text: string
|
||||
}
|
||||
|
||||
function MessageText({ text }: MessageTextProps) {
|
||||
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(
|
||||
<a key={i} href={urls[i]} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline', textUnderlineOffset: 2, opacity: 0.85 }}>
|
||||
{urls[i]}
|
||||
</a>
|
||||
)
|
||||
})
|
||||
return <>{result}</>
|
||||
}
|
||||
|
||||
/* ── Link Preview ── */
|
||||
const URL_REGEX = /https?:\/\/[^\s<>"']+/g
|
||||
const previewCache = {}
|
||||
|
||||
interface LinkPreviewProps {
|
||||
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])
|
||||
|
||||
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 (loading || !data || (!data.title && !data.description && !data.image)) return null
|
||||
|
||||
const domain = (() => { try { return new URL(url).hostname.replace('www.', '') } catch { return '' } })()
|
||||
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" style={{
|
||||
display: 'block', textDecoration: 'none', marginTop: 6, borderRadius: 12, overflow: 'hidden',
|
||||
border: own ? '1px solid rgba(255,255,255,0.15)' : '1px solid var(--border-faint)',
|
||||
background: own ? 'rgba(255,255,255,0.1)' : 'var(--bg-secondary)',
|
||||
maxWidth: 280, transition: 'opacity 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.85'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||
>
|
||||
{data.image && (
|
||||
<img src={data.image} alt="" style={{ width: '100%', height: 140, objectFit: 'cover', display: 'block' }}
|
||||
onError={e => e.target.style.display = 'none'} />
|
||||
)}
|
||||
<div style={{ padding: '8px 10px' }}>
|
||||
{domain && (
|
||||
<div style={{ fontSize: 10, fontWeight: 600, color: own ? 'rgba(255,255,255,0.5)' : 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, marginBottom: 2 }}>
|
||||
{data.site_name || domain}
|
||||
</div>
|
||||
)}
|
||||
{data.title && (
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: own ? '#fff' : 'var(--text-primary)', lineHeight: 1.3, marginBottom: 2, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{data.title}
|
||||
</div>
|
||||
)}
|
||||
{data.description && (
|
||||
<div style={{ fontSize: 11, color: own ? 'rgba(255,255,255,0.7)' : 'var(--text-muted)', lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{data.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Reaction Badge with NOMAD tooltip ── */
|
||||
interface ReactionBadgeProps {
|
||||
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(', ')
|
||||
|
||||
return (
|
||||
<>
|
||||
<button ref={ref} onClick={onReact}
|
||||
onMouseEnter={() => {
|
||||
if (ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect()
|
||||
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
|
||||
}
|
||||
setHover(true)
|
||||
}}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 2, padding: '1px 3px',
|
||||
borderRadius: 99, border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
background: 'transparent', transition: 'transform 0.1s',
|
||||
}}
|
||||
>
|
||||
<TwemojiImg emoji={reaction.emoji} size={16} />
|
||||
{reaction.count > 1 && <span style={{ fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', minWidth: 8 }}>{reaction.count}</span>}
|
||||
</button>
|
||||
{hover && names && ReactDOM.createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
||||
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
|
||||
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
}}>
|
||||
{names}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
import { useCollabChat } from './useCollabChat'
|
||||
import { ChatMessages } from './CollabChatMessages'
|
||||
import { EmojiPicker } from './CollabChatEmojiPicker'
|
||||
import { ReactionMenu } from './CollabChatReactionMenu'
|
||||
|
||||
/* ── Main Component ── */
|
||||
interface CollabChatProps {
|
||||
@@ -353,173 +13,8 @@ interface CollabChatProps {
|
||||
}
|
||||
|
||||
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 [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<ReturnType<typeof setTimeout>[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
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 scrollToBottom = useCallback((behavior = 'auto') => {
|
||||
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
|
||||
}, [])
|
||||
|
||||
/* ── 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])
|
||||
|
||||
/* ── load more ── */
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
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 })
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
addListener(handler)
|
||||
return () => removeListener(handler)
|
||||
}, [tripId, scrollToBottom])
|
||||
|
||||
/* ── auto-resize textarea ── */
|
||||
const handleTextChange = useCallback((e) => {
|
||||
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'
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── send ── */
|
||||
const handleSend = useCallback(async () => {
|
||||
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)
|
||||
if (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])
|
||||
|
||||
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 () => {
|
||||
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 {
|
||||
const data = await collabApi.reactMessage(tripId, msgId, emoji)
|
||||
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, reactions: data.reactions } : m))
|
||||
} catch {}
|
||||
}, [tripId])
|
||||
|
||||
const handleEmojiSelect = useCallback((emoji) => {
|
||||
setText(prev => prev + emoji)
|
||||
textareaRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
/* ── Loading ── */
|
||||
const S = useCollabChat(tripId, currentUser)
|
||||
const { t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } = S
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
@@ -528,247 +23,11 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Main ── */
|
||||
return (
|
||||
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0, height: '100%' }}>
|
||||
{/* Messages */}
|
||||
{messages.length === 0 ? (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8, color: 'var(--text-faint)', padding: 32 }}>
|
||||
<MessageCircle size={40} strokeWidth={1.2} style={{ opacity: 0.4 }} />
|
||||
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('collab.chat.empty')}</span>
|
||||
<span style={{ fontSize: 12, opacity: 0.6 }}>{t('collab.chat.emptyDesc') || ''}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div ref={scrollRef} onScroll={checkAtBottom} className="chat-scroll" style={{
|
||||
flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '8px 14px 4px', WebkitOverflowScrolling: 'touch',
|
||||
display: 'flex', flexDirection: 'column', gap: 1,
|
||||
}}>
|
||||
{hasMore && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0 10px' }}>
|
||||
<button onClick={handleLoadMore} disabled={loadingMore} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, fontWeight: 600,
|
||||
color: 'var(--text-muted)', background: 'var(--bg-secondary)', border: '1px solid var(--border-faint)',
|
||||
borderRadius: 99, padding: '5px 14px', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<ChevronUp size={13} />
|
||||
{loadingMore ? '...' : t('collab.chat.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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
|
||||
// Deleted message placeholder
|
||||
if (msg._deleted) {
|
||||
return (
|
||||
<React.Fragment key={msg.id}>
|
||||
{showDate && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99, letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
||||
{formatDateSeparator(msg.created_at, t)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontStyle: 'italic' }}>
|
||||
{msg.username} {t('collab.chat.deletedMessage') || 'deleted a message'} · {formatTime(msg.created_at, is12h)}
|
||||
</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
// Bubble border radius — iMessage style tails
|
||||
const br = own
|
||||
? `18px 18px ${isLastInGroup ? '4px' : '18px'} 18px`
|
||||
: `18px 18px 18px ${isLastInGroup ? '4px' : '18px'}`
|
||||
|
||||
return (
|
||||
<React.Fragment key={msg.id}>
|
||||
{/* Date separator */}
|
||||
{showDate && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
|
||||
background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99,
|
||||
letterSpacing: 0.3, textTransform: 'uppercase',
|
||||
}}>
|
||||
{formatDateSeparator(msg.created_at, t)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: 'flex', alignItems: own ? 'flex-end' : 'flex-start',
|
||||
flexDirection: own ? 'row-reverse' : 'row',
|
||||
gap: 6, marginTop: isNewGroup ? 10 : 1,
|
||||
paddingLeft: own ? 40 : 0, paddingRight: own ? 0 : 40,
|
||||
transition: 'transform 0.3s ease, opacity 0.3s ease, max-height 0.3s ease',
|
||||
...(deletingIds.has(msg.id) ? { transform: 'scale(0.3)', opacity: 0, maxHeight: 0, marginTop: 0, overflow: 'hidden' } : {}),
|
||||
}}>
|
||||
{/* Avatar slot for others */}
|
||||
{!own && (
|
||||
<div style={{ width: 28, flexShrink: 0, alignSelf: 'flex-end' }}>
|
||||
{showAvatar && (
|
||||
msg.user_avatar ? (
|
||||
<img src={msg.user_avatar} alt="" style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 11, fontWeight: 700, color: 'var(--text-muted)',
|
||||
}}>
|
||||
{(msg.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: own ? 'flex-end' : 'flex-start', maxWidth: '78%', minWidth: 0 }}>
|
||||
{/* Username for others at group start */}
|
||||
{!own && isNewGroup && (
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 2, paddingLeft: 4 }}>
|
||||
{msg.username}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Bubble */}
|
||||
<div
|
||||
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
|
||||
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.currentTarget.dataset.lastTap = now
|
||||
}}
|
||||
>
|
||||
{bigEmoji ? (
|
||||
<div style={{ fontSize: 40, lineHeight: 1.2, padding: '2px 0' }}>
|
||||
{msg.text}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
background: own ? '#007AFF' : 'var(--bg-secondary)',
|
||||
color: own ? '#fff' : 'var(--text-primary)',
|
||||
borderRadius: br, padding: hasReply ? '4px 4px 8px 4px' : '8px 14px',
|
||||
fontSize: 14, lineHeight: 1.4, wordBreak: 'break-word', whiteSpace: 'pre-wrap',
|
||||
}}>
|
||||
{/* Inline reply quote */}
|
||||
{hasReply && (
|
||||
<div style={{
|
||||
padding: '5px 10px', marginBottom: 4, borderRadius: 12,
|
||||
background: own ? 'rgba(255,255,255,0.15)' : 'var(--bg-tertiary)',
|
||||
fontSize: 12, lineHeight: 1.3,
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, fontSize: 11, opacity: 0.7, marginBottom: 1 }}>
|
||||
{msg.reply_username || ''}
|
||||
</div>
|
||||
<div style={{ opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{(msg.reply_text || '').slice(0, 80)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasReply ? (
|
||||
<div style={{ padding: '0 10px 4px' }}><MessageText text={msg.text} /></div>
|
||||
) : <MessageText text={msg.text} />}
|
||||
{(msg.text.match(URL_REGEX) || []).slice(0, 1).map(url => (
|
||||
<LinkPreview key={url} url={url} tripId={tripId} own={own} onLoad={() => { if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover actions */}
|
||||
<div style={{
|
||||
position: 'absolute', top: -14,
|
||||
display: 'flex', gap: 2,
|
||||
opacity: hoveredId === msg.id ? 1 : 0,
|
||||
pointerEvents: hoveredId === msg.id ? 'auto' : 'none',
|
||||
transition: 'opacity .1s',
|
||||
...(own ? { left: -6 } : { right: -6 }),
|
||||
}}>
|
||||
<button onClick={() => setReplyTo(msg)} title={t('collab.chat.reply')} style={{
|
||||
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
||||
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', transition: 'transform 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)' }}
|
||||
>
|
||||
<Reply size={11} />
|
||||
</button>
|
||||
{own && canEdit && (
|
||||
<button onClick={() => handleDelete(msg.id)} title={t('common.delete')} style={{
|
||||
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
||||
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', transition: 'transform 0.12s, background 0.15s, color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)'; e.currentTarget.style.background = '#ef4444'; e.currentTarget.style.color = '#fff' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = 'var(--accent)'; e.currentTarget.style.color = 'var(--accent-text)' }}
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reactions — iMessage style floating badge */}
|
||||
{msg.reactions?.length > 0 && (
|
||||
<div style={{
|
||||
display: 'flex', gap: 3, marginTop: -6, marginBottom: 4,
|
||||
justifyContent: own ? 'flex-end' : 'flex-start',
|
||||
paddingLeft: own ? 0 : 8, paddingRight: own ? 8 : 0,
|
||||
position: 'relative', zIndex: 1,
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 2, padding: '3px 6px',
|
||||
borderRadius: 99, background: 'var(--bg-card)',
|
||||
boxShadow: '0 1px 6px rgba(0,0,0,0.12)', border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
{msg.reactions.map(r => {
|
||||
const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id))
|
||||
return (
|
||||
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => { if (canEdit) handleReact(msg.id, r.emoji) }} />
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamp — only on last message of group */}
|
||||
{isLastInGroup && (
|
||||
<span style={{ fontSize: 9, color: 'var(--text-faint)', marginTop: 2, padding: '0 4px' }}>
|
||||
{formatTime(msg.created_at, is12h)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ChatMessages {...S} />
|
||||
{/* Composer */}
|
||||
<div style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)', background: 'var(--bg-card)' }} className="pb-3">
|
||||
<div style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)' }} className="pb-3 bg-surface-card">
|
||||
{/* Reply preview */}
|
||||
{replyTo && (
|
||||
<div style={{
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface ChatReaction {
|
||||
emoji: string
|
||||
count: number
|
||||
users: { id: number; username: string }[]
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: number
|
||||
trip_id: number
|
||||
user_id: number
|
||||
text: string
|
||||
reply_to_id: number | null
|
||||
reactions: ChatReaction[]
|
||||
created_at: string
|
||||
user?: { username: string; avatar_url: string | null }
|
||||
reply_to?: ChatMessage | null
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { EMOJI_CATEGORIES } from './CollabChat.constants'
|
||||
import { TwemojiImg } from './CollabChatTwemojiImg'
|
||||
|
||||
/* ── Emoji Picker ── */
|
||||
interface EmojiPickerProps {
|
||||
onSelect: (emoji: string) => void
|
||||
onClose: () => void
|
||||
anchorRef: React.RefObject<HTMLElement | null>
|
||||
containerRef: React.RefObject<HTMLElement | null>
|
||||
}
|
||||
|
||||
export function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: EmojiPickerProps) {
|
||||
const [cat, setCat] = useState(Object.keys(EMOJI_CATEGORIES)[0])
|
||||
const ref = useRef(null)
|
||||
|
||||
const getPos = () => {
|
||||
const container = containerRef?.current
|
||||
const anchor = anchorRef?.current
|
||||
if (container && anchor) {
|
||||
const cRect = container.getBoundingClientRect()
|
||||
const aRect = anchor.getBoundingClientRect()
|
||||
return { bottom: window.innerHeight - aRect.top + 16, left: cRect.left + cRect.width / 2 - 140 }
|
||||
}
|
||||
return { bottom: 80, left: 0 }
|
||||
}
|
||||
const pos = getPos()
|
||||
|
||||
useEffect(() => {
|
||||
const close = (e) => {
|
||||
if (ref.current && ref.current.contains(e.target)) return
|
||||
if (anchorRef?.current && anchorRef.current.contains(e.target)) return
|
||||
onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', close)
|
||||
return () => document.removeEventListener('mousedown', close)
|
||||
}, [onClose, anchorRef])
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div ref={ref} style={{
|
||||
position: 'fixed', bottom: pos.bottom, left: pos.left, zIndex: 10000,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-faint)', borderRadius: 16,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.18)', width: 280, overflow: 'hidden',
|
||||
}}>
|
||||
{/* Category tabs */}
|
||||
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-faint)', padding: '6px 8px', gap: 2 }}>
|
||||
{Object.keys(EMOJI_CATEGORIES).map(c => (
|
||||
<button key={c} onClick={() => setCat(c)} style={{
|
||||
flex: 1, padding: '4px 0', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||
background: cat === c ? 'var(--bg-hover)' : 'transparent',
|
||||
color: 'var(--text-primary)', fontSize: 10, fontWeight: 600, fontFamily: 'inherit',
|
||||
}}>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Emoji grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(10, 1fr)', gap: 2, padding: 8 }}>
|
||||
{EMOJI_CATEGORIES[cat].map((emoji, i) => (
|
||||
<button key={i} onClick={() => onSelect(emoji)} style={{
|
||||
width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'none', border: 'none', cursor: 'pointer', borderRadius: 6,
|
||||
padding: 2, transition: 'transform 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.transform = 'scale(1.2)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'none'; e.currentTarget.style.transform = 'scale(1)' }}
|
||||
>
|
||||
<TwemojiImg emoji={emoji} size={20} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { collabApi } from '../../api/client'
|
||||
|
||||
/* ── Link Preview ── */
|
||||
const previewCache = {}
|
||||
|
||||
interface LinkPreviewProps {
|
||||
url: string
|
||||
tripId: number
|
||||
own: boolean
|
||||
onLoad: (() => void) | undefined
|
||||
}
|
||||
|
||||
export function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) {
|
||||
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 (loading || !data || (!data.title && !data.description && !data.image)) return null
|
||||
|
||||
const domain = (() => { try { return new URL(url).hostname.replace('www.', '') } catch { return '' } })()
|
||||
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" style={{
|
||||
display: 'block', textDecoration: 'none', marginTop: 6, borderRadius: 12, overflow: 'hidden',
|
||||
border: own ? '1px solid rgba(255,255,255,0.15)' : '1px solid var(--border-faint)',
|
||||
background: own ? 'rgba(255,255,255,0.1)' : 'var(--bg-secondary)',
|
||||
maxWidth: 280, transition: 'opacity 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.85'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||
>
|
||||
{data.image && (
|
||||
<img src={data.image} alt="" style={{ width: '100%', height: 140, objectFit: 'cover', display: 'block' }}
|
||||
onError={e => e.currentTarget.style.display = 'none'} />
|
||||
)}
|
||||
<div style={{ padding: '8px 10px' }}>
|
||||
{domain && (
|
||||
<div style={{ fontSize: 10, fontWeight: 600, color: own ? 'rgba(255,255,255,0.5)' : 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, marginBottom: 2 }}>
|
||||
{data.site_name || domain}
|
||||
</div>
|
||||
)}
|
||||
{data.title && (
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: own ? '#fff' : 'var(--text-primary)', lineHeight: 1.3, marginBottom: 2, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{data.title}
|
||||
</div>
|
||||
)}
|
||||
{data.description && (
|
||||
<div style={{ fontSize: 11, color: own ? 'rgba(255,255,255,0.7)' : 'var(--text-muted)', lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{data.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { URL_REGEX } from './CollabChat.constants'
|
||||
|
||||
/* ── Message Text with clickable URLs ── */
|
||||
interface MessageTextProps {
|
||||
text: string
|
||||
}
|
||||
|
||||
export function MessageText({ text }: MessageTextProps) {
|
||||
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(
|
||||
<a key={i} href={urls[i]} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline', textUnderlineOffset: 2, opacity: 0.85 }}>
|
||||
{urls[i]}
|
||||
</a>
|
||||
)
|
||||
})
|
||||
return <>{result}</>
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import React from 'react'
|
||||
import { Trash2, Reply, ChevronUp, MessageCircle } from 'lucide-react'
|
||||
import { URL_REGEX } from './CollabChat.constants'
|
||||
import { formatTime, formatDateSeparator, shouldShowDateSeparator } from './CollabChat.helpers'
|
||||
import { MessageText } from './CollabChatMessageText'
|
||||
import { LinkPreview } from './CollabChatLinkPreview'
|
||||
import { ReactionBadge } from './CollabChatReactionBadge'
|
||||
|
||||
export function ChatMessages(props: any) {
|
||||
const { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } = props
|
||||
return (
|
||||
<>
|
||||
{/* Messages */}
|
||||
{messages.length === 0 ? (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8, color: 'var(--text-faint)', padding: 32 }}>
|
||||
<MessageCircle size={40} strokeWidth={1.2} style={{ opacity: 0.4 }} />
|
||||
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('collab.chat.empty')}</span>
|
||||
<span style={{ fontSize: 12, opacity: 0.6 }}>{t('collab.chat.emptyDesc') || ''}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div ref={scrollRef} onScroll={checkAtBottom} className="chat-scroll" style={{
|
||||
flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '8px 14px 4px', WebkitOverflowScrolling: 'touch',
|
||||
display: 'flex', flexDirection: 'column', gap: 1,
|
||||
}}>
|
||||
{hasMore && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0 10px' }}>
|
||||
<button onClick={handleLoadMore} disabled={loadingMore} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, fontWeight: 600,
|
||||
color: 'var(--text-muted)', background: 'var(--bg-secondary)', border: '1px solid var(--border-faint)',
|
||||
borderRadius: 99, padding: '5px 14px', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<ChevronUp size={13} />
|
||||
{loadingMore ? '...' : t('collab.chat.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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
|
||||
// Deleted message placeholder
|
||||
if (msg._deleted) {
|
||||
return (
|
||||
<React.Fragment key={msg.id}>
|
||||
{showDate && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99, letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
||||
{formatDateSeparator(msg.created_at, t)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontStyle: 'italic' }}>
|
||||
{msg.username} {t('collab.chat.deletedMessage') || 'deleted a message'} · {formatTime(msg.created_at, is12h)}
|
||||
</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
// Bubble border radius — iMessage style tails
|
||||
const br = own
|
||||
? `18px 18px ${isLastInGroup ? '4px' : '18px'} 18px`
|
||||
: `18px 18px 18px ${isLastInGroup ? '4px' : '18px'}`
|
||||
|
||||
return (
|
||||
<React.Fragment key={msg.id}>
|
||||
{/* Date separator */}
|
||||
{showDate && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
|
||||
background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99,
|
||||
letterSpacing: 0.3, textTransform: 'uppercase',
|
||||
}}>
|
||||
{formatDateSeparator(msg.created_at, t)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: 'flex', alignItems: own ? 'flex-end' : 'flex-start',
|
||||
flexDirection: own ? 'row-reverse' : 'row',
|
||||
gap: 6, marginTop: isNewGroup ? 10 : 1,
|
||||
paddingLeft: own ? 40 : 0, paddingRight: own ? 0 : 40,
|
||||
transition: 'transform 0.3s ease, opacity 0.3s ease, max-height 0.3s ease',
|
||||
...(deletingIds.has(msg.id) ? { transform: 'scale(0.3)', opacity: 0, maxHeight: 0, marginTop: 0, overflow: 'hidden' } : {}),
|
||||
}}>
|
||||
{/* Avatar slot for others */}
|
||||
{!own && (
|
||||
<div style={{ width: 28, flexShrink: 0, alignSelf: 'flex-end' }}>
|
||||
{showAvatar && (
|
||||
msg.user_avatar ? (
|
||||
<img src={msg.user_avatar} alt="" style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 11, fontWeight: 700, color: 'var(--text-muted)',
|
||||
}}>
|
||||
{(msg.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: own ? 'flex-end' : 'flex-start', maxWidth: '78%', minWidth: 0 }}>
|
||||
{/* Username for others at group start */}
|
||||
{!own && isNewGroup && (
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 2, paddingLeft: 4 }}>
|
||||
{msg.username}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Bubble */}
|
||||
<div
|
||||
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 = Number(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.currentTarget.dataset.lastTap = String(now)
|
||||
}}
|
||||
>
|
||||
{bigEmoji ? (
|
||||
<div style={{ fontSize: 40, lineHeight: 1.2, padding: '2px 0' }}>
|
||||
{msg.text}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
background: own ? '#007AFF' : 'var(--bg-secondary)',
|
||||
color: own ? '#fff' : 'var(--text-primary)',
|
||||
borderRadius: br, padding: hasReply ? '4px 4px 8px 4px' : '8px 14px',
|
||||
fontSize: 14, lineHeight: 1.4, wordBreak: 'break-word', whiteSpace: 'pre-wrap',
|
||||
}}>
|
||||
{/* Inline reply quote */}
|
||||
{hasReply && (
|
||||
<div style={{
|
||||
padding: '5px 10px', marginBottom: 4, borderRadius: 12,
|
||||
background: own ? 'rgba(255,255,255,0.15)' : 'var(--bg-tertiary)',
|
||||
fontSize: 12, lineHeight: 1.3,
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, fontSize: 11, opacity: 0.7, marginBottom: 1 }}>
|
||||
{msg.reply_username || ''}
|
||||
</div>
|
||||
<div style={{ opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{(msg.reply_text || '').slice(0, 80)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasReply ? (
|
||||
<div style={{ padding: '0 10px 4px' }}><MessageText text={msg.text} /></div>
|
||||
) : <MessageText text={msg.text} />}
|
||||
{(msg.text.match(URL_REGEX) || []).slice(0, 1).map(url => (
|
||||
<LinkPreview key={url} url={url} tripId={tripId} own={own} onLoad={() => { if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover actions */}
|
||||
<div style={{
|
||||
position: 'absolute', top: -14,
|
||||
display: 'flex', gap: 2,
|
||||
opacity: hoveredId === msg.id ? 1 : 0,
|
||||
pointerEvents: hoveredId === msg.id ? 'auto' : 'none',
|
||||
transition: 'opacity .1s',
|
||||
...(own ? { left: -6 } : { right: -6 }),
|
||||
}}>
|
||||
<button onClick={() => setReplyTo(msg)} title={t('collab.chat.reply')} style={{
|
||||
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
||||
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', transition: 'transform 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)' }}
|
||||
>
|
||||
<Reply size={11} />
|
||||
</button>
|
||||
{own && canEdit && (
|
||||
<button onClick={() => handleDelete(msg.id)} title={t('common.delete')} style={{
|
||||
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
||||
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', transition: 'transform 0.12s, background 0.15s, color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)'; e.currentTarget.style.background = '#ef4444'; e.currentTarget.style.color = '#fff' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = 'var(--accent)'; e.currentTarget.style.color = 'var(--accent-text)' }}
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reactions — iMessage style floating badge */}
|
||||
{msg.reactions?.length > 0 && (
|
||||
<div style={{
|
||||
display: 'flex', gap: 3, marginTop: -6, marginBottom: 4,
|
||||
justifyContent: own ? 'flex-end' : 'flex-start',
|
||||
paddingLeft: own ? 0 : 8, paddingRight: own ? 8 : 0,
|
||||
position: 'relative', zIndex: 1,
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 2, padding: '3px 6px',
|
||||
borderRadius: 99, background: 'var(--bg-card)',
|
||||
boxShadow: '0 1px 6px rgba(0,0,0,0.12)', border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
{msg.reactions.map(r => {
|
||||
const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id))
|
||||
return (
|
||||
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => { if (canEdit) handleReact(msg.id, r.emoji) }} />
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamp — only on last message of group */}
|
||||
{isLastInGroup && (
|
||||
<span style={{ fontSize: 9, color: 'var(--text-faint)', marginTop: 2, padding: '0 4px' }}>
|
||||
{formatTime(msg.created_at, is12h)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { TwemojiImg } from './CollabChatTwemojiImg'
|
||||
import type { ChatReaction } from './CollabChat.types'
|
||||
|
||||
/* ── Reaction Badge with NOMAD tooltip ── */
|
||||
interface ReactionBadgeProps {
|
||||
reaction: ChatReaction
|
||||
currentUserId: number
|
||||
onReact: () => void
|
||||
}
|
||||
|
||||
export 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(', ')
|
||||
|
||||
return (
|
||||
<>
|
||||
<button ref={ref} onClick={onReact}
|
||||
onMouseEnter={() => {
|
||||
if (ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect()
|
||||
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
|
||||
}
|
||||
setHover(true)
|
||||
}}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 2, padding: '1px 3px',
|
||||
borderRadius: 99, border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
background: 'transparent', transition: 'transform 0.1s',
|
||||
}}
|
||||
>
|
||||
<TwemojiImg emoji={reaction.emoji} size={16} />
|
||||
{reaction.count > 1 && <span style={{ fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', minWidth: 8 }}>{reaction.count}</span>}
|
||||
</button>
|
||||
{hover && names && ReactDOM.createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
||||
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
|
||||
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
}}>
|
||||
{names}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { QUICK_REACTIONS } from './CollabChat.constants'
|
||||
import { TwemojiImg } from './CollabChatTwemojiImg'
|
||||
|
||||
/* ── Reaction Quick Menu (right-click) ── */
|
||||
interface ReactionMenuProps {
|
||||
x: number
|
||||
y: number
|
||||
onReact: (emoji: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ReactionMenu({ x, y, onReact, onClose }: ReactionMenuProps) {
|
||||
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])
|
||||
|
||||
// Clamp to viewport
|
||||
const menuWidth = 156
|
||||
const clampedLeft = Math.max(menuWidth / 2 + 8, Math.min(x, window.innerWidth - menuWidth / 2 - 8))
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{
|
||||
position: 'fixed', top: y - 80, left: clampedLeft, transform: 'translateX(-50%)', zIndex: 10000,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-faint)', borderRadius: 16,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.18)', padding: '6px 8px',
|
||||
display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 2, width: menuWidth,
|
||||
}}>
|
||||
{QUICK_REACTIONS.map(emoji => (
|
||||
<button key={emoji} onClick={() => onReact(emoji)} style={{
|
||||
width: 30, height: 30, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'none', border: 'none', cursor: 'pointer', borderRadius: '50%',
|
||||
padding: 3, transition: 'transform 0.1s, background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)'; e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = 'none' }}
|
||||
>
|
||||
<TwemojiImg emoji={emoji} size={18} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useState } from 'react'
|
||||
import { emojiToCodepoint } from './CollabChat.helpers'
|
||||
|
||||
export function TwemojiImg({ emoji, size = 20, style = {} }) {
|
||||
const cp = emojiToCodepoint(emoji)
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
if (failed) {
|
||||
return <span style={{ fontSize: size, lineHeight: 1, display: 'inline-block', verticalAlign: 'middle', ...style }}>{emoji}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={`https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/${cp}.png`}
|
||||
alt={emoji}
|
||||
draggable={false}
|
||||
style={{ width: size, height: size, display: 'inline-block', verticalAlign: 'middle', ...style }}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export const FONT = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif"
|
||||
|
||||
export const NOTE_COLORS = [
|
||||
{ value: '#6366f1', label: 'Indigo' },
|
||||
{ value: '#ef4444', label: 'Red' },
|
||||
{ value: '#f59e0b', label: 'Amber' },
|
||||
{ value: '#10b981', label: 'Emerald' },
|
||||
{ value: '#3b82f6', label: 'Blue' },
|
||||
{ value: '#8b5cf6', label: 'Violet' },
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
// Pure formatting helper for note timestamps. Falls back to translated
|
||||
// relative labels for recent timestamps and a localized short date beyond a week.
|
||||
export 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.getTime() - d.getTime()
|
||||
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' })
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
export interface NoteFile {
|
||||
id: number
|
||||
filename: string
|
||||
original_name: string
|
||||
mime_type: string
|
||||
file_size?: number | null
|
||||
url?: string
|
||||
}
|
||||
|
||||
export 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[]
|
||||
// Wire field: collabService embeds note files as `attachments` (with url).
|
||||
attachments?: NoteFile[]
|
||||
}
|
||||
|
||||
export interface NoteAuthor {
|
||||
username: string
|
||||
avatar?: string | null
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
|
||||
export function AuthedImg({ src, style, onClick, onMouseEnter, onMouseLeave, alt }: { src: string; style?: React.CSSProperties; onClick?: () => void; onMouseEnter?: React.MouseEventHandler<HTMLImageElement>; onMouseLeave?: React.MouseEventHandler<HTMLImageElement>; alt?: string }) {
|
||||
const [authSrc, setAuthSrc] = useState('')
|
||||
useEffect(() => {
|
||||
getAuthUrl(src, 'download').then(setAuthSrc)
|
||||
}, [src])
|
||||
return authSrc ? <img src={authSrc} alt={alt} style={style} onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} /> : null
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { Trash2, Pin, PinOff, Pencil, Maximize2 } from 'lucide-react'
|
||||
import { FONT } from './CollabNotes.constants'
|
||||
import { AuthedImg } from './CollabNotesAuthedImg'
|
||||
import { UserAvatar } from './CollabNotesUserAvatar'
|
||||
import { WebsiteThumbnail } from './CollabNotesWebsiteThumbnail'
|
||||
import type { CollabNote, NoteFile } from './CollabNotes.types'
|
||||
import type { User } from '../../types'
|
||||
|
||||
// ── Note Card ───────────────────────────────────────────────────────────────
|
||||
interface NoteCardProps {
|
||||
note: CollabNote
|
||||
currentUser: User
|
||||
canEdit: boolean
|
||||
onUpdate: (noteId: number, data: Partial<CollabNote>) => Promise<void>
|
||||
onDelete: (noteId: number) => Promise<void>
|
||||
onEdit: (note: CollabNote) => void
|
||||
onView: (note: CollabNote) => void
|
||||
onPreviewFile: (file: NoteFile) => void
|
||||
getCategoryColor: (category: string) => string
|
||||
tripId: number
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdit, onView, onPreviewFile, getCategoryColor, tripId, t }: NoteCardProps) {
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
const author = note.author || note.user || { username: note.username, avatar: note.avatar_url || (note.avatar ? `/uploads/avatars/${note.avatar}` : null) }
|
||||
const color = getCategoryColor ? getCategoryColor(note.category) : (note.color || '#6366f1')
|
||||
|
||||
const handleTogglePin = useCallback(() => {
|
||||
onUpdate(note.id, { pinned: !note.pinned })
|
||||
}, [note.id, note.pinned, onUpdate])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
onDelete(note.id)
|
||||
}, [note.id, onDelete])
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
border: `1px solid ${note.pinned ? color + '40' : color + '25'}`,
|
||||
background: note.pinned ? `${color}08` : 'var(--bg-card)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
fontFamily: FONT,
|
||||
transition: 'transform 0.12s, box-shadow 0.12s',
|
||||
...(hovered ? { transform: 'translateY(-1px)', boxShadow: '0 4px 16px rgba(0,0,0,0.08)' } : {}),
|
||||
}}
|
||||
>
|
||||
{/* Header bar — like reservation cards */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '7px 10px',
|
||||
background: `${color}0d`,
|
||||
}}>
|
||||
{!!note.pinned && <Pin size={9} color={color} style={{ flexShrink: 0 }} />}
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 5, overflow: 'hidden', flex: 1, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{note.title}
|
||||
</span>
|
||||
{note.category && (
|
||||
<span style={{ fontSize: 8, fontWeight: 600, color, background: `${color}18`, padding: '2px 6px', borderRadius: 99, flexShrink: 0, letterSpacing: '0.02em', textTransform: 'uppercase' }}>
|
||||
{note.category}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Hover actions in header */}
|
||||
{(
|
||||
<div style={{
|
||||
display: 'flex', gap: 2,
|
||||
}}>
|
||||
{note.content && (
|
||||
<button onClick={() => onView?.(note)} title={t('collab.notes.expand') || 'Expand'}
|
||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Maximize2 size={10} />
|
||||
</button>
|
||||
)}
|
||||
{canEdit && <button onClick={handleTogglePin} title={note.pinned ? t('collab.notes.unpin') : t('collab.notes.pin')}
|
||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = color}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
{note.pinned ? <PinOff size={10} /> : <Pin size={10} />}
|
||||
</button>}
|
||||
{canEdit && <button onClick={() => onEdit?.(note)} title={t('collab.notes.edit')}
|
||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={10} />
|
||||
</button>}
|
||||
{canEdit && <button onClick={handleDelete} title={t('collab.notes.delete')}
|
||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={10} />
|
||||
</button>}
|
||||
<div style={{ width: 1, height: 12, background: 'var(--border-faint)', flexShrink: 0, marginLeft: 1, marginRight: 1 }} />
|
||||
{/* Author avatar */}
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}
|
||||
onMouseEnter={e => { const tip = e.currentTarget.querySelector<HTMLElement>('[data-tip]'); if (tip) tip.style.opacity = '1' }}
|
||||
onMouseLeave={e => { const tip = e.currentTarget.querySelector<HTMLElement>('[data-tip]'); if (tip) tip.style.opacity = '0' }}>
|
||||
<UserAvatar user={author} size={16} />
|
||||
<div data-tip style={{
|
||||
position: 'absolute', bottom: '100%', left: '50%', transform: 'translateX(-50%)',
|
||||
marginBottom: 6, pointerEvents: 'none', opacity: 0, transition: 'opacity 0.12s',
|
||||
whiteSpace: 'nowrap', zIndex: 10,
|
||||
background: 'var(--bg-card)', color: 'var(--text-primary)',
|
||||
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
{author.username}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Card body */}
|
||||
<div style={{
|
||||
padding: '8px 12px 10px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
flex: 1,
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{note.content && (
|
||||
<div className="collab-note-md" style={{
|
||||
fontSize: 11.5, color: 'var(--text-muted)', lineHeight: 1.5, margin: 0,
|
||||
maxHeight: '4.5em', overflow: 'hidden',
|
||||
wordBreak: 'break-word', fontFamily: FONT,
|
||||
}}>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{note.content}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Right: website + attachment thumbnails */}
|
||||
{(note.website || (note.attachments?.length ?? 0) > 0) && (
|
||||
<div style={{ display: 'flex', gap: 6, flexShrink: 0, alignItems: 'flex-start' }}>
|
||||
{/* Website */}
|
||||
{note.website && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||
<span style={{ fontSize: 7, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3 }}>Link</span>
|
||||
<WebsiteThumbnail url={note.website} tripId={tripId} color={color} />
|
||||
</div>
|
||||
)}
|
||||
{/* Files */}
|
||||
{(note.attachments || []).length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||
<span style={{ fontSize: 7, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3 }}>{t('files.title')}</span>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(note.attachments || []).slice(0, note.website ? 1 : 2).map(a => {
|
||||
const isImage = a.mime_type?.startsWith('image/')
|
||||
const ext = (a.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
||||
return isImage ? (
|
||||
<AuthedImg key={a.id} src={a.url} alt={a.original_name}
|
||||
style={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 8, cursor: 'pointer', transition: 'transform 0.12s, box-shadow 0.12s' }}
|
||||
onClick={() => onPreviewFile?.(a)}
|
||||
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' }} />
|
||||
) : (
|
||||
<div key={a.id} title={a.original_name} onClick={() => onPreviewFile?.(a)}
|
||||
style={{
|
||||
width: 48, height: 48, borderRadius: 8, cursor: 'pointer',
|
||||
background: a.mime_type === 'application/pdf' ? '#ef44441a' : 'var(--bg-secondary)',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1,
|
||||
transition: 'transform 0.12s, box-shadow 0.12s',
|
||||
}}
|
||||
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' }}>
|
||||
<span style={{ fontSize: 9, fontWeight: 700, color: a.mime_type === 'application/pdf' ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{(note.attachments?.length || 0) > (note.website ? 1 : 2) && (
|
||||
<span style={{ fontSize: 8, color: 'var(--text-faint)', textAlign: 'center' }}>+{(note.attachments?.length || 0) - (note.website ? 1 : 2)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState } from 'react'
|
||||
import { Plus, Trash2, X } from 'lucide-react'
|
||||
import { FONT, NOTE_COLORS } from './CollabNotes.constants'
|
||||
import { EditableCatName } from './CollabNotesEditableCatName'
|
||||
|
||||
// ── Category Settings Modal ──────────────────────────────────────────────────
|
||||
interface CategorySettingsModalProps {
|
||||
onClose: () => void
|
||||
categories: string[]
|
||||
categoryColors: Record<string, string>
|
||||
onSave: (colors: Record<string, string>) => void
|
||||
onRenameCategory: (oldName: string, newName: string) => Promise<void>
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
export function CategorySettingsModal({ onClose, categories, categoryColors, onSave, onRenameCategory, t }: CategorySettingsModalProps) {
|
||||
const [localColors, setLocalColors] = useState({ ...categoryColors })
|
||||
const [renames, setRenames] = useState<Record<string, string>>({}) // { oldName: newName }
|
||||
const [newCatName, setNewCatName] = useState('')
|
||||
|
||||
const handleColorChange = (cat, color) => {
|
||||
setLocalColors(prev => ({ ...prev, [cat]: color }))
|
||||
}
|
||||
|
||||
const handleAddCategory = () => {
|
||||
if (!newCatName.trim() || localColors[newCatName.trim()]) return
|
||||
setLocalColors(prev => ({ ...prev, [newCatName.trim()]: NOTE_COLORS[Object.keys(prev).length % NOTE_COLORS.length].value }))
|
||||
setNewCatName('')
|
||||
}
|
||||
|
||||
const handleRemoveCategory = (cat) => {
|
||||
setLocalColors(prev => { const n = { ...prev }; delete n[cat]; return n })
|
||||
}
|
||||
|
||||
const handleRenameCategory = (oldName, newName) => {
|
||||
if (!newName.trim() || newName.trim() === oldName || localColors[newName.trim()]) return
|
||||
// Track rename for saving to DB later
|
||||
const originalName = Object.entries(renames).find(([, v]) => v === oldName)?.[0] || oldName
|
||||
setRenames(prev => ({ ...prev, [originalName]: newName.trim() }))
|
||||
setLocalColors(prev => {
|
||||
const n = {}
|
||||
for (const [k, v] of Object.entries(prev)) {
|
||||
n[k === oldName ? newName.trim() : k] = v
|
||||
}
|
||||
return n
|
||||
})
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
// Apply renames to notes in DB
|
||||
for (const [oldName, newName] of Object.entries(renames)) {
|
||||
if (oldName !== newName) await onRenameCategory(oldName, newName)
|
||||
}
|
||||
await onSave(localColors)
|
||||
onClose()
|
||||
}
|
||||
|
||||
// Merge existing categories from notes with saved colors
|
||||
const allCats = [...new Set([...categories, ...Object.keys(localColors)])]
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))',
|
||||
backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT,
|
||||
}} onClick={onClose}>
|
||||
<div style={{
|
||||
background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 420,
|
||||
maxHeight: '80vh', overflow: 'auto', border: '1px solid var(--border-faint)',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>
|
||||
{t('collab.notes.categorySettings') || 'Category Settings'}
|
||||
</h3>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Categories list */}
|
||||
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{allCats.length === 0 && (
|
||||
<p style={{ fontSize: 12, color: 'var(--text-faint)', textAlign: 'center', padding: 16 }}>
|
||||
{t('collab.notes.noCategoriesYet') || 'No categories yet'}
|
||||
</p>
|
||||
)}
|
||||
{allCats.map(cat => (
|
||||
<div key={cat} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
{/* Color swatches */}
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{NOTE_COLORS.map(c => (
|
||||
<button key={c.value} onClick={() => handleColorChange(cat, c.value)} style={{
|
||||
width: 20, height: 20, borderRadius: 6, background: c.value, border: 'none', cursor: 'pointer', padding: 0,
|
||||
outline: (localColors[cat] || NOTE_COLORS[0].value) === c.value ? '2px solid var(--text-primary)' : '2px solid transparent',
|
||||
outlineOffset: 1, transition: 'transform 0.1s',
|
||||
transform: (localColors[cat] || NOTE_COLORS[0].value) === c.value ? 'scale(1.1)' : 'scale(1)',
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
{/* Category name — editable */}
|
||||
<EditableCatName name={cat} onRename={(newName) => handleRenameCategory(cat, newName)} />
|
||||
{/* Delete */}
|
||||
<button onClick={() => handleRemoveCategory(cat)} style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 3, display: 'flex',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add new */}
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 4 }}>
|
||||
<input value={newCatName} onChange={e => setNewCatName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
|
||||
placeholder={t('collab.notes.newCategory')}
|
||||
style={{
|
||||
flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px',
|
||||
fontSize: 13, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none',
|
||||
}} />
|
||||
<button onClick={handleAddCategory} disabled={!newCatName.trim()} style={{
|
||||
background: newCatName.trim() ? 'var(--accent)' : 'var(--border-primary)', color: 'var(--accent-text)',
|
||||
border: 'none', borderRadius: 10, padding: '8px 14px', cursor: newCatName.trim() ? 'pointer' : 'default',
|
||||
display: 'flex', alignItems: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Save */}
|
||||
<button onClick={handleSave} style={{
|
||||
width: '100%', borderRadius: 99, padding: '9px 14px', background: 'var(--accent)', color: 'var(--accent-text)',
|
||||
fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', marginTop: 8,
|
||||
}}>
|
||||
{t('collab.notes.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
interface EditableCatNameProps {
|
||||
name: string
|
||||
onRename: (newName: string) => void
|
||||
}
|
||||
|
||||
export function EditableCatName({ name, onRename }: EditableCatNameProps) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [value, setValue] = useState(name)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
useEffect(() => { if (editing && inputRef.current) { inputRef.current.focus(); inputRef.current.select() } }, [editing])
|
||||
|
||||
const save = () => {
|
||||
setEditing(false)
|
||||
if (value.trim() && value.trim() !== name) onRename(value.trim())
|
||||
else setValue(name)
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return <input ref={inputRef} value={value} onChange={e => setValue(e.target.value)}
|
||||
onBlur={save} onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setValue(name); setEditing(false) } }}
|
||||
style={{ flex: 1, fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', border: '1px solid var(--border-primary)', borderRadius: 6, padding: '2px 8px', background: 'var(--bg-input)', fontFamily: 'inherit', outline: 'none' }} />
|
||||
}
|
||||
|
||||
return (
|
||||
<span onClick={() => { setValue(name); setEditing(true) }}
|
||||
style={{ flex: 1, fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', cursor: 'pointer', padding: '2px 0' }}
|
||||
title="Click to rename">
|
||||
{name}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, ExternalLink, Loader2 } from 'lucide-react'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import type { NoteFile } from './CollabNotes.types'
|
||||
|
||||
// ── File Preview Portal ─────────────────────────────────────────────────────
|
||||
interface FilePreviewPortalProps {
|
||||
file: NoteFile | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
||||
const [authUrl, setAuthUrl] = useState('')
|
||||
const rawUrl = file?.url || ''
|
||||
useEffect(() => {
|
||||
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/')
|
||||
|
||||
const openInNewTab = () => openFile(rawUrl).catch(() => {})
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} onClick={onClose}>
|
||||
{isImage ? (
|
||||
/* Image lightbox — floating controls */
|
||||
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
|
||||
{authUrl
|
||||
? <img src={authUrl} alt={file.original_name} style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} />
|
||||
: <Loader2 size={32} className="animate-spin text-[rgba(255,255,255,0.5)]" />
|
||||
}
|
||||
<div style={{ position: 'absolute', top: -36, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
|
||||
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>{file.original_name}</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><ExternalLink size={15} /></button>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}><X size={17} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Document viewer — card with header */
|
||||
<div style={{ width: '100%', maxWidth: 950, height: '94vh', display: 'flex', flexDirection: 'column', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{file.original_name}</span>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 3, fontSize: 11, color: 'var(--text-muted)', padding: 0 }}><ExternalLink size={13} /></button>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={18} /></button>
|
||||
</div>
|
||||
</div>
|
||||
{(isPdf || isTxt) ? (
|
||||
<object data={authUrl ? `${authUrl}#view=FitH` : ''} type={file.mime_type} style={{ flex: 1, width: '100%', border: 'none', background: '#fff' }} title={file.original_name}>
|
||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14, padding: 0 }}>Download</button>
|
||||
</p>
|
||||
</object>
|
||||
) : (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 40 }}>
|
||||
<button onClick={openInNewTab} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-primary)', textDecoration: 'underline', fontSize: 14, padding: 0 }}>Download {file.original_name}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useRef } from 'react'
|
||||
import { Plus, X } from 'lucide-react'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { FONT } from './CollabNotes.constants'
|
||||
import { AuthedImg } from './CollabNotesAuthedImg'
|
||||
import type { CollabNote } from './CollabNotes.types'
|
||||
|
||||
// ── New Note Modal (portal to body) ─────────────────────────────────────────
|
||||
interface NoteFormModalProps {
|
||||
onClose: () => void
|
||||
onSubmit: (data: { title: string; content: string; category: string | null; website: string | null; color?: string | null; _pendingFiles?: File[]; files?: File[] }) => Promise<void>
|
||||
onDeleteFile?: (noteId: number, fileId: number) => Promise<void>
|
||||
existingCategories: string[]
|
||||
categoryColors: Record<string, string>
|
||||
getCategoryColor: (category: string) => string
|
||||
note: CollabNote | null
|
||||
tripId: number
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
export 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 finalCategory = category
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!title.trim()) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await onSubmit({
|
||||
title: title.trim(),
|
||||
content: content.trim(),
|
||||
category: finalCategory || null,
|
||||
color: getCategoryColor(finalCategory),
|
||||
website: website.trim() || null,
|
||||
_pendingFiles: pendingFiles,
|
||||
})
|
||||
onClose()
|
||||
} catch {
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteAttachment = async (fileId) => {
|
||||
if (onDeleteFile && note) {
|
||||
await onDeleteFile(note.id, fileId)
|
||||
setExistingAttachments(prev => prev.filter(a => a.id !== fileId))
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = title.trim() && !submitting
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'var(--overlay-bg, rgba(0,0,0,0.35))',
|
||||
backdropFilter: 'blur(6px)',
|
||||
WebkitBackdropFilter: 'blur(6px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
padding: 16,
|
||||
fontFamily: FONT,
|
||||
}}
|
||||
>
|
||||
<form
|
||||
style={{
|
||||
background: 'var(--bg-card)',
|
||||
borderRadius: 16,
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto',
|
||||
border: '1px solid var(--border-faint)',
|
||||
}}
|
||||
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
|
||||
}
|
||||
}
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{/* Modal header */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '14px 16px 12px',
|
||||
borderBottom: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
<h3 style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
color: 'var(--text-primary)',
|
||||
margin: 0,
|
||||
fontFamily: FONT,
|
||||
}}>
|
||||
{isEdit ? t('collab.notes.edit') : t('collab.notes.new')}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--text-faint)',
|
||||
padding: 2,
|
||||
borderRadius: 6,
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal body */}
|
||||
<div style={{
|
||||
padding: '14px 16px 16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
}}>
|
||||
{/* Title */}
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-faint)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
marginBottom: 4,
|
||||
fontFamily: FONT,
|
||||
}}>
|
||||
{t('collab.notes.title')}
|
||||
</div>
|
||||
<input
|
||||
autoFocus
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder={t('collab.notes.titlePlaceholder')}
|
||||
style={{
|
||||
width: '100%',
|
||||
border: '1px solid var(--border-primary)',
|
||||
borderRadius: 10,
|
||||
padding: '8px 12px',
|
||||
fontSize: 13,
|
||||
background: 'var(--bg-input)',
|
||||
color: 'var(--text-primary)',
|
||||
fontFamily: 'inherit',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-faint)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
marginBottom: 4,
|
||||
fontFamily: FONT,
|
||||
}}>
|
||||
{t('collab.notes.contentPlaceholder')}
|
||||
</div>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={e => setContent(e.target.value)}
|
||||
placeholder={t('collab.notes.contentPlaceholder')}
|
||||
style={{
|
||||
width: '100%',
|
||||
border: '1px solid var(--border-primary)',
|
||||
borderRadius: 10,
|
||||
padding: '8px 12px',
|
||||
fontSize: 13,
|
||||
background: 'var(--bg-input)',
|
||||
color: 'var(--text-primary)',
|
||||
fontFamily: 'inherit',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
resize: 'vertical',
|
||||
minHeight: 180,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category pills */}
|
||||
<div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6, fontFamily: FONT }}>
|
||||
{t('collab.notes.category')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{allCategories.map(cat => {
|
||||
const c = getCategoryColor(cat)
|
||||
const active = category === cat
|
||||
return (
|
||||
<button key={cat} type="button" onClick={() => setCategory(cat)}
|
||||
style={{ padding: '4px 12px', borderRadius: 99, border: active ? `1.5px solid ${c}` : '1px solid var(--border-faint)', background: active ? `${c}18` : 'transparent', color: active ? c : 'var(--text-muted)', fontSize: 11, fontWeight: 600, cursor: 'pointer', fontFamily: FONT }}>
|
||||
{cat}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Website */}
|
||||
<div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
|
||||
{t('collab.notes.website')}
|
||||
</div>
|
||||
<input value={website} onChange={e => setWebsite(e.target.value)}
|
||||
placeholder={t('collab.notes.websitePlaceholder')}
|
||||
style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 13, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
|
||||
{/* File attachments */}
|
||||
{canUploadFiles && <div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
|
||||
{t('collab.notes.attachFiles')}
|
||||
</div>
|
||||
<input ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { const files = e.target.files; if (files?.length) setPendingFiles(prev => [...prev, ...Array.from(files)]); e.target.value = '' }} />
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{/* Existing attachments (edit mode) */}
|
||||
{existingAttachments.map(a => {
|
||||
const isImage = a.mime_type?.startsWith('image/')
|
||||
return (
|
||||
<div key={a.id} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
{isImage && <AuthedImg src={a.url} style={{ width: 18, height: 18, objectFit: 'cover', borderRadius: 3 }} />}
|
||||
{(a.original_name || '').length > 20 ? a.original_name.slice(0, 17) + '...' : a.original_name}
|
||||
<button type="button" onClick={() => handleDeleteAttachment(a.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#ef4444', padding: 0, display: 'flex' }}>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* New pending files */}
|
||||
{pendingFiles.map((f, i) => (
|
||||
<div key={`new-${i}`} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
{f.name.length > 20 ? f.name.slice(0, 17) + '...' : f.name}
|
||||
<button type="button" onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 0, display: 'flex' }}>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => fileRef.current?.click()}
|
||||
style={{ padding: '4px 10px', borderRadius: 8, border: '1px dashed var(--border-faint)', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', fontSize: 11, fontFamily: FONT, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<Plus size={11} /> {t('files.attach') || 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
style={{
|
||||
width: '100%',
|
||||
borderRadius: 99,
|
||||
padding: '7px 14px',
|
||||
background: canSubmit ? 'var(--accent)' : 'var(--border-primary)',
|
||||
color: canSubmit ? 'var(--accent-text)' : 'var(--text-faint)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
fontFamily: FONT,
|
||||
border: 'none',
|
||||
cursor: canSubmit ? 'pointer' : 'default',
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{submitting ? '...' : isEdit ? t('collab.notes.save') : t('collab.notes.create')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { FONT } from './CollabNotes.constants'
|
||||
import type { NoteAuthor } from './CollabNotes.types'
|
||||
|
||||
// ── Avatar ──────────────────────────────────────────────────────────────────
|
||||
interface UserAvatarProps {
|
||||
user: NoteAuthor | null
|
||||
size?: number
|
||||
}
|
||||
|
||||
export function UserAvatar({ user, size = 14 }: UserAvatarProps) {
|
||||
if (!user) return null
|
||||
if (user.avatar) {
|
||||
return (
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.username}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
objectFit: 'cover',
|
||||
flexShrink: 0,
|
||||
background: 'var(--bg-tertiary)',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const initials = (user.username || '?').slice(0, 1)
|
||||
return (
|
||||
<div style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--bg-tertiary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: size * 0.45,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-faint)',
|
||||
flexShrink: 0,
|
||||
textTransform: 'uppercase',
|
||||
fontFamily: FONT,
|
||||
}}>
|
||||
{initials}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
|
||||
// ── Website Thumbnail (fetches OG image) ────────────────────────────────────
|
||||
const ogCache = {}
|
||||
|
||||
interface WebsiteThumbnailProps {
|
||||
url: string
|
||||
tripId: number
|
||||
color: string
|
||||
}
|
||||
|
||||
export function WebsiteThumbnail({ url, tripId, color }: WebsiteThumbnailProps) {
|
||||
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])
|
||||
|
||||
const domain = (() => { try { return new URL(url).hostname.replace('www.', '') } catch { return 'link' } })()
|
||||
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" title={data?.title || url}
|
||||
style={{
|
||||
width: 48, height: 48, borderRadius: 8, cursor: 'pointer', overflow: 'hidden',
|
||||
background: data?.image ? 'none' : 'var(--bg-tertiary)', border: 'none',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 2,
|
||||
textDecoration: 'none', transition: 'transform 0.12s, box-shadow 0.12s', flexShrink: 0,
|
||||
}}
|
||||
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 ? (
|
||||
<img src={data.image} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} onError={() => setFailed(true)} />
|
||||
) : (
|
||||
<>
|
||||
<ExternalLink size={14} color="var(--text-muted)" />
|
||||
<span style={{ fontSize: 7, fontWeight: 600, color: 'var(--text-muted)', maxWidth: 42, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'center' }}>
|
||||
{domain}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -17,11 +17,7 @@ function useIsDesktop(breakpoint = 1024) {
|
||||
return isDesktop
|
||||
}
|
||||
|
||||
const card = {
|
||||
display: 'flex', flexDirection: 'column',
|
||||
background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-faint)',
|
||||
overflow: 'hidden', minHeight: 0,
|
||||
}
|
||||
const cardClass = 'flex flex-col bg-surface-card rounded-2xl border border-edge-faint overflow-hidden min-h-0'
|
||||
|
||||
interface TripMember {
|
||||
id: number
|
||||
@@ -88,7 +84,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
// Only chat
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
<CollabChat tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -99,19 +95,19 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
// Chat left (380px) + right panels
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div style={{ ...card, flex: '0 0 380px' }}>
|
||||
<div className={cardClass} style={{ flex: '0 0 380px' }}>
|
||||
<CollabChat tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
{rightPanels.length === 1 && (
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
{rightPanels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{rightPanels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{rightPanels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
)}
|
||||
{rightPanels.length === 2 && rightPanels.map(p => (
|
||||
<div key={p} style={{ ...card, flex: 1 }}>
|
||||
<div key={p} className={cardClass} style={{ flex: 1 }}>
|
||||
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
@@ -119,14 +115,14 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
))}
|
||||
{rightPanels.length === 3 && (
|
||||
<>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
<CollabNotes tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
<CollabPolls tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
<WhatsNextWidget tripMembers={tripMembers} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -142,7 +138,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
if (panels.length === 1) {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
{panels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{panels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{panels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
@@ -154,7 +150,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
{panels.map(p => (
|
||||
<div key={p} style={{ ...card, flex: 1 }}>
|
||||
<div key={p} className={cardClass} style={{ flex: 1 }}>
|
||||
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
|
||||
@@ -49,7 +49,7 @@ beforeEach(() => {
|
||||
),
|
||||
);
|
||||
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 1 }) });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 1 }) });
|
||||
});
|
||||
|
||||
describe('CollabPolls', () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Plus, Trash2, X, Check, BarChart3, Lock, Clock } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import ReactDOM from 'react-dom'
|
||||
@@ -78,7 +79,7 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
|
||||
if (!canSubmit) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await onCreate({ question: question.trim(), options: options.filter(o => o.trim()), multiple_choice: multiChoice })
|
||||
await onCreate({ question: question.trim(), options: options.filter(o => o.trim()), multi_choice: multiChoice })
|
||||
onClose()
|
||||
} catch {} finally { setSubmitting(false) }
|
||||
}
|
||||
@@ -230,7 +231,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
|
||||
<Clock size={8} /> {remaining}
|
||||
</span>
|
||||
)}
|
||||
{poll.multiple_choice && (
|
||||
{poll.multi_choice && (
|
||||
<span style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 99 }}>
|
||||
{t('collab.polls.multiChoice')}
|
||||
</span>
|
||||
@@ -305,7 +306,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
|
||||
flex: 1, fontSize: 13, fontWeight: myVote || isWinner ? 600 : 400,
|
||||
color: 'var(--text-primary)', position: 'relative', zIndex: 1,
|
||||
}}>
|
||||
{typeof opt === 'string' ? opt : opt.label || opt}
|
||||
{typeof opt === 'string' ? opt : opt.text}
|
||||
</span>
|
||||
|
||||
{/* Voter avatars */}
|
||||
@@ -342,6 +343,7 @@ interface CollabPollsProps {
|
||||
|
||||
export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const canEdit = can('collab_edit', trip)
|
||||
@@ -378,33 +380,44 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
}, [])
|
||||
|
||||
const handleCreate = useCallback(async (data) => {
|
||||
const result = await collabApi.createPoll(tripId, data)
|
||||
const created = result.poll || result
|
||||
setPolls(prev => prev.some(p => p.id === created.id) ? prev : [created, ...prev])
|
||||
setShowForm(false)
|
||||
}, [tripId])
|
||||
try {
|
||||
const result = await collabApi.createPoll(tripId, data)
|
||||
const created = result.poll || result
|
||||
setPolls(prev => prev.some(p => p.id === created.id) ? prev : [created, ...prev])
|
||||
setShowForm(false)
|
||||
} catch (err) {
|
||||
toast.error(t('common.error'))
|
||||
throw err
|
||||
}
|
||||
}, [tripId, toast, t])
|
||||
|
||||
const handleVote = useCallback(async (pollId, optionIndex) => {
|
||||
try {
|
||||
const result = await collabApi.votePoll(tripId, pollId, optionIndex)
|
||||
const updated = result.poll || result
|
||||
setPolls(prev => prev.map(p => p.id === updated.id ? updated : p))
|
||||
} catch {}
|
||||
}, [tripId])
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}, [tripId, toast, t])
|
||||
|
||||
const handleClose = useCallback(async (pollId) => {
|
||||
try {
|
||||
await collabApi.closePoll(tripId, pollId)
|
||||
setPolls(prev => prev.map(p => p.id === pollId ? { ...p, is_closed: true } : p))
|
||||
} catch {}
|
||||
}, [tripId])
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}, [tripId, toast, t])
|
||||
|
||||
const handleDelete = useCallback(async (pollId) => {
|
||||
try {
|
||||
await collabApi.deletePoll(tripId, pollId)
|
||||
setPolls(prev => prev.filter(p => p.id !== pollId))
|
||||
} catch {}
|
||||
}, [tripId])
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}, [tripId, toast, t])
|
||||
|
||||
const activePolls = polls.filter(p => !p.is_closed && !isExpired(p.deadline))
|
||||
const closedPolls = polls.filter(p => p.is_closed || isExpired(p.deadline))
|
||||
|
||||
@@ -32,22 +32,23 @@ function makeAssignment(id: number, placeOverrides: Record<string, unknown> = {}
|
||||
notes: null,
|
||||
place: {
|
||||
id,
|
||||
trip_id: 1,
|
||||
name: `Place ${id}`,
|
||||
description: null,
|
||||
lat: 0,
|
||||
lng: 0,
|
||||
address: null,
|
||||
category_id: null,
|
||||
icon: null,
|
||||
price: null,
|
||||
currency: null,
|
||||
image_url: null,
|
||||
google_place_id: null,
|
||||
osm_id: null,
|
||||
route_geometry: null,
|
||||
place_time: null,
|
||||
end_time: null,
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
duration_minutes: 60,
|
||||
notes: null,
|
||||
transport_mode: 'walking',
|
||||
website: null,
|
||||
phone: null,
|
||||
...placeOverrides,
|
||||
},
|
||||
participants,
|
||||
@@ -83,7 +84,7 @@ describe('WhatsNextWidget', () => {
|
||||
|
||||
it('FE-COMP-WHATSNEXT-002: shows empty state when all events are in the past', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: yesterday, title: 'Old Day', order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
days: [{ id: 1, trip_id: 1, date: yesterday, title: 'Old Day', day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(10, { place_time: '08:00' })],
|
||||
},
|
||||
@@ -95,7 +96,7 @@ describe('WhatsNextWidget', () => {
|
||||
|
||||
it('FE-COMP-WHATSNEXT-003: shows a future-day event with place name', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(20, { name: 'Eiffel Tower' })],
|
||||
},
|
||||
@@ -106,7 +107,7 @@ describe('WhatsNextWidget', () => {
|
||||
|
||||
it('FE-COMP-WHATSNEXT-004: shows "Tomorrow" label for next-day group', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(21, { name: 'Museum' })],
|
||||
},
|
||||
@@ -118,7 +119,7 @@ describe('WhatsNextWidget', () => {
|
||||
|
||||
it('FE-COMP-WHATSNEXT-005: shows "Today" label for today\'s events with future time', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: today, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
days: [{ id: 1, trip_id: 1, date: today, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(22, { name: 'Night Dinner', place_time: '23:59' })],
|
||||
},
|
||||
@@ -130,7 +131,7 @@ describe('WhatsNextWidget', () => {
|
||||
it('FE-COMP-WHATSNEXT-006: renders event time in 24h format', () => {
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h' } })
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(30, { name: 'Gallery', place_time: '14:30' })],
|
||||
},
|
||||
@@ -142,7 +143,7 @@ describe('WhatsNextWidget', () => {
|
||||
it('FE-COMP-WHATSNEXT-007: renders event time in 12h format', () => {
|
||||
seedStore(useSettingsStore, { settings: { time_format: '12h' } })
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(31, { name: 'Gallery', place_time: '14:30' })],
|
||||
},
|
||||
@@ -153,7 +154,7 @@ describe('WhatsNextWidget', () => {
|
||||
|
||||
it('FE-COMP-WHATSNEXT-008: shows "TBD" when event has no time', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(32, { name: 'Free Time', place_time: null })],
|
||||
},
|
||||
@@ -164,7 +165,7 @@ describe('WhatsNextWidget', () => {
|
||||
|
||||
it('FE-COMP-WHATSNEXT-009: renders address when provided', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(33, { name: 'Café', address: '123 Rue de Rivoli' })],
|
||||
},
|
||||
@@ -179,7 +180,7 @@ describe('WhatsNextWidget', () => {
|
||||
trip_id: 1,
|
||||
date: getFutureDate(i + 1),
|
||||
title: null,
|
||||
order: i,
|
||||
day_number: i,
|
||||
assignments: [],
|
||||
notes_items: [],
|
||||
notes: null,
|
||||
@@ -207,7 +208,7 @@ describe('WhatsNextWidget', () => {
|
||||
|
||||
it('FE-COMP-WHATSNEXT-011: shows participant username chip', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(40, { name: 'Louvre' }, [{ user_id: 3, username: 'alice', avatar: null }])],
|
||||
},
|
||||
@@ -218,7 +219,7 @@ describe('WhatsNextWidget', () => {
|
||||
|
||||
it('FE-COMP-WHATSNEXT-012: falls back to tripMembers when assignment has no participants', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(41, { name: 'Park' }, [])],
|
||||
},
|
||||
@@ -229,7 +230,7 @@ describe('WhatsNextWidget', () => {
|
||||
|
||||
it('FE-COMP-WHATSNEXT-013: renders end time when provided', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(50, { name: 'Concert', place_time: '19:00', end_time: '21:30' })],
|
||||
},
|
||||
@@ -241,7 +242,7 @@ describe('WhatsNextWidget', () => {
|
||||
|
||||
it('FE-COMP-WHATSNEXT-014: multiple events on same day share one day header', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [
|
||||
makeAssignment(60, { name: 'Breakfast', place_time: '08:00' }),
|
||||
@@ -263,7 +264,7 @@ describe('WhatsNextWidget', () => {
|
||||
if (now.getHours() > 0) {
|
||||
const pastTime = '00:01' // Very early — will be past for most of the day
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: today, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
|
||||
days: [{ id: 1, trip_id: 1, date: today, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: {
|
||||
'1': [makeAssignment(70, { name: 'Early Bird', place_time: pastTime })],
|
||||
},
|
||||
|
||||
@@ -30,6 +30,7 @@ function formatDayLabel(date, t, locale) {
|
||||
interface TripMember {
|
||||
id: number
|
||||
username: string
|
||||
avatar?: string | null
|
||||
avatar_url?: string | null
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
|
||||
export function useCollabChat(tripId: any, currentUser: any) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
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<ReturnType<typeof setTimeout>[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
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 scrollToBottom = useCallback((behavior = 'auto') => {
|
||||
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
|
||||
}, [])
|
||||
|
||||
/* ── 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])
|
||||
|
||||
/* ── load more ── */
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
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 })
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
addListener(handler)
|
||||
return () => removeListener(handler)
|
||||
}, [tripId, scrollToBottom])
|
||||
|
||||
/* ── auto-resize textarea ── */
|
||||
const handleTextChange = useCallback((e) => {
|
||||
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'
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── send ── */
|
||||
const handleSend = useCallback(async () => {
|
||||
const body = text.trim()
|
||||
if (!body || sending) return
|
||||
setSending(true)
|
||||
try {
|
||||
const payload: { text: string; reply_to?: number } = { 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])
|
||||
}
|
||||
setText(''); setReplyTo(null); setShowEmoji(false)
|
||||
if (textareaRef.current) textareaRef.current.style.height = 'auto'
|
||||
isAtBottom.current = true
|
||||
setTimeout(() => scrollToBottom('smooth'), 50)
|
||||
} catch { toast.error(t('common.error')) } finally { setSending(false) }
|
||||
}, [text, sending, replyTo, tripId, scrollToBottom, toast, t])
|
||||
|
||||
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 timer = setTimeout(async () => {
|
||||
try {
|
||||
await collabApi.deleteMessage(tripId, msgId)
|
||||
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, _deleted: true } : m))
|
||||
} catch { toast.error(t('common.error')) }
|
||||
setDeletingIds(prev => { const s = new Set(prev); s.delete(msgId); return s })
|
||||
}, 400)
|
||||
deleteTimersRef.current.push(timer)
|
||||
}, [tripId, toast, t])
|
||||
|
||||
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 { toast.error(t('common.error')) }
|
||||
}, [tripId, toast, t])
|
||||
|
||||
const handleEmojiSelect = useCallback((emoji) => {
|
||||
setText(prev => prev + emoji)
|
||||
textareaRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
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}[️]?(?:\p{Extended_Pictographic}[️]?)*){1,3}$/u
|
||||
return emojiRegex.test(text.trim())
|
||||
}
|
||||
|
||||
return { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly }
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { ArrowRightLeft, RefreshCw } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
|
||||
const CURRENCIES = [
|
||||
'AED', 'AFN', 'ALL', 'AMD', 'ANG', 'AOA', 'ARS', 'AUD', 'AWG', 'AZN', 'BAM', 'BBD', 'BDT', 'BGN', 'BHD',
|
||||
'BIF', 'BMD', 'BND', 'BOB', 'BRL', 'BSD', 'BTN', 'BWP', 'BYN', 'BZD', 'CAD', 'CDF', 'CHF', 'CLF', 'CLP',
|
||||
'CNH', 'CNY', 'COP', 'CRC', 'CUP', 'CVE', 'CZK', 'DJF', 'DKK', 'DOP', 'DZD', 'EGP', 'ERN', 'ETB', 'EUR',
|
||||
'FJD', 'FKP', 'FOK', 'GBP', 'GEL', 'GGP', 'GHS', 'GIP', 'GMD', 'GNF', 'GTQ', 'GYD', 'HKD', 'HNL', 'HRK',
|
||||
'HTG', 'HUF', 'IDR', 'ILS', 'IMP', 'INR', 'IQD', 'ISK', 'JEP', 'JMD', 'JOD', 'JPY', 'KES', 'KGS', 'KHR',
|
||||
'KID', 'KMF', 'KRW', 'KWD', 'KYD', 'KZT', 'LAK', 'LBP', 'LKR', 'LRD', 'LSL', 'LYD', 'MAD', 'MDL', 'MGA',
|
||||
'MKD', 'MMK', 'MNT', 'MOP', 'MRU', 'MUR', 'MVR', 'MWK', 'MXN', 'MYR', 'MZN', 'NAD', 'NGN', 'NIO', 'NOK',
|
||||
'NPR', 'NZD', 'OMR', 'PAB', 'PEN', 'PGK', 'PHP', 'PKR', 'PLN', 'PYG', 'QAR', 'RON', 'RSD', 'RUB', 'RWF',
|
||||
'SAR', 'SBD', 'SCR', 'SDG', 'SEK', 'SGD', 'SHP', 'SLE', 'SOS', 'SRD', 'SSP', 'STN', 'SYP', 'SZL', 'THB',
|
||||
'TJS', 'TMT', 'TND', 'TOP', 'TRY', 'TTD', 'TVD', 'TWD', 'TZS', 'UAH', 'UGX', 'USD', 'UYU', 'UZS', 'VES',
|
||||
'VND', 'VUV', 'WST', 'XAF', 'XCD', 'XDR', 'XOF', 'XPF', 'YER', 'ZAR', 'ZMW', 'ZWL'
|
||||
]
|
||||
|
||||
const CURRENCY_OPTIONS = CURRENCIES.map(c => ({ value: c, label: c }))
|
||||
|
||||
export default function CurrencyWidget() {
|
||||
const { t, locale } = useTranslation()
|
||||
const [from, setFrom] = useState(() => localStorage.getItem('currency_from') || 'EUR')
|
||||
const [to, setTo] = useState(() => localStorage.getItem('currency_to') || 'USD')
|
||||
const [amount, setAmount] = useState('100')
|
||||
const [rate, setRate] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const fetchRate = useCallback(async () => {
|
||||
if (from === to) { setRate(1); return }
|
||||
setLoading(true)
|
||||
try {
|
||||
const resp = await fetch(`https://api.exchangerate-api.com/v4/latest/${from}`)
|
||||
const data = await resp.json()
|
||||
setRate(data.rates?.[to] || null)
|
||||
} catch { setRate(null) }
|
||||
finally { setLoading(false) }
|
||||
}, [from, to])
|
||||
|
||||
useEffect(() => { fetchRate() }, [fetchRate])
|
||||
useEffect(() => { localStorage.setItem('currency_from', from) }, [from])
|
||||
useEffect(() => { localStorage.setItem('currency_to', to) }, [to])
|
||||
|
||||
const swap = () => { setFrom(to); setTo(from) }
|
||||
const rawResult = rate && amount ? (parseFloat(amount) * rate).toFixed(2) : null
|
||||
const formatNumber = (num) => {
|
||||
if (!num || num === '—') return '—'
|
||||
return parseFloat(num).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
const result = rawResult
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{t('dashboard.currency')}</span>
|
||||
<button onClick={fetchRate} className="p-1 rounded-md transition-colors" style={{ color: 'var(--text-faint)' }}>
|
||||
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div className="rounded-xl px-4 py-3 mb-3" style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)' }}>
|
||||
<input
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={e => setAmount(e.target.value)}
|
||||
className="w-full text-2xl font-black tabular-nums outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
|
||||
style={{ color: 'var(--text-primary)', background: 'transparent', border: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* From / Swap / To */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="flex-1" style={{ '--bg-input': 'transparent', '--border-primary': 'transparent' }}>
|
||||
<CustomSelect value={from} onChange={setFrom} options={CURRENCY_OPTIONS} searchable size="sm" />
|
||||
</div>
|
||||
<button onClick={swap} className="p-1.5 rounded-lg shrink-0 transition-colors" style={{ color: 'var(--text-muted)' }}>
|
||||
<ArrowRightLeft size={13} />
|
||||
</button>
|
||||
<div className="flex-1" style={{ '--bg-input': 'transparent', '--border-primary': 'transparent' }}>
|
||||
<CustomSelect value={to} onChange={setTo} options={CURRENCY_OPTIONS} searchable size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Result */}
|
||||
<div className="rounded-xl p-3" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<p className="text-xl font-black tabular-nums" style={{ color: 'var(--text-primary)' }}>
|
||||
{formatNumber(result)} <span className="text-sm font-semibold" style={{ color: 'var(--text-muted)' }}>{to}</span>
|
||||
</p>
|
||||
{rate && <p className="text-[10px] mt-0.5" style={{ color: 'var(--text-faint)' }}>1 {from} = {rate.toFixed(4)} {to}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import TimezoneWidget from './TimezoneWidget'
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any)
|
||||
})
|
||||
|
||||
describe('TimezoneWidget', () => {
|
||||
it('FE-COMP-TIMEZONE-001: renders without crashing with default zones', () => {
|
||||
render(<TimezoneWidget />)
|
||||
expect(document.body).toBeInTheDocument()
|
||||
expect(screen.getByText('New York')).toBeInTheDocument()
|
||||
expect(screen.getByText('Tokyo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-002: shows local time text', () => {
|
||||
render(<TimezoneWidget />)
|
||||
const timeElements = screen.getAllByText(/\d{1,2}:\d{2}/)
|
||||
expect(timeElements.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-003: shows timezone section label', () => {
|
||||
render(<TimezoneWidget />)
|
||||
expect(screen.getByText(/timezones/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-004: default zones render on first load (no localStorage)', () => {
|
||||
localStorage.clear()
|
||||
render(<TimezoneWidget />)
|
||||
expect(screen.getByText('New York')).toBeInTheDocument()
|
||||
expect(screen.getByText('Tokyo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-005: zones saved in localStorage are restored', () => {
|
||||
localStorage.setItem('dashboard_timezones', JSON.stringify([{ label: 'Berlin', tz: 'Europe/Berlin' }]))
|
||||
render(<TimezoneWidget />)
|
||||
expect(screen.getByText('Berlin')).toBeInTheDocument()
|
||||
expect(screen.queryByText('New York')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-006: clicking the Plus button opens the add-zone panel', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TimezoneWidget />)
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
await user.click(allButtons[0])
|
||||
expect(await screen.findByText('Custom Timezone')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-007: adding a popular zone from the dropdown adds it to the list', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TimezoneWidget />)
|
||||
// Open add panel
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
await user.click(allButtons[0])
|
||||
// Find and click Berlin in the popular zones list
|
||||
const berlinButton = await screen.findByRole('button', { name: /Berlin/i })
|
||||
await user.click(berlinButton)
|
||||
expect(screen.getByText('Berlin')).toBeInTheDocument()
|
||||
// Panel should be closed
|
||||
expect(screen.queryByText('Custom Timezone')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-008: adding a custom valid timezone with label shows in the list', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TimezoneWidget />)
|
||||
// Open add panel
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
await user.click(allButtons[0])
|
||||
// Type label and timezone
|
||||
const labelInput = screen.getByPlaceholderText('Label (optional)')
|
||||
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
|
||||
await user.type(labelInput, 'My City')
|
||||
await user.type(tzInput, 'Europe/Paris')
|
||||
// Click Add
|
||||
const addButton = screen.getByRole('button', { name: 'Add' })
|
||||
await user.click(addButton)
|
||||
expect(await screen.findByText('My City')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-009: adding a custom invalid timezone shows an error', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TimezoneWidget />)
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
await user.click(allButtons[0])
|
||||
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
|
||||
await user.type(tzInput, 'Invalid/Timezone')
|
||||
const addButton = screen.getByRole('button', { name: 'Add' })
|
||||
await user.click(addButton)
|
||||
expect(await screen.findByText(/invalid timezone/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-010: adding a duplicate timezone shows a duplicate error', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TimezoneWidget />)
|
||||
// Default zones include New York (America/New_York)
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
await user.click(allButtons[0])
|
||||
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
|
||||
await user.type(tzInput, 'America/New_York')
|
||||
const addButton = screen.getByRole('button', { name: 'Add' })
|
||||
await user.click(addButton)
|
||||
expect(await screen.findByText(/already added/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-011: remove button removes a zone from the list', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TimezoneWidget />)
|
||||
expect(screen.getByText('New York')).toBeInTheDocument()
|
||||
// The remove buttons are always in the DOM (opacity-0 in CSS, not hidden from DOM)
|
||||
// There are 2 zone rows (New York, Tokyo), plus the Plus button = 3 buttons total
|
||||
// Remove buttons for New York and Tokyo come after the Plus button
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
// allButtons[0] = Plus, allButtons[1] = remove New York, allButtons[2] = remove Tokyo
|
||||
await user.click(allButtons[1])
|
||||
expect(screen.queryByText('New York')).toBeNull()
|
||||
expect(screen.getByText('Tokyo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-012: adding a zone persists to localStorage', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TimezoneWidget />)
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
await user.click(allButtons[0])
|
||||
const berlinButton = await screen.findByRole('button', { name: /Berlin/i })
|
||||
await user.click(berlinButton)
|
||||
const saved = JSON.parse(localStorage.getItem('dashboard_timezones') || '[]')
|
||||
expect(saved.some((z: { tz: string }) => z.tz === 'Europe/Berlin')).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-COMP-TIMEZONE-013: Enter key in custom tz input triggers addCustomZone', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TimezoneWidget />)
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
await user.click(allButtons[0])
|
||||
const labelInput = screen.getByPlaceholderText('Label (optional)')
|
||||
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
|
||||
await user.type(labelInput, 'Singapore')
|
||||
await user.type(tzInput, 'Asia/Singapore')
|
||||
await user.keyboard('{Enter}')
|
||||
expect(await screen.findByText('Singapore')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,167 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Clock, Plus, X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
|
||||
const POPULAR_ZONES = [
|
||||
{ label: 'New York', tz: 'America/New_York' },
|
||||
{ label: 'London', tz: 'Europe/London' },
|
||||
{ label: 'Berlin', tz: 'Europe/Berlin' },
|
||||
{ label: 'Paris', tz: 'Europe/Paris' },
|
||||
{ label: 'Dubai', tz: 'Asia/Dubai' },
|
||||
{ label: 'Mumbai', tz: 'Asia/Kolkata' },
|
||||
{ label: 'Bangkok', tz: 'Asia/Bangkok' },
|
||||
{ label: 'Tokyo', tz: 'Asia/Tokyo' },
|
||||
{ label: 'Sydney', tz: 'Australia/Sydney' },
|
||||
{ label: 'Los Angeles', tz: 'America/Los_Angeles' },
|
||||
{ label: 'Chicago', tz: 'America/Chicago' },
|
||||
{ label: 'São Paulo', tz: 'America/Sao_Paulo' },
|
||||
{ label: 'Istanbul', tz: 'Europe/Istanbul' },
|
||||
{ label: 'Singapore', tz: 'Asia/Singapore' },
|
||||
{ label: 'Hong Kong', tz: 'Asia/Hong_Kong' },
|
||||
{ label: 'Seoul', tz: 'Asia/Seoul' },
|
||||
{ label: 'Moscow', tz: 'Europe/Moscow' },
|
||||
{ label: 'Cairo', tz: 'Africa/Cairo' },
|
||||
]
|
||||
|
||||
function getTime(tz, locale, is12h) {
|
||||
try {
|
||||
return new Date().toLocaleTimeString(locale, { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: is12h })
|
||||
} catch { return '—' }
|
||||
}
|
||||
|
||||
function getOffset(tz) {
|
||||
try {
|
||||
const now = new Date()
|
||||
const local = new Date(now.toLocaleString('en-US', { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }))
|
||||
const remote = new Date(now.toLocaleString('en-US', { timeZone: tz }))
|
||||
const diff = (remote - local) / 3600000
|
||||
const sign = diff >= 0 ? '+' : ''
|
||||
return `${sign}${diff}h`
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
export default function TimezoneWidget() {
|
||||
const { t, locale } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const [zones, setZones] = useState(() => {
|
||||
const saved = localStorage.getItem('dashboard_timezones')
|
||||
return saved ? JSON.parse(saved) : [
|
||||
{ label: 'New York', tz: 'America/New_York' },
|
||||
{ label: 'Tokyo', tz: 'Asia/Tokyo' },
|
||||
]
|
||||
})
|
||||
const [now, setNow] = useState(Date.now())
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [customLabel, setCustomLabel] = useState('')
|
||||
const [customTz, setCustomTz] = useState('')
|
||||
const [customError, setCustomError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const i = setInterval(() => setNow(Date.now()), 10000)
|
||||
return () => clearInterval(i)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('dashboard_timezones', JSON.stringify(zones))
|
||||
}, [zones])
|
||||
|
||||
const isValidTz = (tz: string) => {
|
||||
try { Intl.DateTimeFormat('en-US', { timeZone: tz }).format(new Date()); return true } catch { return false }
|
||||
}
|
||||
|
||||
const addCustomZone = () => {
|
||||
const tz = customTz.trim()
|
||||
if (!tz) { setCustomError(t('dashboard.timezoneCustomErrorEmpty')); return }
|
||||
if (!isValidTz(tz)) { setCustomError(t('dashboard.timezoneCustomErrorInvalid')); return }
|
||||
if (zones.find(z => z.tz === tz)) { setCustomError(t('dashboard.timezoneCustomErrorDuplicate')); return }
|
||||
const label = customLabel.trim() || tz.split('/').pop()?.replace(/_/g, ' ') || tz
|
||||
setZones([...zones, { label, tz }])
|
||||
setCustomLabel(''); setCustomTz(''); setCustomError(''); setShowAdd(false)
|
||||
}
|
||||
|
||||
const addZone = (zone) => {
|
||||
if (!zones.find(z => z.tz === zone.tz)) {
|
||||
setZones([...zones, zone])
|
||||
}
|
||||
setShowAdd(false)
|
||||
}
|
||||
|
||||
const removeZone = (tz) => setZones(zones.filter(z => z.tz !== tz))
|
||||
|
||||
const localTime = new Date().toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })
|
||||
const rawZone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const localZone = rawZone.split('/').pop().replace(/_/g, ' ')
|
||||
// Show abbreviated timezone name (e.g. CET, CEST, EST)
|
||||
const tzAbbr = new Date().toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop()
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{t('dashboard.timezone')}</span>
|
||||
<button onClick={() => setShowAdd(!showAdd)} className="p-1 rounded-md transition-colors" style={{ color: 'var(--text-faint)' }}>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Local time */}
|
||||
<div className="mb-3 pb-3" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
||||
<p className="text-2xl font-black tabular-nums" style={{ color: 'var(--text-primary)' }}>{localTime}</p>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{localZone} ({tzAbbr}) · {t('dashboard.localTime')}</p>
|
||||
</div>
|
||||
|
||||
{/* Zone list */}
|
||||
<div className="space-y-2">
|
||||
{zones.map(z => (
|
||||
<div key={z.tz} className="flex items-center justify-between group">
|
||||
<div>
|
||||
<p className="text-lg font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{getTime(z.tz, locale, is12h)}</p>
|
||||
<p className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{z.label} <span style={{ color: 'var(--text-muted)' }}>{getOffset(z.tz)}</span></p>
|
||||
</div>
|
||||
<button onClick={() => removeZone(z.tz)} className="opacity-0 group-hover:opacity-100 p-1 rounded transition-all" style={{ color: 'var(--text-faint)' }}>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add zone dropdown */}
|
||||
{showAdd && (
|
||||
<div className="mt-2 rounded-xl p-2 max-h-[280px] overflow-auto" style={{ background: 'var(--bg-secondary)' }}>
|
||||
{/* Custom timezone */}
|
||||
<div className="px-2 py-2 mb-2 rounded-lg" style={{ background: 'var(--bg-card)' }}>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide mb-2" style={{ color: 'var(--text-faint)' }}>{t('dashboard.timezoneCustomTitle')}</p>
|
||||
<div className="space-y-1.5">
|
||||
<input value={customLabel} onChange={e => setCustomLabel(e.target.value)}
|
||||
placeholder={t('dashboard.timezoneCustomLabelPlaceholder')}
|
||||
className="w-full px-2 py-1.5 rounded-lg text-xs outline-none"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border-secondary)' }} />
|
||||
<input value={customTz} onChange={e => { setCustomTz(e.target.value); setCustomError('') }}
|
||||
placeholder={t('dashboard.timezoneCustomTzPlaceholder')}
|
||||
className="w-full px-2 py-1.5 rounded-lg text-xs outline-none"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: `1px solid ${customError ? '#ef4444' : 'var(--border-secondary)'}` }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') addCustomZone() }} />
|
||||
{customError && <p className="text-[10px]" style={{ color: '#ef4444' }}>{customError}</p>}
|
||||
<button onClick={addCustomZone}
|
||||
className="w-full py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{ background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
|
||||
{t('dashboard.timezoneCustomAdd')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Popular zones */}
|
||||
{POPULAR_ZONES.filter(z => !zones.find(existing => existing.tz === z.tz)).map(z => (
|
||||
<button key={z.tz} onClick={() => addZone(z)}
|
||||
className="w-full flex items-center justify-between px-2 py-1.5 rounded-lg text-xs text-left transition-colors"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<span className="font-medium">{z.label}</span>
|
||||
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{getTime(z.tz, locale, is12h)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'car', 'cruise'])
|
||||
@@ -0,0 +1,39 @@
|
||||
import { FileText, FileImage, File, Plane, Train, Car, Ship } from 'lucide-react'
|
||||
import { downloadFile } from '../../utils/fileDownload'
|
||||
|
||||
export function isImage(mimeType?: string | null) {
|
||||
if (!mimeType) return false
|
||||
return mimeType.startsWith('image/')
|
||||
}
|
||||
|
||||
export function getFileIcon(mimeType?: string | null) {
|
||||
if (!mimeType) return File
|
||||
if (mimeType === 'application/pdf') return FileText
|
||||
if (isImage(mimeType)) return FileImage
|
||||
return File
|
||||
}
|
||||
|
||||
export function formatSize(bytes?: number | null) {
|
||||
if (!bytes) return ''
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export function triggerDownload(url: string, filename: string) {
|
||||
downloadFile(url, filename).catch(() => {})
|
||||
}
|
||||
|
||||
export function formatDateWithLocale(dateStr?: string | null, locale?: string) {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
export function transportIcon(type: string) {
|
||||
if (type === 'train') return Train
|
||||
if (type === 'car') return Car
|
||||
if (type === 'cruise') return Ship
|
||||
return Plane
|
||||
}
|
||||
@@ -7,6 +7,7 @@ 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 type { TripFile } from '../../types';
|
||||
import FileManager from './FileManager';
|
||||
|
||||
// Mock getAuthUrl
|
||||
@@ -36,20 +37,21 @@ vi.mock('../../api/client', async (importOriginal) => {
|
||||
|
||||
import { filesApi } from '../../api/client';
|
||||
|
||||
const buildFile = (overrides = {}) => ({
|
||||
const buildFile = (overrides: Partial<TripFile> = {}): TripFile => ({
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
filename: 'report.pdf',
|
||||
original_name: 'report.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
file_size: 51200,
|
||||
created_at: '2025-01-10T08:00:00Z',
|
||||
url: '/uploads/trips/1/report.pdf',
|
||||
starred: false,
|
||||
starred: 0,
|
||||
deleted_at: null,
|
||||
place_id: null,
|
||||
reservation_id: null,
|
||||
day_id: null,
|
||||
uploaded_by: 1,
|
||||
uploader_name: 'Alice',
|
||||
uploaded_by_name: 'Alice',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -320,8 +322,8 @@ describe('FileManager', () => {
|
||||
|
||||
it('FE-COMP-FILEMANAGER-018: starred filter shows only starred files', async () => {
|
||||
const files = [
|
||||
buildFile({ id: 1, original_name: 'starred.pdf', starred: true }),
|
||||
buildFile({ id: 2, original_name: 'normal.pdf', starred: false }),
|
||||
buildFile({ id: 1, original_name: 'starred.pdf', starred: 1 }),
|
||||
buildFile({ id: 2, original_name: 'normal.pdf', starred: 0 }),
|
||||
];
|
||||
render(<FileManager {...defaultProps} files={files} />);
|
||||
const user = userEvent.setup();
|
||||
@@ -388,7 +390,7 @@ describe('FileManager', () => {
|
||||
|
||||
it('FE-COMP-FILEMANAGER-023: assign modal shows reservations list', async () => {
|
||||
const { buildReservation } = await import('../../../tests/helpers/factories');
|
||||
const reservation = buildReservation({ id: 20, name: 'Hotel Paris' });
|
||||
const reservation = buildReservation({ id: 20, title: 'Hotel Paris' });
|
||||
render(<FileManager {...defaultProps} files={[buildFile()]} reservations={[reservation]} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -418,7 +420,7 @@ describe('FileManager', () => {
|
||||
|
||||
it('FE-COMP-FILEMANAGER-025: clicking a reservation in assign modal calls filesApi.update', async () => {
|
||||
const { buildReservation } = await import('../../../tests/helpers/factories');
|
||||
const reservation = buildReservation({ id: 20, name: 'Train Ticket' });
|
||||
const reservation = buildReservation({ id: 20, title: 'Train Ticket' });
|
||||
const file = buildFile({ id: 1 });
|
||||
render(<FileManager {...defaultProps} files={[file]} reservations={[reservation]} />);
|
||||
const user = userEvent.setup();
|
||||
@@ -436,7 +438,7 @@ describe('FileManager', () => {
|
||||
it('FE-COMP-FILEMANAGER-026: assign modal with both places and reservations shows both sections', async () => {
|
||||
const { buildPlace, buildReservation } = await import('../../../tests/helpers/factories');
|
||||
const place = buildPlace({ id: 10, name: 'Notre Dame' });
|
||||
const reservation = buildReservation({ id: 20, name: 'Airbnb' });
|
||||
const reservation = buildReservation({ id: 20, title: 'Airbnb' });
|
||||
render(<FileManager {...defaultProps} files={[buildFile()]} places={[place]} reservations={[reservation]} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -527,7 +529,7 @@ describe('FileManager', () => {
|
||||
|
||||
it('FE-COMP-FILEMANAGER-032: unlink reservation from assign modal calls filesApi.update', async () => {
|
||||
const { buildReservation } = await import('../../../tests/helpers/factories');
|
||||
const reservation = buildReservation({ id: 20, name: 'Museum Pass' });
|
||||
const reservation = buildReservation({ id: 20, title: 'Museum Pass' });
|
||||
// File already has reservation_id set to 20
|
||||
const file = buildFile({ id: 1, reservation_id: 20 });
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,218 @@
|
||||
import ReactDOM from 'react-dom'
|
||||
import { X, MapPin, Ticket, Check } from 'lucide-react'
|
||||
import { filesApi } from '../../api/client'
|
||||
import type { Place, Reservation, Day } from '../../types'
|
||||
import type { FileManagerState } from './useFileManager'
|
||||
import { TRANSPORT_TYPES } from './FileManager.constants'
|
||||
import { transportIcon } from './FileManager.helpers'
|
||||
|
||||
export function AssignModal(S: FileManagerState) {
|
||||
const { files, assignFileId, setAssignFileId, t, days, assignments, places, reservations, tripId, handleAssign, refreshFiles } = S
|
||||
return ReactDOM.createPortal(
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 5000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
onClick={() => setAssignFileId(null)}>
|
||||
<div style={{
|
||||
background: 'var(--bg-card)', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
|
||||
width: 'min(600px, calc(100vw - 32px))', maxHeight: '70vh', overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ padding: '16px 20px 12px', borderBottom: '1px solid var(--border-primary)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{t('files.assignTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{files.find(f => f.id === assignFileId)?.original_name || ''}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setAssignFileId(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 4, display: 'flex', flexShrink: 0 }}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ padding: '8px 12px 0' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '0 2px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
{t('files.noteLabel') || 'Note'}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('files.notePlaceholder')}
|
||||
defaultValue={files.find(f => f.id === assignFileId)?.description || ''}
|
||||
onBlur={e => {
|
||||
const val = e.target.value.trim()
|
||||
const file = files.find(f => f.id === assignFileId)
|
||||
if (file && val !== (file.description || '')) {
|
||||
handleAssign(file.id, { description: val } as any)
|
||||
}
|
||||
}}
|
||||
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
|
||||
style={{
|
||||
width: '100%', padding: '7px 10px', fontSize: 13, borderRadius: 8,
|
||||
border: '1px solid var(--border-primary)', background: 'var(--bg-secondary)',
|
||||
color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ overflowY: 'auto', padding: 8 }}>
|
||||
{(() => {
|
||||
const file = files.find(f => f.id === assignFileId)
|
||||
if (!file) return null
|
||||
const assignedPlaceIds = new Set<number>()
|
||||
const dayGroups: { day: Day; dayPlaces: Place[] }[] = []
|
||||
for (const day of days) {
|
||||
const da = assignments[String(day.id)] || []
|
||||
const dayPlaces = da.map(a => places.find(p => p.id === a.place?.id || p.id === a.place_id)).filter(Boolean) as Place[]
|
||||
if (dayPlaces.length > 0) {
|
||||
dayGroups.push({ day, dayPlaces })
|
||||
dayPlaces.forEach(p => assignedPlaceIds.add(p.id))
|
||||
}
|
||||
}
|
||||
const unassigned = places.filter(p => !assignedPlaceIds.has(p.id))
|
||||
const placeBtn = (p: Place, idx: number) => {
|
||||
const isLinked = file.place_id === p.id || (file.linked_place_ids || []).includes(p.id)
|
||||
return (
|
||||
<button key={`${p.id}-${idx}`} onClick={async () => {
|
||||
if (isLinked) {
|
||||
if (file.place_id === p.id) {
|
||||
await handleAssign(file.id, { place_id: null })
|
||||
} else {
|
||||
try {
|
||||
const linksRes = await filesApi.getLinks(tripId, file.id)
|
||||
const link = (linksRes.links || []).find((l: any) => l.place_id === p.id)
|
||||
if (link) await filesApi.removeLink(tripId, file.id, link.id)
|
||||
refreshFiles()
|
||||
} catch {}
|
||||
}
|
||||
} else {
|
||||
if (!file.place_id) {
|
||||
await handleAssign(file.id, { place_id: p.id })
|
||||
} else {
|
||||
try {
|
||||
await filesApi.addLink(tripId, file.id, { place_id: p.id })
|
||||
refreshFiles()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}} style={{
|
||||
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
|
||||
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
||||
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
|
||||
<MapPin size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
|
||||
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const placesSection = places.length > 0 && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
{t('files.assignPlace')}
|
||||
</div>
|
||||
{dayGroups.map(({ day, dayPlaces }) => (
|
||||
<div key={day.id}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>
|
||||
<span>{day.title || t('dayplan.dayN', { n: day.day_number })}</span>
|
||||
{(() => {
|
||||
const badge = day.date || (day.title ? t('dayplan.dayN', { n: day.day_number }) : null)
|
||||
return badge ? (
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
|
||||
background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 999,
|
||||
}}>{badge}</span>
|
||||
) : null
|
||||
})()}
|
||||
</div>
|
||||
{dayPlaces.map(placeBtn)}
|
||||
</div>
|
||||
))}
|
||||
{unassigned.length > 0 && (
|
||||
<div>
|
||||
{dayGroups.length > 0 && <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>{t('files.unassigned')}</div>}
|
||||
{unassigned.map(placeBtn)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const bookingReservations = reservations.filter(r => !TRANSPORT_TYPES.has(r.type))
|
||||
const transportReservations = reservations.filter(r => TRANSPORT_TYPES.has(r.type))
|
||||
|
||||
const reservationBtn = (r: Reservation) => {
|
||||
const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id)
|
||||
const Icon = TRANSPORT_TYPES.has(r.type) ? transportIcon(r.type) : Ticket
|
||||
return (
|
||||
<button key={r.id} onClick={async () => {
|
||||
if (isLinked) {
|
||||
if (file.reservation_id === r.id) {
|
||||
await handleAssign(file.id, { reservation_id: null })
|
||||
} else {
|
||||
try {
|
||||
const linksRes = await filesApi.getLinks(tripId, file.id)
|
||||
const link = (linksRes.links || []).find((l: any) => l.reservation_id === r.id)
|
||||
if (link) await filesApi.removeLink(tripId, file.id, link.id)
|
||||
refreshFiles()
|
||||
} catch {}
|
||||
}
|
||||
} else {
|
||||
if (!file.reservation_id) {
|
||||
await handleAssign(file.id, { reservation_id: r.id })
|
||||
} else {
|
||||
try {
|
||||
await filesApi.addLink(tripId, file.id, { reservation_id: r.id })
|
||||
refreshFiles()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}} style={{
|
||||
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
|
||||
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
||||
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
|
||||
<Icon size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
|
||||
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const bookingsSection = reservations.length > 0 && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{bookingReservations.length > 0 && (
|
||||
<>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
{t('files.assignBooking')}
|
||||
</div>
|
||||
{bookingReservations.map(reservationBtn)}
|
||||
</>
|
||||
)}
|
||||
{transportReservations.length > 0 && (
|
||||
<>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5, marginTop: bookingReservations.length > 0 ? 4 : 0 }}>
|
||||
{t('files.assignTransport')}
|
||||
</div>
|
||||
{transportReservations.map(reservationBtn)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const hasBoth = placesSection && bookingsSection
|
||||
return (
|
||||
<div className={hasBoth ? 'md:flex' : ''}>
|
||||
<div className={hasBoth ? 'md:w-1/2' : ''} style={{ overflowY: 'auto', maxHeight: '55vh', paddingRight: hasBoth ? 6 : 0 }}>{placesSection}</div>
|
||||
{hasBoth && <div className="hidden md:block" style={{ width: 1, background: 'var(--border-primary)', flexShrink: 0 }} />}
|
||||
{hasBoth && <div className="block md:hidden" style={{ height: 1, background: 'var(--border-primary)', margin: '8px 0' }} />}
|
||||
<div className={hasBoth ? 'md:w-1/2' : ''} style={{ overflowY: 'auto', maxHeight: '55vh', paddingLeft: hasBoth ? 6 : 0 }}>{bookingsSection}</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
|
||||
// Authenticated image — fetches a short-lived download token and renders the image
|
||||
export function AuthedImg({ src, style }: { src: string; style?: React.CSSProperties }) {
|
||||
const [authSrc, setAuthSrc] = useState('')
|
||||
useEffect(() => {
|
||||
getAuthUrl(src, 'download').then(setAuthSrc)
|
||||
}, [src])
|
||||
return authSrc ? <img src={authSrc} alt="" style={style} /> : null
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useRef } from 'react'
|
||||
|
||||
export function AvatarChip({ name, avatarUrl, size = 20 }: { name: string; avatarUrl?: string | null; size?: number }) {
|
||||
const [hover, setHover] = useState(false)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
const onEnter = () => {
|
||||
if (ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect()
|
||||
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
|
||||
}
|
||||
setHover(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={ref} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
width: size, height: size, borderRadius: '50%', border: '1.5px solid var(--border-primary)',
|
||||
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: size * 0.4, fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
|
||||
cursor: 'default',
|
||||
}}>
|
||||
{avatarUrl
|
||||
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: name?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
{hover && ReactDOM.createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
||||
background: 'var(--bg-elevated)', color: 'var(--text-primary)',
|
||||
fontSize: 11, fontWeight: 500, padding: '3px 8px', borderRadius: 6,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', whiteSpace: 'nowrap', zIndex: 9999,
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
{name}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Upload, FileText, Star } from 'lucide-react'
|
||||
import type { FileManagerState } from './useFileManager'
|
||||
import { FileRow } from './FileManagerRow'
|
||||
|
||||
export function FilesView(S: FileManagerState) {
|
||||
const {
|
||||
can, trip, getRootProps, getInputProps, isDragActive, uploading, t, allowedFileTypes,
|
||||
files, filterType, setFilterType, filteredFiles,
|
||||
} = S
|
||||
return (
|
||||
<>
|
||||
{/* Upload zone */}
|
||||
{can('file_upload', trip) && <div
|
||||
{...getRootProps()}
|
||||
style={{
|
||||
margin: '16px 28px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
|
||||
textAlign: 'center', cursor: 'pointer', transition: 'all 0.15s',
|
||||
borderColor: isDragActive ? 'var(--text-secondary)' : 'var(--border-primary)',
|
||||
background: isDragActive ? 'var(--bg-secondary)' : 'var(--bg-card)',
|
||||
}}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<Upload size={24} style={{ margin: '0 auto 8px', color: isDragActive ? 'var(--text-secondary)' : 'var(--text-faint)', display: 'block' }} />
|
||||
{uploading ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
<div style={{ width: 14, height: 14, border: '2px solid var(--text-secondary)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
|
||||
{t('files.uploading')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', fontWeight: 500, margin: 0 }}>{t('files.dropzone')}</p>
|
||||
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', marginTop: 3 }}>{t('files.dropzoneHint')}</p>
|
||||
<p style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 6, opacity: 0.7 }}>
|
||||
{(allowedFileTypes || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv').toUpperCase().split(',').join(', ')} · Max 50 MB
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>}
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="md:!hidden" style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0, flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ id: 'all', label: t('files.filterAll') },
|
||||
...(files.some(f => f.starred) ? [{ id: 'starred', icon: Star }] : []),
|
||||
{ id: 'pdf', label: t('files.filterPdf') },
|
||||
{ id: 'image', label: t('files.filterImages') },
|
||||
{ id: 'doc', label: t('files.filterDocs') },
|
||||
...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []),
|
||||
].map(tab => (
|
||||
<button key={tab.id} onClick={() => setFilterType(tab.id)} style={{
|
||||
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer', fontSize: 12,
|
||||
fontFamily: 'inherit', transition: 'all 0.12s',
|
||||
background: filterType === tab.id ? 'var(--accent)' : 'transparent',
|
||||
color: filterType === tab.id ? 'var(--accent-text)' : 'var(--text-muted)',
|
||||
fontWeight: filterType === tab.id ? 600 : 400,
|
||||
}}>{tab.icon ? <tab.icon size={13} fill={filterType === tab.id ? '#facc15' : 'none'} color={filterType === tab.id ? '#facc15' : 'currentColor'} /> : tab.label}</button>
|
||||
))}
|
||||
<span style={{ marginLeft: 'auto', fontSize: 11.5, color: 'var(--text-faint)', alignSelf: 'center' }}>
|
||||
{filteredFiles.length === 1 ? t('files.countSingular') : t('files.count', { count: filteredFiles.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* File list */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 28px 16px' }} className="max-md:!px-4">
|
||||
{filteredFiles.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
|
||||
<FileText size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
|
||||
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('files.empty')}</p>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-faint)', margin: 0 }}>{t('files.emptyHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{filteredFiles.map(file => <FileRow key={file.id} {...S} file={file} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { ExternalLink, Download, X, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { TripFile } from '../../types'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import { openFile as openFileUrl } from '../../utils/fileDownload'
|
||||
import { triggerDownload } from './FileManager.helpers'
|
||||
|
||||
// Image lightbox with gallery navigation
|
||||
interface ImageLightboxProps {
|
||||
files: (TripFile & { url: string })[]
|
||||
initialIndex: number
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
|
||||
const { t } = useTranslation()
|
||||
const [index, setIndex] = useState(initialIndex)
|
||||
const [imgSrc, setImgSrc] = useState('')
|
||||
const [touchStart, setTouchStart] = useState<number | null>(null)
|
||||
const file = files[index]
|
||||
|
||||
useEffect(() => {
|
||||
setImgSrc('')
|
||||
if (file) getAuthUrl(file.url, 'download').then(setImgSrc)
|
||||
}, [file?.url])
|
||||
|
||||
const goPrev = () => setIndex(i => Math.max(0, i - 1))
|
||||
const goNext = () => setIndex(i => Math.min(files.length - 1, i + 1))
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key === 'ArrowLeft') goPrev()
|
||||
if (e.key === 'ArrowRight') goNext()
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [])
|
||||
|
||||
if (!file) return null
|
||||
|
||||
const hasPrev = index > 0
|
||||
const hasNext = index < files.length - 1
|
||||
const navBtn = (side: 'left' | 'right', onClick: () => void, show: boolean): React.ReactNode => show ? (
|
||||
<button onClick={e => { e.stopPropagation(); onClick() }}
|
||||
style={{
|
||||
position: 'absolute', top: '50%', [side]: 12, transform: 'translateY(-50%)', zIndex: 10,
|
||||
background: 'rgba(0,0,0,0.5)', border: 'none', borderRadius: '50%', width: 40, height: 40,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
|
||||
color: 'rgba(255,255,255,0.8)', transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(0,0,0,0.75)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'rgba(0,0,0,0.5)')}>
|
||||
{side === 'left' ? <ChevronLeft size={22} /> : <ChevronRight size={22} />}
|
||||
</button>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 2000, display: 'flex', flexDirection: 'column', paddingBottom: 'var(--bottom-nav-h)' }}
|
||||
onClick={onClose}
|
||||
onTouchStart={e => setTouchStart(e.touches[0].clientX)}
|
||||
onTouchEnd={e => {
|
||||
if (touchStart === null) return
|
||||
const diff = e.changedTouches[0].clientX - touchStart
|
||||
if (diff > 60) goPrev()
|
||||
else if (diff < -60) goNext()
|
||||
setTouchStart(null)
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
||||
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
|
||||
{file.original_name}
|
||||
<span style={{ marginLeft: 8, color: 'rgba(255,255,255,0.4)' }}>{index + 1} / {files.length}</span>
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => openFileUrl(file.url, file.original_name).catch(() => {})}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
|
||||
title={t('files.openTab')}>
|
||||
<ExternalLink size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => triggerDownload(file.url, file.original_name)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
|
||||
title={t('files.download') || 'Download'}>
|
||||
<Download size={16} />
|
||||
</button>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main image + nav */}
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', minHeight: 0 }}
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
{navBtn('left', goPrev, hasPrev)}
|
||||
{imgSrc && <img src={imgSrc} alt={file.original_name} style={{ maxWidth: '85vw', maxHeight: '80vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} onClick={e => e.stopPropagation()} />}
|
||||
{navBtn('right', goNext, hasNext)}
|
||||
</div>
|
||||
|
||||
{/* Thumbnail strip */}
|
||||
{files.length > 1 && (
|
||||
<div style={{ display: 'flex', gap: 4, justifyContent: 'center', padding: '10px 16px', flexShrink: 0, overflowX: 'auto' }} onClick={e => e.stopPropagation()}>
|
||||
{files.map((f, i) => (
|
||||
<ThumbImg key={f.id} file={f} active={i === index} onClick={() => setIndex(i)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ThumbImg({ file, active, onClick }: { file: TripFile & { url: string }; active: boolean; onClick: () => void }) {
|
||||
const [src, setSrc] = useState('')
|
||||
useEffect(() => { getAuthUrl(file.url, 'download').then(setSrc) }, [file.url])
|
||||
return (
|
||||
<button onClick={onClick} style={{
|
||||
width: 48, height: 48, borderRadius: 6, overflow: 'hidden', border: active ? '2px solid #fff' : '2px solid transparent',
|
||||
opacity: active ? 1 : 0.5, cursor: 'pointer', padding: 0, background: '#111', flexShrink: 0, transition: 'opacity 0.15s',
|
||||
}}>
|
||||
{src && <img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import ReactDOM from 'react-dom'
|
||||
import { ExternalLink, Download, X } from 'lucide-react'
|
||||
import { openFile as openFileUrl } from '../../utils/fileDownload'
|
||||
import type { FileManagerState } from './useFileManager'
|
||||
import { triggerDownload } from './FileManager.helpers'
|
||||
|
||||
export function PdfPreviewModal(S: FileManagerState) {
|
||||
const { previewFile, setPreviewFile, previewFileUrl, toast, t } = S
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
onClick={() => setPreviewFile(null)}
|
||||
>
|
||||
<div
|
||||
style={{ width: '100%', maxWidth: 950, height: '94vh', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
|
||||
<ExternalLink size={13} /> {t('files.openTab')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => triggerDownload(previewFile.url, previewFile.original_name)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
|
||||
<Download size={13} /> {t('files.download') || 'Download'}
|
||||
</button>
|
||||
<button onClick={() => setPreviewFile(null)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6, transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<object
|
||||
data={previewFileUrl ? `${previewFileUrl}#view=FitH` : undefined}
|
||||
type="application/pdf"
|
||||
style={{ flex: 1, width: '100%', border: 'none' }}
|
||||
title={previewFile.original_name}
|
||||
>
|
||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
<button onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>{t('files.downloadPdf')}</button>
|
||||
</p>
|
||||
</object>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Trash2, ExternalLink, Download, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil } from 'lucide-react'
|
||||
import type { TripFile } from '../../types'
|
||||
import type { FileManagerState } from './useFileManager'
|
||||
import { TRANSPORT_TYPES } from './FileManager.constants'
|
||||
import { getFileIcon, isImage, formatSize, formatDateWithLocale, transportIcon, triggerDownload } from './FileManager.helpers'
|
||||
import { AuthedImg } from './FileManagerAuthedImg'
|
||||
import { AvatarChip } from './FileManagerAvatarChip'
|
||||
import { SourceBadge } from './FileManagerSourceBadge'
|
||||
|
||||
export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolean }) {
|
||||
const {
|
||||
file, isTrash = false, places, reservations, t, locale, can, trip,
|
||||
handleStar, handleRestore, handlePermanentDelete, handleDelete, openFile, setAssignFileId,
|
||||
} = p
|
||||
const FileIcon = getFileIcon(file.mime_type)
|
||||
const allLinkedPlaceIds = new Set<number>()
|
||||
if (file.place_id) allLinkedPlaceIds.add(file.place_id)
|
||||
for (const pid of (file.linked_place_ids || [])) allLinkedPlaceIds.add(pid)
|
||||
const linkedPlaces = [...allLinkedPlaceIds].map(pid => places?.find(p => p.id === pid)).filter(Boolean)
|
||||
// All linked reservations (primary + file_links)
|
||||
const allLinkedResIds = new Set<number>()
|
||||
if (file.reservation_id) allLinkedResIds.add(file.reservation_id)
|
||||
for (const rid of (file.linked_reservation_ids || [])) allLinkedResIds.add(rid)
|
||||
const linkedReservations = [...allLinkedResIds].map(rid => reservations?.find(r => r.id === rid)).filter(Boolean)
|
||||
return (
|
||||
<div key={file.id} style={{
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
|
||||
padding: '10px 12px', display: 'flex', alignItems: 'flex-start', gap: 10,
|
||||
transition: 'border-color 0.12s',
|
||||
opacity: isTrash ? 0.7 : 1,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
|
||||
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border-primary)'}
|
||||
className="group"
|
||||
>
|
||||
{/* Icon or thumbnail */}
|
||||
<div
|
||||
onClick={() => !isTrash && openFile(file)}
|
||||
style={{
|
||||
flexShrink: 0, width: 36, height: 36, borderRadius: 8,
|
||||
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: isTrash ? 'default' : 'pointer', overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{isImage(file.mime_type)
|
||||
? <AuthedImg src={file.url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: (() => {
|
||||
const ext = (file.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
||||
const isPdf = file.mime_type === 'application/pdf'
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', background: isPdf ? '#ef44441a' : 'var(--bg-tertiary)' }}>
|
||||
<span style={{ fontSize: 9, fontWeight: 700, color: isPdf ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
|
||||
</div>
|
||||
)
|
||||
})()
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
{file.uploaded_by_name && (
|
||||
<AvatarChip name={file.uploaded_by_name} avatarUrl={file.uploaded_by_avatar} size={20} />
|
||||
)}
|
||||
{!isTrash && file.starred ? <Star size={12} fill="#facc15" color="#facc15" style={{ flexShrink: 0 }} /> : null}
|
||||
<span
|
||||
onClick={() => !isTrash && openFile(file)}
|
||||
style={{ fontWeight: 500, fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: isTrash ? 'default' : 'pointer' }}
|
||||
>
|
||||
{file.original_name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{file.description && (
|
||||
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', margin: '2px 0 0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.description}</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 6, marginTop: 4 }}>
|
||||
{file.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatSize(file.file_size)}</span>}
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatDateWithLocale(file.created_at, locale)}</span>
|
||||
|
||||
{linkedPlaces.map(p => (
|
||||
<SourceBadge key={p.id} icon={MapPin} label={`${t('files.sourcePlan')} · ${p.name}`} />
|
||||
))}
|
||||
{linkedReservations.map(r => (
|
||||
TRANSPORT_TYPES.has(r.type)
|
||||
? <SourceBadge key={r.id} icon={transportIcon(r.type)} label={`${t('files.sourceTransport')} · ${r.title || t('files.sourceTransport')}`} />
|
||||
: <SourceBadge key={r.id} icon={Ticket} label={`${t('files.sourceBooking')} · ${r.title || t('files.sourceBooking')}`} />
|
||||
))}
|
||||
{file.note_id && (
|
||||
<SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions — always visible on mobile, hover on desktop */}
|
||||
<div className="file-actions" style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
||||
{isTrash ? (
|
||||
<>
|
||||
{can('file_delete', trip) && <button onClick={() => handleRestore(file.id)} title={t('files.restore') || 'Restore'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#22c55e'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<RotateCcw size={14} />
|
||||
</button>}
|
||||
{can('file_delete', trip) && <button onClick={() => handlePermanentDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={14} />
|
||||
</button>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button onClick={() => handleStar(file.id)} title={file.starred ? t('files.unstar') || 'Unstar' : t('files.star') || 'Star'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: file.starred ? '#facc15' : 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => { if (!file.starred) e.currentTarget.style.color = '#facc15' }} onMouseLeave={e => { if (!file.starred) e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||
<Star size={14} fill={file.starred ? '#facc15' : 'none'} />
|
||||
</button>
|
||||
{can('file_edit', trip) && <button onClick={() => setAssignFileId(file.id)} title={t('files.assign') || 'Assign'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={14} />
|
||||
</button>}
|
||||
<button onClick={() => openFile(file)} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<ExternalLink size={14} />
|
||||
</button>
|
||||
<button onClick={() => triggerDownload(file.url, file.original_name)} title={t('files.download') || 'Download'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
{can('file_delete', trip) && <button onClick={() => handleDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={14} />
|
||||
</button>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Source badge
|
||||
interface SourceBadgeProps {
|
||||
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
|
||||
label: string
|
||||
}
|
||||
|
||||
export function SourceBadge({ icon: Icon, label }: SourceBadgeProps) {
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
fontSize: 10.5, color: '#4b5563',
|
||||
background: 'var(--bg-tertiary)', border: '1px solid var(--border-primary)',
|
||||
borderRadius: 6, padding: '2px 7px',
|
||||
fontWeight: 500, maxWidth: '100%', overflow: 'hidden',
|
||||
}}>
|
||||
<Icon size={10} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Star, Trash2 } from 'lucide-react'
|
||||
import type { FileManagerState } from './useFileManager'
|
||||
|
||||
export function FileManagerToolbar(S: FileManagerState) {
|
||||
const { showTrash, t, files, filterType, setFilterType, toggleTrash } = S
|
||||
return (
|
||||
<div style={{ padding: '24px 28px 0', flexShrink: 0 }} className="max-md:!px-4 max-md:!pt-4">
|
||||
<div style={{
|
||||
background: 'var(--bg-tertiary)', borderRadius: 18,
|
||||
padding: '14px 16px 14px 22px',
|
||||
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
|
||||
{showTrash ? (t('files.trash') || 'Trash') : t('files.title')}
|
||||
</h2>
|
||||
|
||||
{!showTrash && (
|
||||
<>
|
||||
<div className="hidden md:block" style={{ width: 1, height: 22, background: 'var(--border-faint)', flexShrink: 0 }} />
|
||||
<div className="hidden md:inline-flex" style={{ gap: 4, flexWrap: 'wrap', flex: 1, minWidth: 0 }}>
|
||||
{[
|
||||
{ id: 'all', label: t('files.filterAll') },
|
||||
...(files.some(f => f.starred) ? [{ id: 'starred', icon: Star } as const] : []),
|
||||
{ id: 'pdf', label: t('files.filterPdf') },
|
||||
{ id: 'image', label: t('files.filterImages') },
|
||||
{ id: 'doc', label: t('files.filterDocs') },
|
||||
...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []),
|
||||
].map(tab => {
|
||||
const active = filterType === tab.id
|
||||
const TabIcon = 'icon' in tab ? tab.icon : null
|
||||
const count = tab.id === 'all' ? files.length
|
||||
: tab.id === 'starred' ? files.filter(f => f.starred).length
|
||||
: tab.id === 'pdf' ? files.filter(f => (f.mime_type || '').includes('pdf') || /\.pdf$/i.test(f.original_name)).length
|
||||
: tab.id === 'image' ? files.filter(f => (f.mime_type || '').startsWith('image/')).length
|
||||
: tab.id === 'doc' ? files.filter(f => /\.(docx?|xlsx?|txt|csv)$/i.test(f.original_name)).length
|
||||
: tab.id === 'collab' ? files.filter(f => f.note_id).length
|
||||
: 0
|
||||
return (
|
||||
<button key={tab.id} onClick={() => setFilterType(tab.id)}
|
||||
style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '6px 12px', borderRadius: 99, fontSize: 13, whiteSpace: 'nowrap',
|
||||
background: active ? 'var(--bg-card)' : 'transparent',
|
||||
color: active ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
fontWeight: active ? 500 : 400,
|
||||
boxShadow: active ? '0 1px 2px rgba(0,0,0,0.06)' : 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{TabIcon ? <TabIcon size={13} fill={active ? '#facc15' : 'none'} color={active ? '#facc15' : 'currentColor'} /> : null}
|
||||
{'label' in tab && tab.label}
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 600,
|
||||
background: active ? 'var(--bg-tertiary)' : 'rgba(0,0,0,0.06)',
|
||||
color: 'var(--text-faint)',
|
||||
padding: '1px 6px', borderRadius: 99, minWidth: 16, textAlign: 'center',
|
||||
}}>{count}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button onClick={toggleTrash} style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||
background: 'var(--accent)', color: 'var(--accent-text)',
|
||||
flexShrink: 0, marginLeft: 'auto',
|
||||
opacity: showTrash ? 1 : 0.88,
|
||||
transition: 'opacity 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '1'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = showTrash ? '1' : '0.88'}
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={2.5} /> <span className="hidden sm:inline">{t('files.trash') || 'Trash'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import type { FileManagerState } from './useFileManager'
|
||||
import { FileRow } from './FileManagerRow'
|
||||
|
||||
export function TrashView(S: FileManagerState) {
|
||||
const { trashFiles, can, trip, handleEmptyTrash, loadingTrash, t } = S
|
||||
return (
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
|
||||
{trashFiles.length > 0 && can('file_delete', trip) && (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
|
||||
<button onClick={handleEmptyTrash} style={{
|
||||
padding: '5px 12px', borderRadius: 8, border: '1px solid #fecaca',
|
||||
background: '#fef2f2', color: '#dc2626', fontSize: 12, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
{t('files.emptyTrash') || 'Empty Trash'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{loadingTrash ? (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: 'var(--text-faint)' }}>
|
||||
<div style={{ width: 20, height: 20, border: '2px solid var(--text-faint)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.8s linear infinite', margin: '0 auto' }} />
|
||||
</div>
|
||||
) : trashFiles.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
|
||||
<Trash2 size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
|
||||
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('files.trashEmpty') || 'Trash is empty'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{trashFiles.map(file => <FileRow key={file.id} {...S} file={file} isTrash />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { filesApi } from '../../api/client'
|
||||
import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import { isImage } from './FileManager.helpers'
|
||||
|
||||
export interface FileManagerProps {
|
||||
files?: TripFile[]
|
||||
onUpload: (fd: FormData) => Promise<any>
|
||||
onDelete: (fileId: number) => Promise<void>
|
||||
onUpdate?: (fileId: number, data: Partial<TripFile>) => Promise<void>
|
||||
places: Place[]
|
||||
days?: Day[]
|
||||
assignments?: AssignmentsMap
|
||||
reservations?: Reservation[]
|
||||
tripId: number
|
||||
allowedFileTypes?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* File manager state: upload (dropzone + paste), star/trash/restore, the
|
||||
* filter tabs, lightbox + PDF preview and the assign-to-place/reservation
|
||||
* modal. Kept in one hook so FileManager renders as thin layout sections.
|
||||
*/
|
||||
export function useFileManager({ files = [], onUpload, onDelete, onUpdate, places, days = [], assignments = {}, reservations = [], tripId, allowedFileTypes }: FileManagerProps) {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [filterType, setFilterType] = useState('all')
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null)
|
||||
const [showTrash, setShowTrash] = useState(false)
|
||||
const [trashFiles, setTrashFiles] = useState<TripFile[]>([])
|
||||
const [loadingTrash, setLoadingTrash] = useState(false)
|
||||
const toast = useToast()
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const { t, locale } = useTranslation()
|
||||
|
||||
const loadTrash = useCallback(async () => {
|
||||
setLoadingTrash(true)
|
||||
try {
|
||||
const data = await filesApi.list(tripId, true)
|
||||
setTrashFiles(data.files || [])
|
||||
} catch { /* */ }
|
||||
setLoadingTrash(false)
|
||||
}, [tripId])
|
||||
|
||||
const toggleTrash = useCallback(() => {
|
||||
if (!showTrash) loadTrash()
|
||||
setShowTrash(v => !v)
|
||||
}, [showTrash, loadTrash])
|
||||
|
||||
const refreshFiles = useCallback(async () => {
|
||||
if (onUpdate) onUpdate(0, {} as any)
|
||||
}, [onUpdate])
|
||||
|
||||
const handleStar = async (fileId: number) => {
|
||||
try {
|
||||
await filesApi.toggleStar(tripId, fileId)
|
||||
refreshFiles()
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
const handleRestore = async (fileId: number) => {
|
||||
try {
|
||||
await filesApi.restore(tripId, fileId)
|
||||
setTrashFiles(prev => prev.filter(f => f.id !== fileId))
|
||||
refreshFiles()
|
||||
toast.success(t('files.toast.restored'))
|
||||
} catch {
|
||||
toast.error(t('files.toast.restoreError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handlePermanentDelete = async (fileId: number) => {
|
||||
if (!confirm(t('files.confirm.permanentDelete'))) return
|
||||
try {
|
||||
await filesApi.permanentDelete(tripId, fileId)
|
||||
setTrashFiles(prev => prev.filter(f => f.id !== fileId))
|
||||
toast.success(t('files.toast.deleted'))
|
||||
} catch {
|
||||
toast.error(t('files.toast.deleteError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleEmptyTrash = async () => {
|
||||
if (!confirm(t('files.confirm.emptyTrash'))) return
|
||||
try {
|
||||
await filesApi.emptyTrash(tripId)
|
||||
setTrashFiles([])
|
||||
toast.success(t('files.toast.trashEmptied') || 'Trash emptied')
|
||||
} catch {
|
||||
toast.error(t('files.toast.deleteError'))
|
||||
}
|
||||
}
|
||||
|
||||
const [previewFile, setPreviewFile] = useState(null)
|
||||
const [previewFileUrl, setPreviewFileUrl] = useState('')
|
||||
const [assignFileId, setAssignFileId] = useState<number | null>(null)
|
||||
|
||||
const onDrop = useCallback(async (acceptedFiles) => {
|
||||
if (acceptedFiles.length === 0) return
|
||||
setUploading(true)
|
||||
const uploadedIds: number[] = []
|
||||
try {
|
||||
for (const file of acceptedFiles) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const result = await onUpload(formData)
|
||||
const fileObj = result?.file || result
|
||||
if (fileObj?.id) uploadedIds.push(fileObj.id)
|
||||
}
|
||||
toast.success(t('files.uploaded', { count: acceptedFiles.length }))
|
||||
// Open assign modal for the last uploaded file
|
||||
const lastId = uploadedIds[uploadedIds.length - 1]
|
||||
if (lastId && (places.length > 0 || reservations.length > 0)) {
|
||||
setAssignFileId(lastId)
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('files.uploadError'))
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}, [onUpload, toast, t, places, reservations])
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
maxSize: 50 * 1024 * 1024,
|
||||
noClick: false,
|
||||
})
|
||||
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
||||
if (!can('file_upload', trip)) return
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
const pastedFiles: File[] = []
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile()
|
||||
if (file) pastedFiles.push(file)
|
||||
}
|
||||
}
|
||||
if (pastedFiles.length > 0) {
|
||||
e.preventDefault()
|
||||
onDrop(pastedFiles)
|
||||
}
|
||||
}, [onDrop])
|
||||
|
||||
const filteredFiles = files.filter(f => {
|
||||
if (filterType === 'starred') return !!f.starred
|
||||
if (filterType === 'pdf') return f.mime_type === 'application/pdf'
|
||||
if (filterType === 'image') return isImage(f.mime_type)
|
||||
if (filterType === 'doc') return (f.mime_type || '').includes('word') || (f.mime_type || '').includes('excel') || (f.mime_type || '').includes('text')
|
||||
if (filterType === 'collab') return !!f.note_id
|
||||
return true
|
||||
})
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
await onDelete(id)
|
||||
toast.success(t('files.toast.trashed') || 'Moved to trash')
|
||||
} catch {
|
||||
toast.error(t('files.toast.deleteError'))
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (previewFile) {
|
||||
getAuthUrl(previewFile.url, 'download').then(setPreviewFileUrl)
|
||||
} else {
|
||||
setPreviewFileUrl('')
|
||||
}
|
||||
}, [previewFile?.url])
|
||||
|
||||
const handleAssign = async (fileId: number, data: { place_id?: number | null; reservation_id?: number | null }) => {
|
||||
try {
|
||||
await filesApi.update(tripId, fileId, data)
|
||||
refreshFiles()
|
||||
} catch {
|
||||
toast.error(t('files.toast.assignError'))
|
||||
}
|
||||
}
|
||||
|
||||
const imageFiles = filteredFiles.filter(f => isImage(f.mime_type))
|
||||
|
||||
const openFile = (file) => {
|
||||
if (isImage(file.mime_type)) {
|
||||
const idx = imageFiles.findIndex(f => f.id === file.id)
|
||||
setLightboxIndex(idx >= 0 ? idx : 0)
|
||||
} else {
|
||||
setPreviewFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
files, places, days, assignments, reservations, tripId, allowedFileTypes,
|
||||
uploading, filterType, setFilterType, lightboxIndex, setLightboxIndex,
|
||||
showTrash, trashFiles, loadingTrash, toast, can, trip, t, locale,
|
||||
toggleTrash, refreshFiles, handleStar, handleRestore, handlePermanentDelete, handleEmptyTrash,
|
||||
previewFile, setPreviewFile, previewFileUrl, assignFileId, setAssignFileId,
|
||||
getRootProps, getInputProps, isDragActive, handlePaste, filteredFiles, handleDelete,
|
||||
handleAssign, imageFiles, openFile,
|
||||
}
|
||||
}
|
||||
|
||||
export type FileManagerState = ReturnType<typeof useFileManager>
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { X, Check, UserPlus } from 'lucide-react'
|
||||
import { journeyApi, authApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
|
||||
export default function ContributorInviteDialog({ journeyId, existingUserIds, onClose, onInvited }: {
|
||||
journeyId: number
|
||||
existingUserIds: number[]
|
||||
onClose: () => void
|
||||
onInvited: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [users, setUsers] = useState<{ id: number; username: string; email: string; avatar?: string | null }[]>([])
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
|
||||
const [role, setRole] = useState<'editor' | 'viewer'>('viewer')
|
||||
const [sending, setSending] = useState(false)
|
||||
const toast = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
authApi.listUsers().then(d => setUsers(d.users || [])).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const filtered = users.filter(u => {
|
||||
if (existingUserIds.includes(u.id)) return false
|
||||
if (!search) return true
|
||||
const q = search.toLowerCase()
|
||||
return u.username.toLowerCase().includes(q) || u.email.toLowerCase().includes(q)
|
||||
})
|
||||
|
||||
const handleInvite = async () => {
|
||||
if (!selectedUserId) return
|
||||
setSending(true)
|
||||
try {
|
||||
await journeyApi.addContributor(journeyId, selectedUserId, role)
|
||||
toast.success(t('journey.contributors.added'))
|
||||
onInvited()
|
||||
} catch {
|
||||
toast.error(t('journey.contributors.addFailed'))
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5 bg-[rgba(9,9,11,0.75)]">
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[420px] w-full flex flex-col overflow-hidden">
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.contributors.invite')}</h2>
|
||||
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5 flex flex-col gap-4">
|
||||
{/* Search */}
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.contributors.searchUser')}</label>
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder={t('journey.contributors.searchPlaceholder')}
|
||||
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400 dark:focus:border-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* User list */}
|
||||
<div className="max-h-[200px] overflow-y-auto flex flex-col gap-1">
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-[12px] text-zinc-400 text-center py-4">{t('journey.contributors.noUsers')}</p>
|
||||
)}
|
||||
{filtered.map(u => (
|
||||
<div
|
||||
key={u.id}
|
||||
onClick={() => setSelectedUserId(u.id)}
|
||||
className={`flex items-center gap-2.5 p-2.5 rounded-lg cursor-pointer transition-all ${
|
||||
selectedUserId === u.id
|
||||
? 'bg-zinc-100 dark:bg-zinc-800 border border-zinc-900 dark:border-white'
|
||||
: 'hover:bg-zinc-50 dark:hover:bg-zinc-800 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-300 flex items-center justify-center text-[12px] font-semibold">
|
||||
{u.username[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] font-medium text-zinc-900 dark:text-white">{u.username}</div>
|
||||
<div className="text-[11px] text-zinc-500 truncate">{u.email}</div>
|
||||
</div>
|
||||
{selectedUserId === u.id && (
|
||||
<div className="w-5 h-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center">
|
||||
<Check size={12} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Role selector */}
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.invite.role')}</label>
|
||||
<div className="flex gap-2">
|
||||
{(['viewer', 'editor'] as const).map(r => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setRole(r)}
|
||||
className={`flex-1 py-2 rounded-lg text-[12px] font-medium border transition-all ${
|
||||
role === r
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white'
|
||||
: 'border-zinc-200 dark:border-zinc-700 text-zinc-500 hover:border-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{t(`journey.invite.${r}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
|
||||
<button onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleInvite}
|
||||
disabled={!selectedUserId || sending}
|
||||
className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{sending ? t('journey.invite.inviting') : t('journey.invite.invite')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { journeyApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
|
||||
export function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }: {
|
||||
journeyId: number
|
||||
existingTripIds: number[]
|
||||
onClose: () => void
|
||||
onAdded: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [trips, setTrips] = useState<{ id: number; title: string; destination?: string; start_date?: string; end_date?: string }[]>([])
|
||||
const [search, setSearch] = useState('')
|
||||
const [adding, setAdding] = useState<number | null>(null)
|
||||
const toast = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
journeyApi.availableTrips().then(d => setTrips(d.trips || [])).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const filtered = trips.filter(trip => {
|
||||
if (existingTripIds.includes(trip.id)) return false
|
||||
if (!search) return true
|
||||
const q = search.toLowerCase()
|
||||
return trip.title.toLowerCase().includes(q) || (trip.destination || '').toLowerCase().includes(q)
|
||||
})
|
||||
|
||||
const handleAdd = async (tripId: number) => {
|
||||
setAdding(tripId)
|
||||
try {
|
||||
await journeyApi.addTrip(journeyId, tripId)
|
||||
toast.success(t('journey.trips.tripLinked'))
|
||||
onAdded()
|
||||
} catch {
|
||||
toast.error(t('journey.trips.linkFailed'))
|
||||
} finally {
|
||||
setAdding(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5 bg-[rgba(9,9,11,0.75)]">
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[420px] w-full flex flex-col overflow-hidden">
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.trips.linkTrip')}</h2>
|
||||
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5 flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.trips.searchTrip')}</label>
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder={t('journey.trips.searchPlaceholder')}
|
||||
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400 dark:focus:border-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[280px] overflow-y-auto flex flex-col gap-1">
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-[12px] text-zinc-400 text-center py-4">{t('journey.trips.noTripsAvailable')}</p>
|
||||
)}
|
||||
{filtered.map(trip => (
|
||||
<div
|
||||
key={trip.id}
|
||||
className="flex items-center gap-2.5 p-2.5 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 border border-transparent"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-md flex-shrink-0" style={{ background: pickGradient(trip.id) }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] font-medium text-zinc-900 dark:text-white truncate">{trip.title}</div>
|
||||
{(trip.destination || trip.start_date) && (
|
||||
<div className="text-[11px] text-zinc-500 truncate">
|
||||
{trip.destination}{trip.destination && trip.start_date ? ' · ' : ''}{trip.start_date}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleAdd(trip.id)}
|
||||
disabled={adding === trip.id}
|
||||
className="px-3 py-1.5 rounded-lg text-[11px] font-semibold bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-200 disabled:opacity-50"
|
||||
>
|
||||
{adding === trip.id ? '...' : t('journey.trips.link')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
|
||||
|
||||
export function MoodChip({ mood }: { mood: string }) {
|
||||
const { t } = useTranslation()
|
||||
const config = MOOD_CONFIG[mood]
|
||||
if (!config) return null
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium" style={{ background: config.bg, color: config.text }}>
|
||||
<Icon size={11} />
|
||||
{t(config.label)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WeatherChip({ weather }: { weather: string }) {
|
||||
const { t } = useTranslation()
|
||||
const config = WEATHER_CONFIG[weather]
|
||||
if (!config) return null
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400">
|
||||
<Icon size={11} />
|
||||
{t(config.label)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useState } from 'react'
|
||||
import { ArrowLeft, ChevronRight, Calendar } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
export function DatePicker({ value, onChange, tripDates }: {
|
||||
value: string
|
||||
onChange: (date: string) => void
|
||||
tripDates?: Set<string>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [viewMonth, setViewMonth] = useState(() => {
|
||||
const d = value ? new Date(value + 'T00:00:00') : new Date()
|
||||
return { year: d.getFullYear(), month: d.getMonth() }
|
||||
})
|
||||
|
||||
const daysInMonth = new Date(viewMonth.year, viewMonth.month + 1, 0).getDate()
|
||||
const firstDow = new Date(viewMonth.year, viewMonth.month, 1).getDay()
|
||||
const monthName = new Date(viewMonth.year, viewMonth.month).toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
|
||||
|
||||
const prevMonth = () => {
|
||||
setViewMonth(p => p.month === 0 ? { year: p.year - 1, month: 11 } : { ...p, month: p.month - 1 })
|
||||
}
|
||||
const nextMonth = () => {
|
||||
setViewMonth(p => p.month === 11 ? { year: p.year + 1, month: 0 } : { ...p, month: p.month + 1 })
|
||||
}
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
|
||||
const cells: (number | null)[] = []
|
||||
for (let i = 0; i < firstDow; i++) cells.push(null)
|
||||
for (let d = 1; d <= daysInMonth; d++) cells.push(d)
|
||||
|
||||
const formatted = value ? new Date(value + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white text-left flex items-center justify-between"
|
||||
>
|
||||
{formatted ? (
|
||||
<span>{formatted}</span>
|
||||
) : (
|
||||
<span>
|
||||
<span className="hidden sm:inline">{t('journey.picker.selectDate')}</span>
|
||||
<span className="sm:hidden">{t('common.date')}</span>
|
||||
</span>
|
||||
)}
|
||||
<Calendar size={13} className="text-zinc-400" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[10]" onClick={() => setOpen(false)} />
|
||||
<div className="absolute top-full left-0 mt-1 z-[20] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]">
|
||||
{/* Month nav */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<button type="button" onClick={prevMonth} className="w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500">
|
||||
<ArrowLeft size={14} />
|
||||
</button>
|
||||
<span className="text-[13px] font-semibold text-zinc-900 dark:text-white">{monthName}</span>
|
||||
<button type="button" onClick={nextMonth} className="w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500">
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Weekday headers */}
|
||||
<div className="grid grid-cols-7 mb-1">
|
||||
{['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((d, i) => (
|
||||
<div key={i} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day grid */}
|
||||
<div className="grid grid-cols-7">
|
||||
{cells.map((day, i) => {
|
||||
if (day === null) return <div key={`e${i}`} />
|
||||
const dateStr = `${viewMonth.year}-${pad(viewMonth.month + 1)}-${pad(day)}`
|
||||
const isSelected = dateStr === value
|
||||
const isTrip = tripDates?.has(dateStr)
|
||||
const isToday = dateStr === new Date().toISOString().split('T')[0]
|
||||
|
||||
return (
|
||||
<button
|
||||
key={dateStr}
|
||||
type="button"
|
||||
onClick={() => { onChange(dateStr); setOpen(false) }}
|
||||
className={`w-9 h-9 rounded-lg text-[12px] font-medium flex items-center justify-center relative transition-colors ${
|
||||
isSelected
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: isToday
|
||||
? 'text-zinc-900 dark:text-white font-bold'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
{isTrip && !isSelected && (
|
||||
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-indigo-500" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { MapPin, Clock, MoreHorizontal, Pencil, Trash2 } from 'lucide-react'
|
||||
import { formatLocationName } from '../../utils/formatters'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
||||
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
|
||||
import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { PhotoGrid } from './JourneyDetailPagePhotoGrid'
|
||||
import { MoodChip, WeatherChip } from './JourneyDetailPageChips'
|
||||
import { ExpandableStory } from './JourneyDetailPageExpandableStory'
|
||||
import { VerdictSection } from './JourneyDetailPageVerdictSection'
|
||||
|
||||
export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
|
||||
entry: JourneyEntry
|
||||
readOnly?: boolean
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const menuBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const photos = entry.photos || []
|
||||
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null
|
||||
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null
|
||||
|
||||
const prosArr = entry.pros_cons?.pros ?? []
|
||||
const consArr = entry.pros_cons?.cons ?? []
|
||||
const hasProscons = prosArr.length > 0 || consArr.length > 0
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl overflow-hidden transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] hover:border-zinc-400 dark:hover:border-zinc-500 hover:shadow-sm">
|
||||
|
||||
{/* Hero area: photos with title overlay */}
|
||||
{photos.length > 0 ? (
|
||||
<div className="relative">
|
||||
<PhotoGrid photos={photos} onClick={(idx) => onPhotoClick(photos, idx)} />
|
||||
{/* Gradient overlay for title */}
|
||||
<div className="absolute inset-x-0 bottom-0 pointer-events-none" style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.2) 50%, transparent 100%)', height: '60%' }} />
|
||||
|
||||
{/* Badges top-left */}
|
||||
<div className="absolute top-3 left-4 right-14 flex items-center gap-1.5 z-[2]">
|
||||
{entry.location_name && (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-black/40 backdrop-blur-sm rounded-full text-[10px] font-semibold text-white tracking-wide max-w-full overflow-hidden">
|
||||
<MapPin size={10} className="flex-shrink-0" />
|
||||
<span className="truncate">{formatLocationName(entry.location_name)}</span>
|
||||
</span>
|
||||
)}
|
||||
{entry.entry_time && (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-black/40 backdrop-blur-sm rounded-full text-[10px] font-semibold text-white tracking-wide">
|
||||
<Clock size={10} />
|
||||
{entry.entry_time}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Menu top-right */}
|
||||
{!readOnly && (
|
||||
<div className="absolute top-2.5 right-3 z-[2]">
|
||||
<button ref={menuBtnRef} onClick={() => setMenuOpen(!menuOpen)} className="w-8 h-8 rounded-[10px] bg-black/40 backdrop-blur-sm flex items-center justify-center text-white hover:bg-black/50">
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
{menuOpen && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[99]" onClick={() => setMenuOpen(false)} />
|
||||
<div className="fixed z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-lg py-1 min-w-[120px]" style={{ top: (menuBtnRef.current?.getBoundingClientRect().bottom || 0) + 4, right: window.innerWidth - (menuBtnRef.current?.getBoundingClientRect().right || 0) }}>
|
||||
<button onClick={() => { setMenuOpen(false); onEdit() }} className="w-full text-left px-3 py-1.5 text-[12px] text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex items-center gap-2"><Pencil size={12} /> {t('common.edit')}</button>
|
||||
<button onClick={() => { setMenuOpen(false); onDelete() }} className="w-full text-left px-3 py-1.5 text-[12px] text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"><Trash2 size={12} /> {t('common.delete')}</button>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title on photo */}
|
||||
{entry.title && (
|
||||
<div className="absolute bottom-4 left-5 right-5 z-[2] pointer-events-none">
|
||||
<h3 className="text-[22px] font-bold text-white tracking-[-0.02em] leading-tight drop-shadow-sm">{entry.title}</h3>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* No photos: simple header */
|
||||
<div className="flex items-center justify-between px-4 pt-3">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1 mr-2">
|
||||
{entry.location_name && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-zinc-100 dark:bg-zinc-800 rounded-full text-[10px] font-semibold text-zinc-500 max-w-full overflow-hidden">
|
||||
<MapPin size={10} className="flex-shrink-0" /> <span className="truncate">{formatLocationName(entry.location_name)}</span>
|
||||
</span>
|
||||
)}
|
||||
{entry.entry_time && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-zinc-100 dark:bg-zinc-800 rounded-full text-[10px] font-semibold text-zinc-500">
|
||||
<Clock size={10} /> {entry.entry_time}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="relative">
|
||||
<button ref={menuBtnRef} onClick={() => setMenuOpen(!menuOpen)} className="w-7 h-7 rounded-md flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
{menuOpen && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[99]" onClick={() => setMenuOpen(false)} />
|
||||
<div className="fixed z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-lg py-1 min-w-[120px]" style={{ top: (menuBtnRef.current?.getBoundingClientRect().bottom || 0) + 4, right: window.innerWidth - (menuBtnRef.current?.getBoundingClientRect().right || 0) }}>
|
||||
<button onClick={() => { setMenuOpen(false); onEdit() }} className="w-full text-left px-3 py-1.5 text-[12px] text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex items-center gap-2"><Pencil size={12} /> {t('common.edit')}</button>
|
||||
<button onClick={() => { setMenuOpen(false); onDelete() }} className="w-full text-left px-3 py-1.5 text-[12px] text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"><Trash2 size={12} /> {t('common.delete')}</button>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-5 pt-4 pb-5">
|
||||
{/* Title (only if no photos — otherwise shown on image) */}
|
||||
{!photos.length && entry.title && (
|
||||
<h3 className="text-base font-semibold text-zinc-900 dark:text-white tracking-tight leading-snug mb-1">{entry.title}</h3>
|
||||
)}
|
||||
{!photos.length && entry.location_name && !entry.title && (
|
||||
<div className="mb-2" />
|
||||
)}
|
||||
{entry.story && (
|
||||
<ExpandableStory story={entry.story} />
|
||||
)}
|
||||
|
||||
{/* Pros & Cons — "Pros & Cons" style */}
|
||||
{hasProscons && (
|
||||
<VerdictSection pros={prosArr} cons={consArr} />
|
||||
)}
|
||||
|
||||
{(mood || weather || (entry.tags && entry.tags.length > 0)) && (
|
||||
<div className="flex items-center justify-between pt-3 mt-3 border-t border-zinc-100 dark:border-zinc-800">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{mood && <MoodChip mood={entry.mood!} />}
|
||||
{weather && <WeatherChip weather={entry.weather!} />}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{entry.tags?.map((tag, i) => (
|
||||
<span key={i} className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick?: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`bg-white dark:bg-zinc-900 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-xl px-4 py-3.5 flex items-center gap-3 transition-[border-color,border-style] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] ${onClick ? 'hover:border-solid hover:border-zinc-400 dark:hover:border-zinc-500 cursor-pointer' : ''}`}
|
||||
>
|
||||
<div className="w-9 h-9 rounded-lg bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-zinc-500 flex-shrink-0">
|
||||
<MapPin size={14} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] font-medium text-zinc-900 dark:text-white">
|
||||
{entry.title || t('journey.detail.newEntry')}
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-500 mt-0.5">
|
||||
{formatLocationName(entry.location_name)}{entry.entry_time ? ` · ${entry.entry_time}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-500 font-medium flex-shrink-0">
|
||||
{t('journey.detail.addEntry')} →
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CheckinCard({ entry, onClick }: { entry: JourneyEntry; onClick?: () => void }) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl px-3.5 py-2.5 flex items-center gap-2.5 transition-colors duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] ${onClick ? 'hover:border-zinc-400 dark:hover:border-zinc-500 cursor-pointer' : ''}`}
|
||||
>
|
||||
<div className="w-7 h-7 rounded-lg bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 flex items-center justify-center flex-shrink-0">
|
||||
<MapPin size={13} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] font-medium text-zinc-900 dark:text-white flex items-center gap-1.5">
|
||||
{entry.title}
|
||||
{entry.location_name && <span className="text-zinc-500 font-normal text-xs">· {entry.location_name}</span>}
|
||||
</div>
|
||||
{entry.story && <div className="text-[11px] text-zinc-500 mt-0.5">{entry.story}</div>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 flex-shrink-0">
|
||||
{entry.entry_time && <span className="text-[11px] text-zinc-400 tabular-nums">{entry.entry_time}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { X, Plus, Image, Minus, Check, MapPin } from 'lucide-react'
|
||||
import { normalizeImageFiles } from '../../utils/convertHeic'
|
||||
import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { journeyApi, mapsApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useIsMobile } from '../../hooks/useIsMobile'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import type { JourneyEntry, JourneyPhoto, GalleryPhoto } from '../../store/journeyStore'
|
||||
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
|
||||
import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import MarkdownToolbar from './MarkdownToolbar'
|
||||
import { DatePicker } from './JourneyDetailPageDatePicker'
|
||||
|
||||
export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSave, onUploadPhotos, onDone }: {
|
||||
entry: JourneyEntry
|
||||
journeyId: number
|
||||
tripDates: Set<string>
|
||||
galleryPhotos: GalleryPhoto[]
|
||||
onClose: () => void
|
||||
onSave: (data: Record<string, unknown>) => Promise<number>
|
||||
onUploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<JourneyPhoto>>
|
||||
onDone: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const isMobile = useIsMobile()
|
||||
const [title, setTitle] = useState(entry.title || '')
|
||||
const [story, setStory] = useState(entry.story || '')
|
||||
const [entryDate, setEntryDate] = useState(entry.entry_date || new Date().toISOString().split('T')[0])
|
||||
const [entryTime, setEntryTime] = useState(entry.entry_time || '')
|
||||
const [locationName, setLocationName] = useState(entry.location_name || '')
|
||||
const [locationLat, setLocationLat] = useState<number | null>(entry.location_lat ?? null)
|
||||
const [locationLng, setLocationLng] = useState<number | null>(entry.location_lng ?? null)
|
||||
const [locationQuery, setLocationQuery] = useState('')
|
||||
const [locationResults, setLocationResults] = useState<{ name: string; address?: string; lat: number; lng: number }[]>([])
|
||||
const [locationSearching, setLocationSearching] = useState(false)
|
||||
const [showLocationResults, setShowLocationResults] = useState(false)
|
||||
const locationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [mood, setMood] = useState(entry.mood || '')
|
||||
const [weather, setWeather] = useState(entry.weather || '')
|
||||
const [pros, setPros] = useState<string[]>(entry.pros_cons?.pros?.length ? entry.pros_cons.pros : [''])
|
||||
const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState<{ done: number; total: number } | null>(null)
|
||||
const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || [])
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
|
||||
const [showGalleryPick, setShowGalleryPick] = useState(false)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const storyRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// Track which fields differ from the entry we started editing so we can
|
||||
// warn before discarding on close/cancel.
|
||||
const originalPros = (entry.pros_cons?.pros ?? []).join('\n')
|
||||
const originalCons = (entry.pros_cons?.cons ?? []).join('\n')
|
||||
const isDirty = (
|
||||
title !== (entry.title || '') ||
|
||||
story !== (entry.story || '') ||
|
||||
entryDate !== (entry.entry_date || new Date().toISOString().split('T')[0]) ||
|
||||
entryTime !== (entry.entry_time || '') ||
|
||||
locationName !== (entry.location_name || '') ||
|
||||
(locationLat ?? null) !== (entry.location_lat ?? null) ||
|
||||
(locationLng ?? null) !== (entry.location_lng ?? null) ||
|
||||
mood !== (entry.mood || '') ||
|
||||
weather !== (entry.weather || '') ||
|
||||
pros.filter(p => p.trim()).join('\n') !== originalPros ||
|
||||
cons.filter(c => c.trim()).join('\n') !== originalCons ||
|
||||
pendingFiles.length > 0 ||
|
||||
pendingLinkIds.length > 0
|
||||
)
|
||||
|
||||
const availableGalleryPhotos = galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id))
|
||||
|
||||
const handleClose = () => {
|
||||
if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const entryId = await onSave({
|
||||
title: title || null,
|
||||
story: story || null,
|
||||
entry_date: entryDate,
|
||||
entry_time: entryTime || null,
|
||||
location_name: locationName || null,
|
||||
location_lat: locationLat,
|
||||
location_lng: locationLng,
|
||||
mood: mood || null,
|
||||
weather: weather || null,
|
||||
pros_cons: { pros: pros.filter(p => p.trim()), cons: cons.filter(c => c.trim()) },
|
||||
type: ((entry.type === 'skeleton' && (story.trim() || pendingFiles.length > 0 || pendingLinkIds.length > 0)) ? 'entry' : undefined),
|
||||
})
|
||||
// upload queued files after entry is created
|
||||
if (pendingFiles.length > 0 && entryId) {
|
||||
const filesToUpload = pendingFiles
|
||||
setUploadProgress({ done: 0, total: filesToUpload.length })
|
||||
try {
|
||||
const { failed } = await onUploadPhotos(entryId, filesToUpload, {
|
||||
onProgress: p => setUploadProgress({ done: p.done, total: p.total }),
|
||||
})
|
||||
setPendingFiles(failed)
|
||||
if (failed.length > 0) {
|
||||
toast.error(t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(filesToUpload.length) }))
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('journey.editor.uploadFailed')))
|
||||
} finally {
|
||||
setUploadProgress(null)
|
||||
}
|
||||
}
|
||||
// link gallery photos that were picked before save
|
||||
if (pendingLinkIds.length > 0 && entryId) {
|
||||
for (const photoId of pendingLinkIds) {
|
||||
try { await journeyApi.linkPhoto(entryId, photoId) } catch {}
|
||||
}
|
||||
}
|
||||
onDone()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files?.length) return
|
||||
// Queue files locally until Save so cancel/close actually discards. This
|
||||
// keeps photo behavior consistent with text fields — no silent persistence.
|
||||
const normalized = await normalizeImageFiles(files)
|
||||
setPendingFiles(prev => [...prev, ...normalized])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999]" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)' }}>
|
||||
{/* The modal itself is constrained to the feed column on desktop so it
|
||||
centers there — but the backdrop stays full-width (covering the map
|
||||
too) for a uniform dim/blur across the whole page. */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 left-0 flex items-end sm:items-center sm:justify-center sm:p-5"
|
||||
style={{ right: isMobile ? 0 : 'clamp(420px, 44vw, 760px)' }}
|
||||
>
|
||||
<div className="bg-white dark:bg-zinc-900 sm:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] sm:max-w-[640px] w-full flex flex-col overflow-hidden h-full sm:h-auto sm:max-h-[90vh]" style={{ paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}</h2>
|
||||
<button onClick={handleClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-5 flex flex-col gap-4">
|
||||
<input
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder={t('journey.editor.titlePlaceholder')}
|
||||
className="w-full text-[20px] font-medium bg-transparent border-0 border-b border-transparent focus:border-zinc-300 dark:focus:border-zinc-600 outline-none text-zinc-900 dark:text-white placeholder:text-zinc-400 pb-2"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<input ref={fileRef} type="file" accept="image/*" multiple onChange={handleFileChange} onClick={e => { (e.target as HTMLInputElement).value = '' }} className="hidden" />
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={saving}
|
||||
className="flex-1 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-lg py-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center gap-1.5 disabled:opacity-50"
|
||||
>
|
||||
{uploadProgress ? (
|
||||
<><div className="w-3.5 h-3.5 border-2 border-zinc-300 border-t-zinc-600 rounded-full animate-spin" /> {t('journey.editor.uploadingProgress', { done: String(uploadProgress.done), total: String(uploadProgress.total) })}</>
|
||||
) : (
|
||||
<><Plus size={13} /> {t('journey.editor.uploadPhotos')}</>
|
||||
)}
|
||||
</button>
|
||||
{galleryPhotos.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowGalleryPick(!showGalleryPick)}
|
||||
className={`flex-1 border rounded-lg py-4 text-[12px] text-zinc-500 flex items-center justify-center gap-1.5 ${
|
||||
showGalleryPick
|
||||
? 'border-zinc-900 dark:border-white bg-zinc-50 dark:bg-zinc-800'
|
||||
: 'border-dashed border-zinc-200 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
<Image size={13} /> {t('journey.editor.fromGallery')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gallery picker — directly below buttons. Safari collapses
|
||||
`aspect-square` items inside an overflow-scroll grid, so
|
||||
the square is enforced with a padding-top spacer + an
|
||||
absolutely positioned image (works across all browsers). */}
|
||||
{showGalleryPick && (
|
||||
<div className="mt-2 border border-zinc-200 dark:border-zinc-700 rounded-xl p-3 bg-zinc-50 dark:bg-zinc-800/50">
|
||||
<div className="grid grid-cols-5 sm:grid-cols-6 gap-1.5 max-h-[160px] overflow-y-auto">
|
||||
{availableGalleryPhotos.map(gp => (
|
||||
<div
|
||||
key={gp.id}
|
||||
onClick={async () => {
|
||||
if (entry.id > 0) {
|
||||
try {
|
||||
const linked = await journeyApi.linkPhoto(entry.id, gp.id)
|
||||
if (linked) setPhotos(prev => [...prev, linked])
|
||||
} catch {}
|
||||
} else {
|
||||
setPendingLinkIds(prev => [...prev, gp.id])
|
||||
setPhotos(prev => [...prev, gp])
|
||||
}
|
||||
}}
|
||||
className="relative w-full rounded-lg overflow-hidden cursor-pointer hover:ring-2 hover:ring-zinc-900 dark:hover:ring-white hover:ring-offset-1 dark:hover:ring-offset-zinc-900 transition-all"
|
||||
style={{ paddingTop: '100%' }}
|
||||
>
|
||||
<img src={photoUrl(gp)} alt="" className="absolute inset-0 w-full h-full object-cover" loading="lazy" onError={e => { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
|
||||
</div>
|
||||
))}
|
||||
{availableGalleryPhotos.length === 0 && (
|
||||
<div className="col-span-full text-center py-3 text-[11px] text-zinc-400">{t('journey.editor.allPhotosAdded')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(photos.length > 0 || pendingFiles.length > 0) && (
|
||||
<div className="mt-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{photos.map((p, idx) => (
|
||||
<div key={p.id} className={`w-20 h-20 rounded-lg overflow-hidden relative group ${idx === 0 && photos.length > 1 ? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-1 dark:ring-offset-zinc-900' : ''}`}>
|
||||
<img src={photoUrl(p)} className="w-full h-full object-cover" alt="" onError={e => { const img = e.currentTarget; const orig = photoUrl(p, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
|
||||
{idx === 0 && photos.length > 1 && (
|
||||
<span className="absolute bottom-0.5 left-0.5 px-1 py-px rounded text-[8px] font-bold bg-zinc-900/70 text-white">{t('journey.editor.photoFirst')}</span>
|
||||
)}
|
||||
{idx > 0 && photos.length > 1 && (
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
setPhotos(prev => {
|
||||
const next = [...prev]
|
||||
const [moved] = next.splice(idx, 1)
|
||||
next.unshift(moved)
|
||||
next.forEach((ph, i) => { journeyApi.updatePhoto(ph.id, { sort_order: i }).catch(() => {}) })
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className="absolute bottom-0.5 left-0.5 px-1.5 py-0.5 rounded bg-black/60 text-white text-[8px] font-semibold opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{t('journey.editor.makeFirst')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation()
|
||||
setPhotos(prev => prev.filter(x => x.id !== p.id))
|
||||
if (entry.id > 0) {
|
||||
// unlink from entry; gallery row is preserved
|
||||
try { await journeyApi.unlinkPhoto(entry.id, p.id) } catch {}
|
||||
} else {
|
||||
setPendingLinkIds(prev => prev.filter(id => id !== p.id))
|
||||
}
|
||||
}}
|
||||
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{pendingFiles.map((f, i) => (
|
||||
<div key={`pending-${i}`} className="w-20 h-20 rounded-lg overflow-hidden relative group">
|
||||
<img src={URL.createObjectURL(f)} className="w-full h-full object-cover" alt="" />
|
||||
<button
|
||||
onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
|
||||
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden focus-within:border-zinc-400 dark:focus-within:border-zinc-500">
|
||||
<MarkdownToolbar textareaRef={storyRef} onUpdate={setStory} />
|
||||
<textarea
|
||||
ref={storyRef}
|
||||
value={story}
|
||||
onChange={e => setStory(e.target.value)}
|
||||
placeholder={t('journey.editor.writeStory')}
|
||||
rows={6}
|
||||
style={{ minHeight: '144px' }}
|
||||
className="w-full px-3 py-2.5 text-[14px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none resize-none border-0 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pros & Cons */}
|
||||
<div className="bg-zinc-50 dark:bg-zinc-800/50 rounded-2xl p-5">
|
||||
<div className="mb-4">
|
||||
<span className="text-[11px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.editor.prosCons')}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Pros */}
|
||||
<div>
|
||||
<div className="flex items-center gap-[7px] mb-2.5">
|
||||
<div className="w-4 h-4 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||
<Check size={9} className="text-green-700 dark:text-green-400" strokeWidth={3.5} />
|
||||
</div>
|
||||
<span className="text-[12px] font-semibold text-green-700 dark:text-green-400">{t('journey.editor.pros')}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{pros.map((p, i) => (
|
||||
<div key={i} className="flex items-center gap-2 h-9 px-3 border rounded-[10px] border-zinc-200 dark:border-zinc-700">
|
||||
<span className="w-[5px] h-[5px] rounded-full bg-green-500 flex-shrink-0" />
|
||||
<input
|
||||
value={p}
|
||||
onChange={e => { const next = [...pros]; next[i] = e.target.value; setPros(next) }}
|
||||
placeholder={t('journey.editor.proPlaceholder')}
|
||||
className="flex-1 min-w-0 bg-transparent border-none outline-none text-[13px] text-zinc-900 dark:text-zinc-100 placeholder:text-green-400 dark:placeholder:text-green-600"
|
||||
/>
|
||||
{pros.length > 1 && (
|
||||
<button onClick={() => setPros(pros.filter((_, j) => j !== i))} className="p-1 text-green-300 dark:text-green-700 hover:text-green-600 dark:hover:text-green-400 flex-shrink-0">
|
||||
<X size={13} strokeWidth={2.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setPros([...pros, ''])}
|
||||
className="flex items-center justify-center gap-1.5 h-9 w-full border border-dashed border-green-200 dark:border-green-800/40 rounded-[10px] text-[12px] font-medium text-green-700 dark:text-green-400 hover:border-green-300 dark:hover:border-green-700 transition-colors"
|
||||
>
|
||||
<Plus size={13} strokeWidth={2.5} /> {t('journey.editor.addAnother')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cons */}
|
||||
<div>
|
||||
<div className="flex items-center gap-[7px] mb-2.5">
|
||||
<div className="w-4 h-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
|
||||
<Minus size={9} className="text-red-700 dark:text-red-400" strokeWidth={3.5} />
|
||||
</div>
|
||||
<span className="text-[12px] font-semibold text-red-700 dark:text-red-400">{t('journey.editor.cons')}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{cons.map((c, i) => (
|
||||
<div key={i} className="flex items-center gap-2 h-9 px-3 border rounded-[10px] border-zinc-200 dark:border-zinc-700">
|
||||
<span className="w-[5px] h-[5px] rounded-full bg-red-500 flex-shrink-0" />
|
||||
<input
|
||||
value={c}
|
||||
onChange={e => { const next = [...cons]; next[i] = e.target.value; setCons(next) }}
|
||||
placeholder={t('journey.editor.conPlaceholder')}
|
||||
className="flex-1 min-w-0 bg-transparent border-none outline-none text-[13px] text-zinc-900 dark:text-zinc-100 placeholder:text-red-400 dark:placeholder:text-red-600"
|
||||
/>
|
||||
{cons.length > 1 && (
|
||||
<button onClick={() => setCons(cons.filter((_, j) => j !== i))} className="p-1 text-red-300 dark:text-red-700 hover:text-red-600 dark:hover:text-red-400 flex-shrink-0">
|
||||
<X size={13} strokeWidth={2.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setCons([...cons, ''])}
|
||||
className="flex items-center justify-center gap-1.5 h-9 w-full border border-dashed border-red-200 dark:border-red-800/40 rounded-[10px] text-[12px] font-medium text-red-700 dark:text-red-400 hover:border-red-300 dark:hover:border-red-700 transition-colors"
|
||||
>
|
||||
<Plus size={13} strokeWidth={2.5} /> {t('journey.editor.addAnother')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-zinc-200 dark:bg-zinc-700" />
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.editor.date')}</label>
|
||||
<DatePicker value={entryDate} onChange={setEntryDate} tripDates={tripDates} />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.editor.location')}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
value={locationQuery || locationName}
|
||||
onChange={e => {
|
||||
const q = e.target.value
|
||||
setLocationQuery(q)
|
||||
setShowLocationResults(true)
|
||||
if (locationTimerRef.current) clearTimeout(locationTimerRef.current)
|
||||
if (q.trim().length >= 2) {
|
||||
locationTimerRef.current = setTimeout(async () => {
|
||||
setLocationSearching(true)
|
||||
try {
|
||||
const res = await mapsApi.search(q)
|
||||
setLocationResults((res.places || []).slice(0, 6).map((p: any) => ({
|
||||
name: p.name, address: p.address, lat: Number(p.lat), lng: Number(p.lng),
|
||||
})))
|
||||
} catch { setLocationResults([]) }
|
||||
finally { setLocationSearching(false) }
|
||||
}, 400)
|
||||
} else {
|
||||
setLocationResults([])
|
||||
}
|
||||
}}
|
||||
onFocus={() => { if (locationResults.length > 0) setShowLocationResults(true) }}
|
||||
placeholder={t('journey.editor.searchLocation')}
|
||||
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400 dark:focus:border-zinc-500"
|
||||
/>
|
||||
{locationLat && (
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||
<MapPin size={13} className="text-zinc-500 dark:text-zinc-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showLocationResults && locationResults.length > 0 && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[99]" onClick={() => setShowLocationResults(false)} />
|
||||
<div className="absolute left-0 right-0 top-full mt-1 z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg overflow-hidden max-h-[240px] overflow-y-auto">
|
||||
{locationResults.map((r, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => {
|
||||
setLocationName(r.name)
|
||||
setLocationLat(r.lat)
|
||||
setLocationLng(r.lng)
|
||||
setLocationQuery('')
|
||||
setShowLocationResults(false)
|
||||
setLocationResults([])
|
||||
}}
|
||||
className="w-full text-left px-3 py-2.5 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex items-start gap-2.5 border-b border-zinc-100 dark:border-zinc-700 last:border-0"
|
||||
>
|
||||
<MapPin size={13} className="text-zinc-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13px] font-medium text-zinc-900 dark:text-white truncate">{r.name}</div>
|
||||
{r.address && <div className="text-[11px] text-zinc-500 truncate">{r.address}</div>}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{locationSearching && (
|
||||
<div className="absolute left-0 right-0 top-full mt-1 z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg px-3 py-3 text-center text-[12px] text-zinc-400">
|
||||
{t('journey.editor.searching')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.editor.mood')}</label>
|
||||
<div className="flex gap-2">
|
||||
{Object.entries(MOOD_CONFIG).map(([key, config]) => {
|
||||
const Icon = config.icon
|
||||
const active = mood === key
|
||||
return (
|
||||
<button key={key} onClick={() => setMood(active ? '' : key)}
|
||||
className={`flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-medium border transition-all ${
|
||||
active ? '' : 'border-zinc-200 dark:border-zinc-700 text-zinc-500'
|
||||
}`}
|
||||
style={active ? { background: config.bg, color: config.text, borderColor: config.text + '30' } : undefined}>
|
||||
<Icon size={12} />
|
||||
{t(config.label)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.editor.weather')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(WEATHER_CONFIG).map(([key, config]) => {
|
||||
const Icon = config.icon
|
||||
const active = weather === key
|
||||
return (
|
||||
<button key={key} onClick={() => setWeather(active ? '' : key)}
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded-full text-[11px] font-medium border transition-all ${
|
||||
active ? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white' : 'border-zinc-200 dark:border-zinc-700 text-zinc-500 hover:border-zinc-400'
|
||||
}`}>
|
||||
<Icon size={12} />
|
||||
{t(config.label)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50" style={{ paddingBottom: 'max(16px, env(safe-area-inset-bottom, 16px))' }}>
|
||||
<button onClick={handleClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
|
||||
<button onClick={handleSave} disabled={saving} className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50">
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import JournalBody from './JournalBody'
|
||||
|
||||
export function ExpandableStory({ story }: { story: string }) {
|
||||
const { t } = useTranslation()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [clamped, setClamped] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const measuredRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
measuredRef.current = false
|
||||
}, [story])
|
||||
|
||||
useEffect(() => {
|
||||
if (measuredRef.current) return
|
||||
const el = ref.current
|
||||
if (el && !expanded) {
|
||||
setClamped(el.scrollHeight > el.clientHeight)
|
||||
measuredRef.current = true
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
ref={ref}
|
||||
onClick={() => { if (clamped || expanded) setExpanded(e => !e) }}
|
||||
className={`text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed ${
|
||||
expanded ? '' : 'line-clamp-3 md:line-clamp-[9]'
|
||||
} ${clamped || expanded ? 'cursor-pointer' : ''}`}
|
||||
>
|
||||
<JournalBody text={story} />
|
||||
</div>
|
||||
{clamped && !expanded && (
|
||||
<button
|
||||
onClick={() => setExpanded(true)}
|
||||
className="mt-2 inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700 active:scale-95 transition-all"
|
||||
>
|
||||
{t('common.showMore')} <ChevronRight size={10} />
|
||||
</button>
|
||||
)}
|
||||
{expanded && (
|
||||
<button
|
||||
onClick={() => setExpanded(false)}
|
||||
className="mt-2 inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700 active:scale-95 transition-all"
|
||||
>
|
||||
{t('common.showLess')} <ChevronRight size={10} className="rotate-[-90deg]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { RefreshCw, Camera, Image, Plus, X } from 'lucide-react'
|
||||
import { normalizeImageFiles } from '../../utils/convertHeic'
|
||||
import { useJourneyStore } from '../../store/journeyStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { journeyApi, addonsApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import type { JourneyEntry, GalleryPhoto, JourneyTrip } from '../../store/journeyStore'
|
||||
import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { ProviderPicker } from './JourneyDetailPageProviderPicker'
|
||||
|
||||
export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh }: {
|
||||
entries: JourneyEntry[]
|
||||
gallery: GalleryPhoto[]
|
||||
journeyId: number
|
||||
userId: number
|
||||
trips: JourneyTrip[]
|
||||
onPhotoClick: (photos: GalleryPhoto[], index: number) => void
|
||||
onRefresh: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [showPicker, setShowPicker] = useState(false)
|
||||
const [pickerProvider, setPickerProvider] = useState<string | null>(null)
|
||||
const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([])
|
||||
const [galleryProgress, setGalleryProgress] = useState<{ done: number; total: number } | null>(null)
|
||||
const galleryUploading = galleryProgress !== null
|
||||
const toast = useToast()
|
||||
|
||||
// check which providers are enabled AND connected for the current user
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const addonsData = await addonsApi.enabled()
|
||||
const enabledProviders = (addonsData.addons || []).filter(
|
||||
(a: any) => a.type === 'photo_provider' && a.enabled
|
||||
)
|
||||
const connected: { id: string; name: string }[] = []
|
||||
for (const p of enabledProviders) {
|
||||
try {
|
||||
const res = await fetch(`/api/integrations/memories/${p.id}/status`, { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const status = await res.json()
|
||||
if (status.connected) connected.push({ id: p.id, name: p.name })
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
setAvailableProviders(connected)
|
||||
} catch {}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const allPhotos = gallery
|
||||
|
||||
const entriesWithContent = entries.filter(e => e.type !== 'skeleton' || e.title)
|
||||
|
||||
const browseProvider = (provider: string) => {
|
||||
setPickerProvider(provider)
|
||||
setShowPicker(true)
|
||||
}
|
||||
|
||||
const galleryFileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleGalleryUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files?.length) return
|
||||
setGalleryProgress({ done: 0, total: files.length })
|
||||
try {
|
||||
const normalized = await normalizeImageFiles(files)
|
||||
const { failed } = await useJourneyStore.getState().uploadGalleryPhotos(journeyId, normalized, {
|
||||
onProgress: p => setGalleryProgress({ done: p.done, total: p.total }),
|
||||
})
|
||||
if (failed.length > 0) {
|
||||
toast.error(t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(normalized.length) }))
|
||||
} else {
|
||||
toast.success(t('journey.photosUploaded', { count: String(files.length) }))
|
||||
}
|
||||
onRefresh()
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('journey.photosUploadFailed')))
|
||||
} finally {
|
||||
setGalleryProgress(null)
|
||||
}
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const handleDeletePhoto = async (galleryPhotoId: number) => {
|
||||
const store = useJourneyStore.getState()
|
||||
if (!store.current) return
|
||||
|
||||
// Optimistic update — remove from gallery and all entry photo lists
|
||||
useJourneyStore.setState({
|
||||
current: {
|
||||
...store.current,
|
||||
gallery: (store.current.gallery || []).filter(p => p.id !== galleryPhotoId),
|
||||
entries: store.current.entries.map(e => ({
|
||||
...e,
|
||||
photos: e.photos.filter(p => p.id !== galleryPhotoId),
|
||||
})),
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await journeyApi.deleteGalleryPhoto(journeyId, galleryPhotoId)
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
onRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input ref={galleryFileRef} type="file" accept="image/*" multiple onChange={handleGalleryUpload} className="hidden" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 dark:text-zinc-400">
|
||||
<Camera size={10} /> {allPhotos.length} {t('journey.detail.photos')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => galleryFileRef.current?.click()}
|
||||
disabled={galleryUploading}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50"
|
||||
>
|
||||
{galleryUploading ? (
|
||||
<><div className="w-3 h-3 border-2 border-white/30 dark:border-zinc-900/30 border-t-white dark:border-t-zinc-900 rounded-full animate-spin" /> {galleryProgress ? t('journey.editor.uploadingProgress', { done: String(galleryProgress.done), total: String(galleryProgress.total) }) : t('journey.editor.uploading')}</>
|
||||
) : (
|
||||
<><Plus size={12} /> {t('common.upload')}</>
|
||||
)}
|
||||
</button>
|
||||
{availableProviders.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => browseProvider(p.id)}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-[11px] font-medium text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<Image size={12} />
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{allPhotos.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<div className="w-16 h-16 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mx-auto mb-4">
|
||||
<Image size={24} className="text-zinc-400" />
|
||||
</div>
|
||||
<p className="text-[15px] font-medium text-zinc-700 dark:text-zinc-300">{t('journey.detail.noPhotos')}</p>
|
||||
<p className="text-[12px] text-zinc-500 mt-1">{t('journey.detail.noPhotosHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 pb-24 md:pb-6">
|
||||
{allPhotos.map((photo, i) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
|
||||
onClick={() => onPhotoClick(allPhotos, i)}
|
||||
>
|
||||
<img
|
||||
src={photoUrl(photo, 'thumbnail')}
|
||||
alt={photo.caption || ''}
|
||||
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
|
||||
{/* Delete button */}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDeletePhoto(photo.id) }}
|
||||
className="absolute top-1.5 right-1.5 w-6 h-6 rounded-full bg-black/60 backdrop-blur text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
{photo.provider && photo.provider !== 'local' && (
|
||||
<div className="absolute top-1.5 left-1.5">
|
||||
<span className="text-[8px] font-medium px-1.5 py-0.5 rounded-full bg-black/70 backdrop-blur text-white flex items-center gap-1">
|
||||
<RefreshCw size={7} />
|
||||
{photo.provider === 'immich' ? 'Immich' : photo.provider === 'synology' ? 'Synology' : photo.provider}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{photo.caption && (
|
||||
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<p className="text-[10px] text-white truncate">{photo.caption}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Provider Photo Picker Modal */}
|
||||
{showPicker && (
|
||||
<ProviderPicker
|
||||
provider={pickerProvider!}
|
||||
userId={userId}
|
||||
entries={entriesWithContent}
|
||||
trips={trips}
|
||||
existingAssetIds={new Set(gallery.filter(p => p.asset_id).map(p => p.asset_id!))}
|
||||
onClose={() => setShowPicker(false)}
|
||||
onAdd={async (groups, entryId) => {
|
||||
let added = 0
|
||||
let anyFailed = false
|
||||
for (const group of groups) {
|
||||
try {
|
||||
if (entryId) {
|
||||
const result = await journeyApi.addProviderPhotos(entryId, pickerProvider!, group.assetIds, undefined, group.passphrase)
|
||||
added += result.added || 0
|
||||
} else {
|
||||
const result = await journeyApi.addProviderPhotosToGallery(journeyId, pickerProvider!, group.assetIds, group.passphrase)
|
||||
added += result.added || 0
|
||||
}
|
||||
} catch {
|
||||
anyFailed = true
|
||||
}
|
||||
}
|
||||
if (added > 0) {
|
||||
toast.success(t('journey.photosAdded', { count: added }))
|
||||
onRefresh()
|
||||
} else if (anyFailed) {
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
setShowPicker(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useMemo } from 'react'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { formatLocationName } from '../../utils/formatters'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import JourneyMap from './JourneyMapAuto'
|
||||
import type { JourneyMapAutoHandle as JourneyMapHandle } from './JourneyMapAuto'
|
||||
import type { JourneyEntry } from '../../store/journeyStore'
|
||||
import { formatDate } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
|
||||
export function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRef, onLocationClick }: {
|
||||
entries: JourneyEntry[]
|
||||
mapEntries: JourneyEntry[]
|
||||
sortedDates: string[]
|
||||
activeLocationId: string | null
|
||||
fullMapRef: React.RefObject<JourneyMapHandle | null>
|
||||
onLocationClick: (id: string) => void
|
||||
}) {
|
||||
const { t, locale } = useTranslation()
|
||||
// group map entries by date
|
||||
const byDate = new Map<string, { entry: JourneyEntry; globalIdx: number }[]>()
|
||||
mapEntries.forEach((e, i) => {
|
||||
const d = e.entry_date
|
||||
if (!byDate.has(d)) byDate.set(d, [])
|
||||
byDate.get(d)!.push({ entry: e, globalIdx: i })
|
||||
})
|
||||
const dates = [...byDate.keys()].sort()
|
||||
|
||||
// find first and last entry indices
|
||||
const firstId = mapEntries[0]?.id
|
||||
const lastId = mapEntries[mapEntries.length - 1]?.id
|
||||
|
||||
const mapItems = useMemo(() => mapEntries.map(e => ({
|
||||
id: String(e.id),
|
||||
lat: e.location_lat!,
|
||||
lng: e.location_lng!,
|
||||
title: e.title || '',
|
||||
mood: e.mood,
|
||||
entry_date: e.entry_date,
|
||||
})), [mapEntries])
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden">
|
||||
<JourneyMap
|
||||
ref={fullMapRef}
|
||||
checkins={[]}
|
||||
entries={mapItems as any}
|
||||
height={560}
|
||||
activeMarkerId={activeLocationId}
|
||||
onMarkerClick={onLocationClick}
|
||||
/>
|
||||
|
||||
{/* Locations list */}
|
||||
<div>
|
||||
{/* Stats header */}
|
||||
{mapEntries.length > 0 && (
|
||||
<div className="mx-5 mt-4 mb-2 grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ value: mapEntries.length, label: t('journey.stats.places') },
|
||||
{ value: dates.length, label: t('journey.stats.days') },
|
||||
{ value: entries.filter(e => e.type === 'entry').length, label: 'Stories' },
|
||||
].map(s => (
|
||||
<div key={s.label} className="bg-zinc-50 dark:bg-zinc-800 rounded-xl p-3 text-center">
|
||||
<div className="text-[17px] font-bold text-zinc-900 dark:text-white tracking-tight">{s.value}</div>
|
||||
<div className="text-[9px] font-medium text-zinc-500 uppercase tracking-[0.06em]">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Day groups */}
|
||||
<div className="px-5 pb-5">
|
||||
{dates.map((date, dayIdx) => {
|
||||
const items = byDate.get(date)!
|
||||
const fd = formatDate(date, locale)
|
||||
|
||||
return (
|
||||
<div key={date}>
|
||||
{/* Day separator */}
|
||||
<div className="flex items-center gap-2.5 py-3">
|
||||
<span className="text-[10px] font-bold text-zinc-500 dark:text-zinc-400 tracking-[0.12em] uppercase">{t('journey.detail.day', { number: dayIdx + 1 })}</span>
|
||||
<span className="text-[10px] text-zinc-400 font-medium">{fd.month} {fd.day}</span>
|
||||
<div className="flex-1 h-px bg-zinc-200 dark:bg-zinc-700" />
|
||||
</div>
|
||||
|
||||
{/* Location items */}
|
||||
{items.map(({ entry: e, globalIdx }, itemIdx) => {
|
||||
const isActive = activeLocationId === String(e.id)
|
||||
const isFirst = e.id === firstId
|
||||
const isLast = e.id === lastId
|
||||
const showConnector = itemIdx < items.length - 1
|
||||
|
||||
return (
|
||||
<div key={e.id}>
|
||||
<div
|
||||
onClick={() => onLocationClick(String(e.id))}
|
||||
className={`flex items-center gap-3 p-3 rounded-[14px] cursor-pointer transition-all ${
|
||||
isActive
|
||||
? 'bg-zinc-100 dark:bg-zinc-800 border border-zinc-900 dark:border-zinc-100 translate-x-0.5'
|
||||
: 'bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-500 hover:translate-x-0.5'
|
||||
}`}
|
||||
>
|
||||
{/* Number badge */}
|
||||
<div className={`w-9 h-9 rounded-full flex items-center justify-center text-[13px] font-bold flex-shrink-0 border-2 border-white dark:border-zinc-900 ${
|
||||
isActive
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-[0_0_0_2px_rgba(0,0,0,0.15)]'
|
||||
: 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-[0_0_0_1px_rgba(0,0,0,0.1)]'
|
||||
}`}>
|
||||
{globalIdx + 1}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<span className="text-[14px] font-semibold text-zinc-900 dark:text-white truncate">{e.title || e.location_name}</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-500 truncate">
|
||||
{formatLocationName(e.location_name)}{e.entry_time ? ` · ${e.entry_time}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chevron */}
|
||||
<ChevronRight size={14} className={`flex-shrink-0 ${isActive ? 'text-zinc-900 dark:text-white' : 'text-zinc-300 dark:text-zinc-600'}`} />
|
||||
</div>
|
||||
|
||||
{/* Connector line */}
|
||||
{showConnector && (
|
||||
<div className="w-0.5 h-2 bg-zinc-200 dark:bg-zinc-700 ml-[18px] rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Image } from 'lucide-react'
|
||||
import type { JourneyPhoto } from '../../store/journeyStore'
|
||||
import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
|
||||
export function PhotoImg({ photo, className, style, onClick }: { photo: JourneyPhoto; className?: string; style?: React.CSSProperties; onClick?: () => void }) {
|
||||
const src = photoUrl(photo, 'thumbnail')
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
className={className}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
loading="lazy"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function PhotoGrid({ photos, onClick }: { photos: JourneyPhoto[]; onClick: (idx: number) => void }) {
|
||||
const count = photos.length
|
||||
if (count === 0) return null
|
||||
|
||||
if (count === 1) {
|
||||
return (
|
||||
<div className="overflow-hidden cursor-pointer" onClick={() => onClick(0)}>
|
||||
<PhotoImg photo={photos[0]} className="w-full h-72 object-cover" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (count === 2) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-0.5 overflow-hidden">
|
||||
{photos.slice(0, 2).map((p, i) => (
|
||||
<PhotoImg key={p.id} photo={p} className="w-full h-52 object-cover cursor-pointer" onClick={() => onClick(i)} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden flex" style={{ height: 300, gap: 2 }}>
|
||||
<div className="flex-1 min-w-0 cursor-pointer" onClick={() => onClick(0)}>
|
||||
<PhotoImg photo={photos[0]} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col" style={{ gap: 2 }}>
|
||||
<div className="flex-1 min-h-0 cursor-pointer" onClick={() => onClick(1)}>
|
||||
<PhotoImg photo={photos[1]} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 relative cursor-pointer" onClick={() => onClick(2)}>
|
||||
<PhotoImg photo={photos[2]} className="w-full h-full object-cover" />
|
||||
{count > 3 && (
|
||||
<div className="absolute bottom-2 right-2 bg-black/60 backdrop-blur text-white rounded-full px-2 py-0.5 text-[10px] font-semibold flex items-center gap-1">
|
||||
<Image size={10} />
|
||||
+{count - 3}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
import { useEffect, useState, useRef, useMemo } from 'react'
|
||||
import { X, Check, Calendar, ChevronRight, Camera } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { JourneyEntry, JourneyTrip } from '../../store/journeyStore'
|
||||
import { groupPhotosByDate } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger'
|
||||
import { DatePicker } from './JourneyDetailPageDatePicker'
|
||||
|
||||
export function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, onClose, onAdd }: {
|
||||
provider: string
|
||||
userId: number
|
||||
entries: JourneyEntry[]
|
||||
trips: JourneyTrip[]
|
||||
existingAssetIds: Set<string>
|
||||
onClose: () => void
|
||||
onAdd: (groups: Array<{ assetIds: string[]; passphrase?: string }>, entryId: number | null) => Promise<void>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [filter, setFilter] = useState<'trip' | 'custom' | 'all' | 'album'>('trip')
|
||||
const [photos, setPhotos] = useState<any[]>([])
|
||||
const [albums, setAlbums] = useState<Array<{ id: string; albumName: string; assetCount: number; passphrase?: string }>>([])
|
||||
const [selectedAlbum, setSelectedAlbum] = useState<string | null>(null)
|
||||
const [selectedAlbumPassphrase, setSelectedAlbumPassphrase] = useState<string | undefined>(undefined)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [searchPage, setSearchPage] = useState(1)
|
||||
const [searchFrom, setSearchFrom] = useState('')
|
||||
const [searchTo, setSearchTo] = useState('')
|
||||
const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string }>>(new Map())
|
||||
const [customFrom, setCustomFrom] = useState('')
|
||||
const [customTo, setCustomTo] = useState('')
|
||||
const [targetEntryId, setTargetEntryId] = useState<number | null>(null)
|
||||
const [addToOpen, setAddToOpen] = useState(false)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// compute trip range
|
||||
const tripRange = useMemo(() => {
|
||||
let from = '', to = ''
|
||||
for (const t of trips) {
|
||||
if (t.start_date && (!from || t.start_date < from)) from = t.start_date
|
||||
if (t.end_date && (!to || t.end_date > to)) to = t.end_date
|
||||
}
|
||||
return { from, to }
|
||||
}, [trips])
|
||||
|
||||
const cancelPending = () => {
|
||||
if (abortRef.current) { abortRef.current.abort() }
|
||||
abortRef.current = new AbortController()
|
||||
return abortRef.current.signal
|
||||
}
|
||||
|
||||
const searchPhotos = async (from: string, to: string, page: number = 1, append: boolean = false) => {
|
||||
const signal = cancelPending()
|
||||
if (page === 1) { setLoading(true); setPhotos([]) } else { setLoadingMore(true) }
|
||||
setSearchFrom(from)
|
||||
setSearchTo(to)
|
||||
setSearchPage(page)
|
||||
try {
|
||||
const res = await fetch(`/api/integrations/memories/${provider}/search`, {
|
||||
method: 'POST', credentials: 'include', signal,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ from, to, page, size: 50 }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const assets = data.assets || []
|
||||
setPhotos(prev => append ? [...prev, ...assets] : assets)
|
||||
setHasMore(!!data.hasMore)
|
||||
} else {
|
||||
setHasMore(false)
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.name !== 'AbortError') setHasMore(false)
|
||||
}
|
||||
if (!signal.aborted) { setLoading(false); setLoadingMore(false) }
|
||||
}
|
||||
|
||||
const loadMorePhotos = () => {
|
||||
if (loadingMore || !hasMore) return
|
||||
searchPhotos(searchFrom, searchTo, searchPage + 1, true)
|
||||
}
|
||||
|
||||
const loadAlbumPhotos = async (album: { id: string; passphrase?: string }) => {
|
||||
const signal = cancelPending()
|
||||
setLoading(true)
|
||||
setPhotos([])
|
||||
setHasMore(false)
|
||||
try {
|
||||
const qs = album.passphrase ? `?passphrase=${encodeURIComponent(album.passphrase)}` : ''
|
||||
const res = await fetch(`/api/integrations/memories/${provider}/albums/${album.id}/photos${qs}`, { credentials: 'include', signal })
|
||||
if (res.ok) setPhotos((await res.json()).assets || [])
|
||||
} catch (e: any) { if (e.name !== 'AbortError') {} }
|
||||
if (!signal.aborted) setLoading(false)
|
||||
}
|
||||
|
||||
const loadAlbums = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/integrations/memories/${provider}/albums`, { credentials: 'include' })
|
||||
if (res.ok) setAlbums((await res.json()).albums || [])
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// load on mount / filter change
|
||||
useEffect(() => {
|
||||
if (filter === 'trip' && tripRange.from && tripRange.to) {
|
||||
searchPhotos(tripRange.from, tripRange.to)
|
||||
} else if (filter === 'all') {
|
||||
searchPhotos('', '')
|
||||
} else if (filter === 'album' && albums.length === 0) {
|
||||
loadAlbums()
|
||||
}
|
||||
}, [filter])
|
||||
|
||||
const handleCustomSearch = () => {
|
||||
if (customFrom && customTo) searchPhotos(customFrom, customTo)
|
||||
}
|
||||
|
||||
const toggleAsset = (id: string) => {
|
||||
setSelected(prev => {
|
||||
const next = new Map(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase })
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const targetLabel = targetEntryId
|
||||
? entries.find(e => e.id === targetEntryId)?.title || entries.find(e => e.id === targetEntryId)?.entry_date || t('journey.stats.entries')
|
||||
: t('journey.picker.newGallery')
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] flex items-end md:items-center justify-center md:p-5 overscroll-none bg-[rgba(9,9,11,0.75)]" onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] md:max-h-[85vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
|
||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
|
||||
{provider === 'immich' ? 'Immich' : 'Synology Photos'}
|
||||
</h2>
|
||||
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
<div className="px-6 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1.5 mb-3">
|
||||
{[
|
||||
{ id: 'trip' as const, label: t('journey.picker.tripPeriod') },
|
||||
{ id: 'custom' as const, label: t('journey.picker.dateRange') },
|
||||
{ id: 'all' as const, label: t('journey.picker.allPhotos'), short: t('common.all') },
|
||||
{ id: 'album' as const, label: t('journey.picker.albums') },
|
||||
].map(f => (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => setFilter(f.id)}
|
||||
className={`px-3 py-1.5 rounded-lg text-[12px] font-medium transition-colors ${
|
||||
filter === f.id
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
{f.short ? (
|
||||
<>
|
||||
<span className="hidden sm:inline">{f.label}</span>
|
||||
<span className="sm:hidden">{f.short}</span>
|
||||
</>
|
||||
) : f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filter content — always visible row */}
|
||||
<div className="min-h-[36px] flex items-center">
|
||||
{filter === 'trip' && (
|
||||
<div className="flex items-center gap-2 text-[12px] text-zinc-500">
|
||||
{tripRange.from && tripRange.to ? (
|
||||
<>
|
||||
<Calendar size={13} className="text-zinc-400" />
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(tripRange.from + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
<span className="text-zinc-400">—</span>
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(tripRange.to + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</span>
|
||||
<span className="ml-1 text-zinc-400">
|
||||
({Math.ceil((new Date(tripRange.to).getTime() - new Date(tripRange.from).getTime()) / 86400000) + 1} days)
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-zinc-400">{t('journey.trips.noTripsLinkedSettings')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filter === 'custom' && (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<div className="flex-1"><DatePicker value={customFrom} onChange={setCustomFrom} /></div>
|
||||
<span className="text-zinc-400 text-[12px]">—</span>
|
||||
<div className="flex-1"><DatePicker value={customTo} onChange={setCustomTo} /></div>
|
||||
<button onClick={handleCustomSearch}
|
||||
className="px-3 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[12px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 flex-shrink-0">
|
||||
{t('journey.picker.search')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filter === 'album' && (
|
||||
<div className="flex gap-2 overflow-x-auto flex-1">
|
||||
{albums.map((a: any) => (
|
||||
<button
|
||||
key={a.id}
|
||||
onClick={() => { setSelectedAlbum(a.id); setSelectedAlbumPassphrase(a.passphrase); loadAlbumPhotos(a) }}
|
||||
className={`px-2.5 py-1 rounded-lg text-[11px] font-medium whitespace-nowrap flex-shrink-0 border ${
|
||||
selectedAlbum === a.id
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white'
|
||||
: 'border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
{a.albumName || a.name || 'Album'}{a.assetCount != null ? ` (${a.assetCount})` : ''}
|
||||
</button>
|
||||
))}
|
||||
{albums.length === 0 && !loading && <span className="text-[12px] text-zinc-400">{t('journey.picker.noAlbums')}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add-to entry selector */}
|
||||
<div className="px-6 py-2.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex-shrink-0">
|
||||
<div className="relative flex items-center gap-2">
|
||||
<span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.picker.addTo')}</span>
|
||||
<button
|
||||
onClick={() => setAddToOpen(!addToOpen)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-[12px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<span className={targetEntryId ? '' : 'font-semibold'}>{targetLabel}</span>
|
||||
<ChevronRight size={12} className="rotate-90 text-zinc-400" />
|
||||
</button>
|
||||
{addToOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9]" onClick={() => setAddToOpen(false)} />
|
||||
<div className="absolute left-12 top-full mt-1 z-10 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg py-1.5 min-w-[200px] max-h-[240px] overflow-y-auto">
|
||||
<button
|
||||
onClick={() => { setTargetEntryId(null); setAddToOpen(false) }}
|
||||
className={`w-full text-left px-3 py-2 text-[12px] flex items-center gap-2 ${
|
||||
!targetEntryId
|
||||
? 'bg-zinc-100 dark:bg-zinc-700 font-semibold text-zinc-900 dark:text-white'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<Camera size={12} />
|
||||
{t('journey.picker.newGallery')}
|
||||
</button>
|
||||
{entries.filter(e => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]').length > 0 && (
|
||||
<div className="h-px bg-zinc-200 dark:bg-zinc-700 my-1" />
|
||||
)}
|
||||
{entries.filter(e => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]').map(e => (
|
||||
<button
|
||||
key={e.id}
|
||||
onClick={() => { setTargetEntryId(e.id); setAddToOpen(false) }}
|
||||
className={`w-full text-left px-3 py-2 text-[12px] truncate ${
|
||||
targetEntryId === e.id
|
||||
? 'bg-zinc-100 dark:bg-zinc-700 font-semibold text-zinc-900 dark:text-white'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{e.title || e.location_name || new Date(e.entry_date + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short' })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Select all bar — sticky above grid */}
|
||||
{!loading && photos.length > 0 && (() => {
|
||||
const selectable = photos.filter((a: any) => !existingAssetIds.has(a.id))
|
||||
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id))
|
||||
if (selectable.length === 0) return null
|
||||
return (
|
||||
<div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (allSelected) {
|
||||
setSelected(new Map())
|
||||
} else {
|
||||
setSelected(new Map(selectable.map((a: any) => [a.id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase }])))
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-medium border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<div className={`w-3.5 h-3.5 rounded border flex items-center justify-center ${
|
||||
allSelected
|
||||
? 'bg-zinc-900 dark:bg-white border-zinc-900 dark:border-white'
|
||||
: 'border-zinc-300 dark:border-zinc-600'
|
||||
}`}>
|
||||
{allSelected && <Check size={9} className="text-white dark:text-zinc-900" strokeWidth={3} />}
|
||||
</div>
|
||||
{allSelected ? t('journey.picker.deselectAll') : t('journey.picker.selectAll')} ({selectable.length})
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Photo grid */}
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain p-4 min-h-0">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
|
||||
</div>
|
||||
) : photos.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-[13px] text-zinc-500">
|
||||
{filter === 'trip' && !tripRange.from ? t('journey.trips.noTripsLinkedSettings') : t('journey.detail.noPhotos')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{groupPhotosByDate(photos).map(group => (
|
||||
<div key={group.date}>
|
||||
<p className="text-[11px] font-medium text-zinc-500 dark:text-zinc-400 mb-2 mt-4 first:mt-0">
|
||||
{group.label}
|
||||
</p>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-1.5 mb-1">
|
||||
{group.assets.map((asset: any) => {
|
||||
const isSelected = selected.has(asset.id)
|
||||
const alreadyAdded = existingAssetIds.has(asset.id)
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
onClick={() => !alreadyAdded && toggleAsset(asset.id)}
|
||||
className={`relative aspect-square rounded-lg overflow-hidden ${
|
||||
alreadyAdded
|
||||
? 'opacity-40 cursor-not-allowed'
|
||||
: isSelected
|
||||
? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-2 dark:ring-offset-zinc-900 cursor-pointer'
|
||||
: 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={`/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/thumbnail${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onError={e => {
|
||||
const img = e.currentTarget
|
||||
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`
|
||||
if (!img.src.includes('/original')) img.src = original
|
||||
}}
|
||||
/>
|
||||
{alreadyAdded && (
|
||||
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-500 text-white flex items-center justify-center">
|
||||
<Check size={12} />
|
||||
</div>
|
||||
)}
|
||||
{isSelected && !alreadyAdded && (
|
||||
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center">
|
||||
<Check size={12} />
|
||||
</div>
|
||||
)}
|
||||
{asset.city && (
|
||||
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/50 to-transparent">
|
||||
<p className="text-[8px] text-white truncate">{asset.city}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Infinite scroll trigger */}
|
||||
{hasMore && !selectedAlbum && <ScrollTrigger onVisible={loadMorePhotos} loading={loadingMore} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex-shrink-0">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-200/60 dark:bg-zinc-700/60 text-[11px] leading-none text-zinc-500 dark:text-zinc-400">
|
||||
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] leading-none font-bold">{selected.size}</span>
|
||||
<span className="leading-[18px]">{t('journey.picker.selected')}</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const groupMap = new Map<string | undefined, string[]>()
|
||||
for (const [assetId, { passphrase }] of selected.entries()) {
|
||||
const list = groupMap.get(passphrase) || []
|
||||
list.push(assetId)
|
||||
groupMap.set(passphrase, list)
|
||||
}
|
||||
const groups = [...groupMap.entries()].map(([passphrase, assetIds]) => ({ assetIds, passphrase }))
|
||||
onAdd(groups, targetEntryId)
|
||||
}}
|
||||
disabled={selected.size === 0}
|
||||
className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t('common.add')} {selected.size > 0 ? `(${selected.size})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export function ScrollTrigger({ onVisible, loading }: { onVisible: () => void; loading: boolean }) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
const obs = new IntersectionObserver(([entry]) => { if (entry.isIntersecting && !loading) onVisible() }, { rootMargin: '200px' })
|
||||
obs.observe(el)
|
||||
return () => obs.disconnect()
|
||||
}, [onVisible, loading])
|
||||
return (
|
||||
<div ref={ref} className="flex justify-center py-4 mt-2">
|
||||
<div className="w-5 h-5 border-2 border-zinc-300 border-t-zinc-900 dark:border-zinc-600 dark:border-t-white rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { X, ImagePlus, Plus, Trash2, UserPlus, Archive, ArchiveRestore } from 'lucide-react'
|
||||
import { useJourneyStore } from '../../store/journeyStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { journeyApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import JourneyShareSection from './JourneyShareSection'
|
||||
import type { JourneyDetail } from '../../store/journeyStore'
|
||||
import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { AddTripDialog } from './JourneyDetailPageAddTripDialog'
|
||||
|
||||
export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefresh }: {
|
||||
journey: JourneyDetail
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
onOpenInvite: () => void
|
||||
onRefresh: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [title, setTitle] = useState(journey.title)
|
||||
const [subtitle, setSubtitle] = useState(journey.subtitle || '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showAddTrip, setShowAddTrip] = useState(false)
|
||||
const [unlinkTarget, setUnlinkTarget] = useState<{ trip_id: number; title: string } | null>(null)
|
||||
const [showDiscardConfirm, setShowDiscardConfirm] = useState(false)
|
||||
|
||||
const isDirty = title !== journey.title || subtitle !== (journey.subtitle || '')
|
||||
const handleClose = () => { if (isDirty) setShowDiscardConfirm(true); else onClose() }
|
||||
const coverRef = useRef<HTMLInputElement>(null)
|
||||
const toast = useToast()
|
||||
const navigate = useNavigate()
|
||||
const { updateJourney, deleteJourney } = useJourneyStore()
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await updateJourney(journey.id, { title, subtitle: subtitle || null })
|
||||
onSaved()
|
||||
} catch {
|
||||
toast.error(t('journey.settings.saveFailed'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCoverUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const formData = new FormData()
|
||||
formData.append('cover', file)
|
||||
try {
|
||||
await journeyApi.uploadCover(journey.id, formData)
|
||||
toast.success(t('journey.settings.coverUpdated'))
|
||||
onSaved()
|
||||
} catch {
|
||||
toast.error(t('journey.settings.coverFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [archiving, setArchiving] = useState(false)
|
||||
|
||||
const handleArchiveToggle = async () => {
|
||||
setArchiving(true)
|
||||
try {
|
||||
const newStatus = journey.status === 'archived' ? 'active' : 'archived'
|
||||
await updateJourney(journey.id, { status: newStatus })
|
||||
toast.success(newStatus === 'archived' ? t('journey.settings.archived') : t('journey.settings.reopened'))
|
||||
onSaved()
|
||||
} catch {
|
||||
toast.error(t('journey.settings.saveFailed'))
|
||||
} finally {
|
||||
setArchiving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteJourney(journey.id)
|
||||
navigate('/journey')
|
||||
} catch {
|
||||
toast.error(t('journey.settings.failedToDelete'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none bg-[rgba(9,9,11,0.75)]" onClick={handleClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[480px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.settings.title')}</h2>
|
||||
<button onClick={handleClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain px-6 py-5 flex flex-col gap-5">
|
||||
{/* Cover Image */}
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.settings.coverImage')}</label>
|
||||
<input ref={coverRef} type="file" accept="image/*" onChange={handleCoverUpload} className="hidden" />
|
||||
<button
|
||||
onClick={() => coverRef.current?.click()}
|
||||
className="w-full h-28 rounded-xl border border-dashed border-zinc-200 dark:border-zinc-700 flex items-center justify-center gap-2 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 overflow-hidden relative"
|
||||
>
|
||||
{journey.cover_image ? (
|
||||
<>
|
||||
<img src={`/uploads/${journey.cover_image}`} className="absolute inset-0 w-full h-full object-cover opacity-50" alt="" />
|
||||
<span className="relative z-10 flex items-center gap-1.5"><ImagePlus size={14} /> {t('journey.settings.changeCover')}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5"><ImagePlus size={14} /> {t('journey.settings.addCover')}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.settings.name')}</label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[14px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Subtitle */}
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.settings.subtitle')}</label>
|
||||
<input
|
||||
value={subtitle}
|
||||
onChange={e => setSubtitle(e.target.value)}
|
||||
placeholder={t('journey.settings.subtitlePlaceholder')}
|
||||
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[14px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-zinc-200 dark:bg-zinc-700" />
|
||||
|
||||
{/* Synced Trips */}
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.detail.syncedTrips')}</label>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{journey.trips.map((trip: any) => (
|
||||
<div key={trip.trip_id} className="flex items-center gap-2.5 p-2 rounded-lg bg-zinc-50 dark:bg-zinc-800">
|
||||
<div className="w-8 h-8 rounded-md flex-shrink-0" style={{ background: pickGradient(trip.trip_id) }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[12px] font-medium text-zinc-900 dark:text-white">{trip.title}</div>
|
||||
<div className="text-[10px] text-zinc-500">{trip.place_count || 0} {t('journey.synced.places')}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setUnlinkTarget({ trip_id: trip.trip_id, title: trip.title })}
|
||||
className="w-8 h-8 rounded-lg flex-shrink-0 flex items-center justify-center bg-red-500/10 text-red-500 hover:bg-red-500/20 dark:bg-red-500/15 dark:hover:bg-red-500/25 transition-colors"
|
||||
title="Unlink trip"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{journey.trips.length === 0 && <p className="text-[11px] text-zinc-400">{t('journey.trips.noTripsLinkedSettings')}</p>}
|
||||
<button
|
||||
onClick={() => setShowAddTrip(true)}
|
||||
className="w-full mt-1 flex items-center justify-center gap-1.5 py-2.5 rounded-lg border border-dashed border-zinc-300 dark:border-zinc-600 text-[12px] font-medium text-zinc-500 hover:border-zinc-400 hover:text-zinc-700 dark:hover:border-zinc-500 dark:hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
<Plus size={14} /> {t('journey.trips.addTrip')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contributors */}
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.detail.contributors')}</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
{journey.contributors.map((c: any) => (
|
||||
<div key={c.user_id} className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[11px] font-semibold">
|
||||
{(c.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 text-[12px] font-medium text-zinc-900 dark:text-white">{c.username}</div>
|
||||
<span className={`text-[9px] font-medium px-1.5 py-0.5 rounded-full ${c.role === 'owner' ? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500'}`}>{c.role}</span>
|
||||
{c.role !== 'owner' && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!window.confirm(t('journey.contributors.removeConfirm', { username: c.username }))) return
|
||||
try {
|
||||
await journeyApi.removeContributor(journey.id, c.user_id)
|
||||
toast.success(t('journey.contributors.removed'))
|
||||
onRefresh()
|
||||
} catch {
|
||||
toast.error(t('journey.contributors.removeFailed'))
|
||||
}
|
||||
}}
|
||||
aria-label={t('journey.contributors.remove')}
|
||||
title={t('journey.contributors.remove')}
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={onOpenInvite}
|
||||
className="w-full mt-1 flex items-center justify-center gap-1.5 py-2.5 rounded-lg border border-dashed border-zinc-300 dark:border-zinc-600 text-[12px] font-medium text-zinc-500 hover:border-zinc-400 hover:text-zinc-700 dark:hover:border-zinc-500 dark:hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
<UserPlus size={14} /> {t('journey.contributors.invite')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-zinc-200 dark:bg-zinc-700" />
|
||||
|
||||
{/* Public Share */}
|
||||
<JourneyShareSection journeyId={journey.id} />
|
||||
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center gap-1.5 px-4 md:px-6 py-4 pb-6 md:pb-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
aria-label={t('journey.settings.delete')}
|
||||
title={t('journey.settings.delete')}
|
||||
className="flex items-center justify-center gap-1.5 h-9 min-w-9 px-2 md:px-2.5 text-[12px] font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span className="hidden md:inline">{t('journey.settings.delete')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleArchiveToggle}
|
||||
disabled={archiving}
|
||||
aria-label={journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}
|
||||
title={t('journey.settings.endDescription')}
|
||||
className="flex items-center justify-center gap-1.5 h-9 min-w-9 px-2 md:px-2.5 text-[12px] font-medium text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700 rounded-lg mr-auto disabled:opacity-40"
|
||||
>
|
||||
{journey.status === 'archived' ? <ArchiveRestore size={14} /> : <Archive size={14} />}
|
||||
<span className="hidden md:inline">{journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}</span>
|
||||
</button>
|
||||
<button onClick={handleClose} className="h-9 px-3.5 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
|
||||
<button onClick={handleSave} disabled={saving || !title.trim()} className="h-9 px-3.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40">
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Unlink Trip confirm */}
|
||||
<ConfirmDialog
|
||||
isOpen={!!unlinkTarget}
|
||||
onClose={() => setUnlinkTarget(null)}
|
||||
onConfirm={async () => {
|
||||
if (!unlinkTarget) return
|
||||
try {
|
||||
await journeyApi.removeTrip(journey.id, unlinkTarget.trip_id)
|
||||
toast.success(t('journey.trips.tripUnlinked'))
|
||||
setUnlinkTarget(null)
|
||||
onSaved()
|
||||
} catch {
|
||||
toast.error(t('journey.trips.unlinkFailed'))
|
||||
}
|
||||
}}
|
||||
title={t('journey.trips.unlinkTrip')}
|
||||
message={t('journey.trips.unlinkMessage', { title: unlinkTarget?.title })}
|
||||
confirmLabel={t('journey.trips.unlink')}
|
||||
danger
|
||||
/>
|
||||
|
||||
{/* Add Trip */}
|
||||
{showAddTrip && (
|
||||
<AddTripDialog
|
||||
journeyId={journey.id}
|
||||
existingTripIds={journey.trips.map((t: any) => t.trip_id)}
|
||||
onClose={() => setShowAddTrip(false)}
|
||||
onAdded={() => { setShowAddTrip(false); onSaved() }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => setShowDeleteConfirm(false)}
|
||||
onConfirm={handleDelete}
|
||||
title={t('journey.settings.deleteJourney')}
|
||||
message={t('journey.settings.deleteMessage', { title: journey.title })}
|
||||
confirmLabel={t('common.delete')}
|
||||
danger
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={showDiscardConfirm}
|
||||
onClose={() => setShowDiscardConfirm(false)}
|
||||
onConfirm={() => { setShowDiscardConfirm(false); onClose() }}
|
||||
title={t('common.discardChanges')}
|
||||
message={t('journey.editor.discardChangesConfirm')}
|
||||
confirmLabel={t('common.discard')}
|
||||
danger
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState } from 'react'
|
||||
import { Check, Minus, ChevronDown } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
export function VerdictSection({ pros, cons }: { pros: string[]; cons: string[] }) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
// On desktop always show, on mobile toggle
|
||||
return (
|
||||
<div className="mt-5">
|
||||
{/* Header — clickable on mobile */}
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className="md:pointer-events-none w-full flex items-center gap-2.5 mb-3.5 group"
|
||||
>
|
||||
<div className="flex-1 h-px bg-zinc-200 dark:bg-zinc-700" />
|
||||
<span className="text-[10px] font-bold tracking-[0.14em] uppercase text-zinc-400 flex items-center gap-1.5">
|
||||
{t('journey.editor.prosCons')}
|
||||
<ChevronDown
|
||||
size={12}
|
||||
className={`md:hidden text-zinc-400 transition-transform duration-300 ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-zinc-200 dark:bg-zinc-700" />
|
||||
</button>
|
||||
|
||||
{/* Collapsed summary on mobile */}
|
||||
{!open && (
|
||||
<div className="flex items-center justify-center gap-3 md:hidden">
|
||||
{pros.length > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-5 h-5 rounded-md bg-green-500 flex items-center justify-center">
|
||||
<Check size={11} className="text-white" strokeWidth={3} />
|
||||
</div>
|
||||
<span className="text-[12px] font-semibold text-green-700 dark:text-green-400">{pros.length}</span>
|
||||
</div>
|
||||
)}
|
||||
{cons.length > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-5 h-5 rounded-md bg-red-500 flex items-center justify-center">
|
||||
<Minus size={11} className="text-white" strokeWidth={3} />
|
||||
</div>
|
||||
<span className="text-[12px] font-semibold text-red-700 dark:text-red-400">{cons.length}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content — always visible on desktop, toggled on mobile */}
|
||||
<div
|
||||
className={`grid grid-cols-1 md:grid-cols-2 gap-3 overflow-hidden transition-all duration-300 ease-in-out ${
|
||||
open ? 'max-h-[800px] opacity-100' : 'max-h-0 md:max-h-none opacity-0 md:opacity-100'
|
||||
}`}
|
||||
>
|
||||
{pros.length > 0 && (
|
||||
<div className="rounded-xl border border-green-200 dark:border-green-800/30 p-4 bg-gradient-to-b from-green-50 to-white dark:from-green-950/30 dark:to-zinc-900">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-6 h-6 rounded-lg bg-green-500 flex items-center justify-center">
|
||||
<Check size={14} className="text-white" strokeWidth={3} />
|
||||
</div>
|
||||
<span className="hidden md:inline text-[11px] font-bold tracking-[0.1em] uppercase text-green-700 dark:text-green-400">{t('journey.verdict.lovedIt')}</span>
|
||||
<span className="ml-auto text-[11px] font-semibold text-green-600">{pros.length}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{pros.map((p, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<span className="w-[5px] h-[5px] rounded-full bg-green-500 flex-shrink-0 mt-[7px]" />
|
||||
<span className="text-[13px] text-green-900 dark:text-green-100 leading-snug">{p}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{cons.length > 0 && (
|
||||
<div className="rounded-xl border border-red-200 dark:border-red-800/30 p-4 bg-gradient-to-b from-red-50 to-white dark:from-red-950/30 dark:to-zinc-900">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-6 h-6 rounded-lg bg-red-500 flex items-center justify-center">
|
||||
<Minus size={14} className="text-white" strokeWidth={3} />
|
||||
</div>
|
||||
<span className="hidden md:inline text-[11px] font-bold tracking-[0.1em] uppercase text-red-700 dark:text-red-400">{t('journey.verdict.couldBeBetter')}</span>
|
||||
<span className="ml-auto text-[11px] font-semibold text-red-600">{cons.length}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{cons.map((c, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<span className="w-[5px] h-[5px] rounded-full bg-red-500 flex-shrink-0 mt-[7px]" />
|
||||
<span className="text-[13px] text-red-900 dark:text-red-100 leading-snug">{c}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, List, Grid, MapPin, Check } from 'lucide-react'
|
||||
import { journeyApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
|
||||
export default function JourneyShareSection({ journeyId }: { journeyId: number }) {
|
||||
const { t } = useTranslation()
|
||||
const [link, setLink] = useState<{ token: string; share_timeline: boolean; share_gallery: boolean; share_map: boolean } | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const toast = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
journeyApi.getShareLink(journeyId).then(d => setLink(d.link || null)).catch(() => {}).finally(() => setLoading(false))
|
||||
}, [journeyId])
|
||||
|
||||
const createLink = async () => {
|
||||
try {
|
||||
const res = await journeyApi.createShareLink(journeyId, { share_timeline: true, share_gallery: true, share_map: true })
|
||||
setLink({ token: res.token, share_timeline: true, share_gallery: true, share_map: true })
|
||||
toast.success(t('journey.share.linkCreated'))
|
||||
} catch { toast.error(t('journey.share.createFailed')) }
|
||||
}
|
||||
|
||||
const togglePerm = async (key: 'share_timeline' | 'share_gallery' | 'share_map') => {
|
||||
if (!link) return
|
||||
const updated = { ...link, [key]: !link[key] }
|
||||
setLink(updated)
|
||||
try {
|
||||
await journeyApi.createShareLink(journeyId, { share_timeline: updated.share_timeline, share_gallery: updated.share_gallery, share_map: updated.share_map })
|
||||
} catch { setLink(link); toast.error(t('journey.share.updateFailed')) }
|
||||
}
|
||||
|
||||
const deleteLink = async () => {
|
||||
try {
|
||||
await journeyApi.deleteShareLink(journeyId)
|
||||
setLink(null)
|
||||
toast.success(t('journey.share.linkDeleted'))
|
||||
} catch { toast.error(t('journey.share.deleteFailed')) }
|
||||
}
|
||||
|
||||
const shareUrl = link ? `${window.location.origin}/public/journey/${link.token}` : ''
|
||||
|
||||
const copyLink = () => {
|
||||
navigator.clipboard.writeText(shareUrl)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
if (loading) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.share.publicShare')}</label>
|
||||
|
||||
{!link ? (
|
||||
<button
|
||||
onClick={createLink}
|
||||
className="w-full flex items-center justify-center gap-1.5 py-2.5 rounded-lg border border-dashed border-zinc-300 dark:border-zinc-600 text-[12px] font-medium text-zinc-500 hover:border-zinc-400 hover:text-zinc-700 dark:hover:border-zinc-500 dark:hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
<Link size={14} /> {t('journey.share.createLink')}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* URL + Copy */}
|
||||
<div className="flex items-center gap-2 p-2.5 rounded-lg bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700">
|
||||
<Link size={13} className="text-zinc-400 flex-shrink-0" />
|
||||
<span className="flex-1 text-[11px] text-zinc-600 dark:text-zinc-400 truncate">{shareUrl}</span>
|
||||
<button
|
||||
onClick={copyLink}
|
||||
className="flex-shrink-0 px-2.5 py-1 rounded-md bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-700 dark:hover:bg-zinc-200"
|
||||
>
|
||||
{copied ? t('journey.share.copied') : t('journey.share.copy')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Permission toggles */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{[
|
||||
{ key: 'share_timeline' as const, label: t('journey.share.timeline'), icon: List },
|
||||
{ key: 'share_gallery' as const, label: t('journey.share.gallery'), icon: Grid },
|
||||
{ key: 'share_map' as const, label: t('journey.share.map'), icon: MapPin },
|
||||
].map(({ key, label, icon: Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => togglePerm(key)}
|
||||
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg border text-[12px] font-medium transition-all ${
|
||||
link[key]
|
||||
? 'border-zinc-900 dark:border-white bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: 'border-zinc-200 dark:border-zinc-700 text-zinc-500 hover:border-zinc-400'
|
||||
}`}
|
||||
>
|
||||
<Icon size={13} />
|
||||
{label}
|
||||
{link[key] && <Check size={12} className="ml-auto" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Delete link */}
|
||||
<button
|
||||
onClick={deleteLink}
|
||||
className="text-[11px] font-medium text-red-500 hover:text-red-600 self-start"
|
||||
>
|
||||
{t('share.deleteLink')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,9 +7,10 @@ import { useInAppNotificationStore } from '../../store/inAppNotificationStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import InAppNotificationBell from './InAppNotificationBell';
|
||||
import type { InAppNotification } from '../../store/inAppNotificationStore';
|
||||
|
||||
let _notifId = 1;
|
||||
function buildNotification(overrides: Record<string, unknown> = {}) {
|
||||
function buildNotification(overrides: Partial<InAppNotification> = {}): InAppNotification {
|
||||
return {
|
||||
id: _notifId++,
|
||||
type: 'simple',
|
||||
@@ -20,15 +21,15 @@ function buildNotification(overrides: Record<string, unknown> = {}) {
|
||||
sender_avatar: null,
|
||||
recipient_id: 1,
|
||||
title_key: 'test',
|
||||
title_params: '{}',
|
||||
title_params: {},
|
||||
text_key: 'test.text',
|
||||
text_params: '{}',
|
||||
text_params: {},
|
||||
positive_text_key: null,
|
||||
negative_text_key: null,
|
||||
response: null,
|
||||
navigate_text_key: null,
|
||||
navigate_target: null,
|
||||
is_read: 0,
|
||||
is_read: false,
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
@@ -92,14 +93,7 @@ describe('InAppNotificationBell', () => {
|
||||
|
||||
it('FE-COMP-BELL-007: panel shows Mark all read button when panel is open', async () => {
|
||||
const user = userEvent.setup();
|
||||
const notification = {
|
||||
id: 1, type: 'simple', scope: 'trip', target: 1, sender_id: 2,
|
||||
sender_username: 'alice', sender_avatar: null, recipient_id: 1,
|
||||
title_key: 'test', title_params: '{}', text_key: 'test.text', text_params: '{}',
|
||||
positive_text_key: null, negative_text_key: null, response: null,
|
||||
navigate_text_key: null, navigate_target: null, is_read: 0,
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
};
|
||||
const notification = buildNotification({ id: 1, title_key: 'test', text_key: 'test.text' });
|
||||
seedStore(useInAppNotificationStore, { notifications: [notification], unreadCount: 1, isLoading: false });
|
||||
render(<InAppNotificationBell />);
|
||||
const bell = screen.getAllByRole('button')[0];
|
||||
@@ -153,7 +147,7 @@ describe('InAppNotificationBell', () => {
|
||||
|
||||
it('FE-COMP-BELL-013: Mark all read button NOT shown when unreadCount is 0', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(useInAppNotificationStore, { notifications: [buildNotification({ is_read: 1 })], unreadCount: 0, isLoading: false, fetchNotifications: vi.fn(), fetchUnreadCount: vi.fn() });
|
||||
seedStore(useInAppNotificationStore, { notifications: [buildNotification({ is_read: true })], unreadCount: 0, isLoading: false, fetchNotifications: vi.fn(), fetchUnreadCount: vi.fn() });
|
||||
render(<InAppNotificationBell />);
|
||||
await user.click(screen.getAllByRole('button')[0]);
|
||||
await screen.findByText('Notifications');
|
||||
|
||||
@@ -45,8 +45,7 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
<button
|
||||
onClick={handleOpen}
|
||||
title={t('notifications.title')}
|
||||
className="relative p-2 rounded-lg transition-colors"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
className="relative p-2 rounded-lg transition-colors text-content-muted"
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
@@ -72,7 +71,7 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
<>
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setOpen(false)} />
|
||||
<div
|
||||
className="rounded-xl shadow-xl border overflow-hidden"
|
||||
className="rounded-xl shadow-xl border overflow-hidden bg-surface-card border-edge"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 'var(--nav-h)',
|
||||
@@ -81,22 +80,18 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
maxWidth: 'calc(100vw - 16px)',
|
||||
maxHeight: 'min(480px, calc(100vh - var(--nav-h) - 16px))',
|
||||
zIndex: 9999,
|
||||
background: 'var(--bg-card)',
|
||||
borderColor: 'var(--border-primary)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 flex-shrink-0"
|
||||
style={{ borderBottom: '1px solid var(--border-secondary)' }}
|
||||
className="flex items-center justify-between px-4 py-3 flex-shrink-0 border-b border-edge-secondary"
|
||||
>
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
<span className="text-sm font-semibold text-content">
|
||||
{t('notifications.title')}
|
||||
{unreadCount > 0 && (
|
||||
<span className="ml-2 px-1.5 py-0.5 rounded-full text-xs font-medium"
|
||||
style={{ background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
|
||||
<span className="ml-2 px-1.5 py-0.5 rounded-full text-xs font-medium bg-content text-surface">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
@@ -106,8 +101,7 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
<button
|
||||
onClick={markAllRead}
|
||||
title={t('notifications.markAllRead')}
|
||||
className="p-1.5 rounded-lg transition-colors"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
className="p-1.5 rounded-lg transition-colors text-content-muted"
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
@@ -118,8 +112,7 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
<button
|
||||
onClick={deleteAll}
|
||||
title={t('notifications.deleteAll')}
|
||||
className="p-1.5 rounded-lg transition-colors"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
className="p-1.5 rounded-lg transition-colors text-content-muted"
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
@@ -133,13 +126,13 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{isLoading && notifications.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<div className="w-5 h-5 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} />
|
||||
<div className="w-5 h-5 border-2 rounded-full animate-spin border-edge border-t-content" />
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 px-4 text-center gap-2">
|
||||
<Bell className="w-8 h-8" style={{ color: 'var(--text-faint)' }} />
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-muted)' }}>{t('notifications.empty')}</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('notifications.emptyDescription')}</p>
|
||||
<Bell className="w-8 h-8 text-content-faint" />
|
||||
<p className="text-sm font-medium text-content-muted">{t('notifications.empty')}</p>
|
||||
<p className="text-xs text-content-faint">{t('notifications.emptyDescription')}</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.slice(0, 10).map(n => (
|
||||
@@ -151,10 +144,8 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
{/* Footer */}
|
||||
<button
|
||||
onClick={handleShowAll}
|
||||
className="w-full py-2.5 text-xs font-medium transition-colors flex-shrink-0"
|
||||
className="w-full py-2.5 text-xs font-medium transition-colors flex-shrink-0 border-t border-edge-secondary text-content"
|
||||
style={{
|
||||
borderTop: '1px solid var(--border-secondary)',
|
||||
color: 'var(--text-primary)',
|
||||
background: 'transparent',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
// FE-COMP-MOBILETOPHEADER-001 to FE-COMP-MOBILETOPHEADER-004
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import MobileTopHeader from './MobileTopHeader';
|
||||
|
||||
describe('MobileTopHeader', () => {
|
||||
it('FE-COMP-MOBILETOPHEADER-001: renders title as h1', () => {
|
||||
render(<MobileTopHeader title="Journeys" />);
|
||||
const heading = screen.getByRole('heading', { level: 1 });
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(heading.textContent).toBe('Journeys');
|
||||
});
|
||||
|
||||
it('FE-COMP-MOBILETOPHEADER-002: renders subtitle when provided', () => {
|
||||
render(<MobileTopHeader title="Journeys" subtitle="3 trips" />);
|
||||
expect(screen.getByText('3 trips')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOBILETOPHEADER-003: does not render subtitle when omitted', () => {
|
||||
const { container } = render(<MobileTopHeader title="Journeys" />);
|
||||
const subtitleEl = container.querySelector('.text-xs.text-zinc-500');
|
||||
expect(subtitleEl).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOBILETOPHEADER-004: renders action children when provided', () => {
|
||||
render(
|
||||
<MobileTopHeader title="Trips" actions={<button>Add</button>} />,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
interface Props {
|
||||
title: string
|
||||
subtitle?: string
|
||||
actions?: React.ReactNode
|
||||
}
|
||||
|
||||
export default function MobileTopHeader({ title, subtitle, actions }: Props) {
|
||||
return (
|
||||
<div className="px-5 pt-4 pb-3 flex justify-between items-center bg-zinc-50 dark:bg-zinc-950 flex-shrink-0 md:hidden">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-[28px] font-extrabold text-zinc-900 dark:text-white tracking-tight leading-none">{title}</h1>
|
||||
{subtitle && <div className="text-xs text-zinc-500 mt-1">{subtitle}</div>}
|
||||
</div>
|
||||
{actions && <div className="flex gap-2 items-center flex-shrink-0">{actions}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,7 +13,7 @@ const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe
|
||||
|
||||
interface NavbarProps {
|
||||
tripTitle?: string
|
||||
tripId?: string
|
||||
tripId?: number | string
|
||||
onBack?: () => void
|
||||
showBack?: boolean
|
||||
onShare?: () => void
|
||||
@@ -110,8 +110,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{showBack && (
|
||||
<button onClick={onBack}
|
||||
className="trek-back-btn p-1.5 rounded-lg transition-colors flex items-center gap-1.5 text-sm flex-shrink-0"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
className="trek-back-btn p-1.5 rounded-lg transition-colors flex items-center gap-1.5 text-sm flex-shrink-0 text-content-muted"
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<ArrowLeft className="trek-back-icon w-4 h-4" />
|
||||
@@ -126,8 +125,8 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
|
||||
{tripTitle && (
|
||||
<>
|
||||
<span className="hidden sm:inline" style={{ color: 'var(--text-faint)' }}>/</span>
|
||||
<span className="hidden sm:inline text-sm font-medium truncate max-w-48" style={{ color: 'var(--text-muted)' }}>
|
||||
<span className="hidden sm:inline text-content-faint">/</span>
|
||||
<span className="hidden sm:inline text-sm font-medium truncate max-w-48 text-content-muted">
|
||||
{tripTitle}
|
||||
</span>
|
||||
</>
|
||||
@@ -176,8 +175,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
{/* Share button */}
|
||||
{onShare && (
|
||||
<button onClick={onShare}
|
||||
className="flex items-center gap-1.5 py-1.5 px-3 rounded-lg border transition-colors text-sm font-medium flex-shrink-0"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)', background: 'var(--bg-card)' }}
|
||||
className="flex items-center gap-1.5 py-1.5 px-3 rounded-lg border transition-colors text-sm font-medium flex-shrink-0 border-edge text-content-secondary bg-surface-card"
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}>
|
||||
<Users className="w-4 h-4" />
|
||||
@@ -188,18 +186,16 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
{/* Prerelease badge */}
|
||||
{isPrerelease && appVersion && (
|
||||
<span
|
||||
className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold flex-shrink-0"
|
||||
style={{ background: 'rgba(245,158,11,0.15)', color: '#d97706', border: '1px solid rgba(245,158,11,0.3)' }}
|
||||
className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold flex-shrink-0 bg-[rgba(245,158,11,0.15)] text-[#d97706] border border-[rgba(245,158,11,0.3)]"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={{ background: '#f59e0b' }} />
|
||||
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0 bg-[#f59e0b]" />
|
||||
{appVersion}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Dark mode toggle (light ↔ dark, overrides auto) — hidden on mobile */}
|
||||
<button onClick={toggleDarkMode} title={dark ? t('nav.lightMode') : t('nav.darkMode')}
|
||||
className="p-2 rounded-lg transition-colors flex-shrink-0 hidden sm:flex relative w-8 h-8 items-center justify-center"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
className="p-2 rounded-lg transition-colors flex-shrink-0 hidden sm:flex relative w-8 h-8 items-center justify-center text-content-muted"
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<Sun className="w-4 h-4 absolute transition-[transform,opacity] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
@@ -227,21 +223,21 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
{user.username?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm hidden sm:inline max-w-24 truncate" style={{ color: 'var(--text-secondary)' }}>
|
||||
<span className="text-sm hidden sm:inline max-w-24 truncate text-content-secondary">
|
||||
{user.username}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4" style={{ color: 'var(--text-faint)' }} />
|
||||
<ChevronDown className="w-4 h-4 text-content-faint" />
|
||||
</button>
|
||||
|
||||
{userMenuOpen && ReactDOM.createPortal(
|
||||
<>
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setUserMenuOpen(false)} />
|
||||
<div className="trek-menu-enter w-52 rounded-xl shadow-xl border overflow-hidden" style={{ position: 'fixed', top: 'var(--nav-h)', right: 8, zIndex: 9999, background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="px-4 py-3 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{user.username}</p>
|
||||
<p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{user.email}</p>
|
||||
<div className="trek-menu-enter w-52 rounded-xl shadow-xl border overflow-hidden bg-surface-card border-edge" style={{ position: 'fixed', top: 'var(--nav-h)', right: 8, zIndex: 9999 }}>
|
||||
<div className="px-4 py-3 border-b border-edge-secondary">
|
||||
<p className="text-sm font-medium text-content">{user.username}</p>
|
||||
<p className="text-xs truncate text-content-muted">{user.email}</p>
|
||||
{user.role === 'admin' && (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium mt-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium mt-1 text-content-secondary">
|
||||
<Shield className="w-3 h-3" /> {t('nav.administrator')}
|
||||
</span>
|
||||
)}
|
||||
@@ -249,8 +245,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
|
||||
<div className="py-1">
|
||||
<Link to="/settings" onClick={() => setUserMenuOpen(false)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors text-content-secondary"
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<Settings className="w-4 h-4" />
|
||||
@@ -259,8 +254,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
|
||||
{user.role === 'admin' && (
|
||||
<Link to="/admin" onClick={() => setUserMenuOpen(false)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors text-content-secondary"
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<Shield className="w-4 h-4" />
|
||||
@@ -269,14 +263,14 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="py-1 border-t" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<div className="py-1 border-t border-edge-secondary">
|
||||
<button onClick={handleLogout}
|
||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-red-500 hover:bg-red-500/10 transition-colors">
|
||||
<LogOut className="w-4 h-4" />
|
||||
{t('nav.logout')}
|
||||
</button>
|
||||
{appVersion && (
|
||||
<div className="px-4 pt-2 pb-2.5 text-center" style={{ marginTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
|
||||
<div className="px-4 pt-2 pb-2.5 text-center border-t border-edge-secondary" style={{ marginTop: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '4px 12px' }}>
|
||||
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 10, opacity: 0.5 }} />
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react'
|
||||
import Navbar from './Navbar'
|
||||
|
||||
interface PageShellProps {
|
||||
children: React.ReactNode
|
||||
/** Tailwind classes for the full-height root (e.g. "bg-zinc-50 dark:bg-zinc-950"). */
|
||||
className?: string
|
||||
/** Inline `background` for the root, for pages that theme via CSS vars (e.g. "var(--bg-secondary)"). */
|
||||
background?: string
|
||||
/** Props forwarded to the shared Navbar (trip title, back button, …). */
|
||||
navbar?: React.ComponentProps<typeof Navbar>
|
||||
/** paddingTop offset that clears the fixed Navbar. Defaults to the global --nav-h. */
|
||||
navOffset?: string
|
||||
/** Classes/style for the nav-offset content wrapper. */
|
||||
contentClassName?: string
|
||||
contentStyle?: React.CSSProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* The standard authenticated page chrome: a full-height themed root, the shared
|
||||
* fixed Navbar, and a content wrapper offset by the navbar height. Both the web
|
||||
* app and the PWA shell render through this so the offset/background handling
|
||||
* lives in one place instead of being copy-pasted into every page.
|
||||
*/
|
||||
export default function PageShell({
|
||||
children,
|
||||
className,
|
||||
background,
|
||||
navbar,
|
||||
navOffset = 'var(--nav-h)',
|
||||
contentClassName,
|
||||
contentStyle,
|
||||
}: PageShellProps): React.ReactElement {
|
||||
return (
|
||||
<div className={`min-h-screen${className ? ' ' + className : ''}`} style={background ? { background } : undefined}>
|
||||
<Navbar {...navbar} />
|
||||
<div className={contentClassName} style={{ paddingTop: navOffset, ...contentStyle }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -47,27 +47,23 @@ export default function PageSidebar({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl overflow-hidden flex flex-col lg:flex-row relative"
|
||||
className="rounded-2xl overflow-hidden flex flex-col lg:flex-row relative bg-surface-card border border-edge"
|
||||
style={{
|
||||
background: 'var(--bg-card)',
|
||||
border: '1px solid var(--border-primary)',
|
||||
minHeight: 'min(820px, calc(100vh - var(--nav-h) - 120px))',
|
||||
}}
|
||||
>
|
||||
{/* Mobile top bar with hamburger */}
|
||||
<div
|
||||
className="lg:hidden flex items-center justify-between px-4 py-3 border-b"
|
||||
style={{ borderColor: 'var(--border-primary)' }}
|
||||
className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-edge"
|
||||
>
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="w-9 h-9 rounded-lg flex items-center justify-center transition-colors hover:bg-[var(--bg-hover)]"
|
||||
className="w-9 h-9 rounded-lg flex items-center justify-center transition-colors hover:bg-[var(--bg-hover)] text-content"
|
||||
aria-label="Open navigation"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<Menu size={18} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-content">
|
||||
{activeLabel}
|
||||
</div>
|
||||
<div className="w-9" />
|
||||
@@ -75,11 +71,9 @@ export default function PageSidebar({
|
||||
|
||||
{/* Desktop sidebar (always visible on lg) */}
|
||||
<aside
|
||||
className="hidden lg:flex flex-col shrink-0 relative"
|
||||
className="hidden lg:flex flex-col shrink-0 relative bg-surface-secondary border-r border-edge"
|
||||
style={{
|
||||
width: 260,
|
||||
background: 'var(--bg-secondary)',
|
||||
borderRight: '1px solid var(--border-primary)',
|
||||
padding: '24px 14px',
|
||||
}}
|
||||
>
|
||||
@@ -96,31 +90,27 @@ export default function PageSidebar({
|
||||
{mobileOpen && (
|
||||
<>
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 z-40"
|
||||
style={{ background: 'rgba(0,0,0,0.35)' }}
|
||||
className="lg:hidden fixed inset-0 z-40 bg-[rgba(0,0,0,0.35)]"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
/>
|
||||
<aside
|
||||
ref={drawerRef}
|
||||
className="lg:hidden fixed top-0 left-0 bottom-0 z-50 flex flex-col shadow-2xl"
|
||||
className="lg:hidden fixed top-0 left-0 bottom-0 z-50 flex flex-col shadow-2xl bg-surface-secondary"
|
||||
style={{
|
||||
width: 280,
|
||||
background: 'var(--bg-secondary)',
|
||||
padding: '18px 14px',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3 px-2">
|
||||
<span
|
||||
className="text-[11px] font-bold tracking-widest uppercase"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
className="text-[11px] font-bold tracking-widest uppercase text-content-muted"
|
||||
>
|
||||
{sidebarLabel}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center transition-colors hover:bg-[var(--bg-hover)]"
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center transition-colors hover:bg-[var(--bg-hover)] text-content"
|
||||
aria-label="Close navigation"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
@@ -164,8 +154,7 @@ function SidebarInner({
|
||||
<>
|
||||
{sidebarLabel && (
|
||||
<div
|
||||
className="text-[11px] font-bold tracking-widest uppercase mb-3 px-3"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
className="text-[11px] font-bold tracking-widest uppercase mb-3 px-3 text-content-muted"
|
||||
>
|
||||
{sidebarLabel}
|
||||
</div>
|
||||
@@ -178,11 +167,9 @@ function SidebarInner({
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className="flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-left transition-colors"
|
||||
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-left transition-colors ${active ? 'text-content font-semibold' : 'text-content-secondary font-medium'}`}
|
||||
style={{
|
||||
background: active ? 'var(--bg-hover)' : 'transparent',
|
||||
color: active ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||||
fontWeight: active ? 600 : 500,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!active) e.currentTarget.style.background = 'var(--bg-hover)'
|
||||
@@ -199,8 +186,7 @@ function SidebarInner({
|
||||
</nav>
|
||||
{footer && (
|
||||
<div
|
||||
className="mt-4 pt-3 px-3 text-[10px] tracking-wide"
|
||||
style={{ color: 'var(--text-faint)', borderTop: '1px solid var(--border-primary)' }}
|
||||
className="mt-4 pt-3 px-3 text-[10px] tracking-wide text-content-faint border-t border-edge"
|
||||
>
|
||||
{footer}
|
||||
</div>
|
||||
|
||||
@@ -19,8 +19,9 @@ function categoryIconSvg(iconName: string | null | undefined, size: number): str
|
||||
}
|
||||
import type { Place } from '../../types'
|
||||
|
||||
// Fix default marker icons for vite
|
||||
delete L.Icon.Default.prototype._getIconUrl
|
||||
// Fix default marker icons for vite. `_getIconUrl` is a Leaflet-internal field
|
||||
// not present in the public typings, so narrow to delete it.
|
||||
delete (L.Icon.Default.prototype as { _getIconUrl?: unknown })._getIconUrl
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
|
||||
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
|
||||
@@ -121,7 +122,7 @@ interface SelectionControllerProps {
|
||||
places: Place[]
|
||||
selectedPlaceId: number | null
|
||||
dayPlaces: Place[]
|
||||
paddingOpts: Record<string, number>
|
||||
paddingOpts: L.FitBoundsOptions
|
||||
}
|
||||
|
||||
function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }: SelectionControllerProps) {
|
||||
@@ -166,7 +167,7 @@ interface BoundsControllerProps {
|
||||
hasDayDetail?: boolean
|
||||
places: Place[]
|
||||
fitKey: number
|
||||
paddingOpts: Record<string, number>
|
||||
paddingOpts: L.FitBoundsOptions
|
||||
}
|
||||
|
||||
function BoundsController({ places, fitKey, paddingOpts, hasDayDetail }: BoundsControllerProps) {
|
||||
@@ -210,7 +211,7 @@ function MapClickHandler({ onClick }: MapClickHandlerProps) {
|
||||
useEffect(() => {
|
||||
if (!onClick) return
|
||||
map.on('click', onClick)
|
||||
return () => map.off('click', onClick)
|
||||
return () => { map.off('click', onClick) }
|
||||
}, [map, onClick])
|
||||
return null
|
||||
}
|
||||
@@ -220,7 +221,7 @@ function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.Leafle
|
||||
useEffect(() => {
|
||||
if (!onContextMenu) return
|
||||
map.on('contextmenu', onContextMenu)
|
||||
return () => map.off('contextmenu', onContextMenu)
|
||||
return () => { map.off('contextmenu', onContextMenu) }
|
||||
}, [map, onContextMenu])
|
||||
return null
|
||||
}
|
||||
@@ -362,7 +363,7 @@ export const MapView = memo(function MapView({
|
||||
return reservations.filter((r: Reservation) => set.has(r.id))
|
||||
}, [reservations, visibleConnectionIds])
|
||||
// Dynamic padding: account for sidebars + bottom inspector + day detail panel
|
||||
const paddingOpts = useMemo(() => {
|
||||
const paddingOpts = useMemo((): L.FitBoundsOptions => {
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
if (isMobile) return { padding: [40, 20] }
|
||||
const top = 60
|
||||
@@ -514,8 +515,7 @@ export const MapView = memo(function MapView({
|
||||
center={center}
|
||||
zoom={zoom}
|
||||
zoomControl={false}
|
||||
className="w-full h-full"
|
||||
style={{ background: '#e5e7eb' }}
|
||||
className="w-full h-full bg-[#e5e7eb]"
|
||||
>
|
||||
<TileLayer
|
||||
url={tileUrl}
|
||||
|
||||
@@ -313,7 +313,9 @@ export function MapViewGL({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const curAlt = (ll as any).alt ?? 0
|
||||
if (Math.abs(curAlt - alt) > 0.25) {
|
||||
marker.setLngLat([ll.lng, ll.lat, alt])
|
||||
// mapbox-gl accepts a third altitude element at runtime, but its typings
|
||||
// only model the 2-tuple form, so cast to LngLatLike.
|
||||
marker.setLngLat([ll.lng, ll.lat, alt] as unknown as mapboxgl.LngLatLike)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -421,7 +421,7 @@ export default function ReservationOverlay({ reservations, showConnections, show
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
|
||||
<div style={{ fontWeight: 600, fontSize: 12 }}>{item.from.name}</div>
|
||||
{item.res.title && <div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{item.res.title}</div>}
|
||||
{item.res.title && <div className="text-content-muted" style={{ fontSize: 11 }}>{item.res.title}</div>}
|
||||
</Tooltip>
|
||||
</Marker>,
|
||||
<Marker
|
||||
@@ -434,7 +434,7 @@ export default function ReservationOverlay({ reservations, showConnections, show
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
|
||||
<div style={{ fontWeight: 600, fontSize: 12 }}>{item.to.name}</div>
|
||||
{item.res.title && <div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{item.res.title}</div>}
|
||||
{item.res.title && <div className="text-content-muted" style={{ fontSize: 11 }}>{item.res.title}</div>}
|
||||
</Tooltip>
|
||||
</Marker>,
|
||||
])}
|
||||
|
||||
@@ -78,12 +78,12 @@ export function generateGoogleMapsUrl(places: Waypoint[]): string | null {
|
||||
}
|
||||
|
||||
/** Reorders waypoints using a nearest-neighbor heuristic to minimize total Euclidean distance. */
|
||||
export function optimizeRoute(places: Waypoint[]): Waypoint[] {
|
||||
export function optimizeRoute<T extends Waypoint>(places: T[]): T[] {
|
||||
const valid = places.filter((p) => p.lat && p.lng)
|
||||
if (valid.length <= 2) return places
|
||||
|
||||
const visited = new Set<number>()
|
||||
const result: Waypoint[] = []
|
||||
const result: T[] = []
|
||||
let current = valid[0]
|
||||
visited.add(0)
|
||||
result.push(current)
|
||||
|
||||
@@ -1,789 +0,0 @@
|
||||
// FE-COMP-MEMORIESPANEL-001 to FE-COMP-MEMORIESPANEL-027
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import MemoriesPanel from './MemoriesPanel';
|
||||
|
||||
// Mock fetchImageAsBlob to avoid real HTTP calls for thumbnail/image rendering
|
||||
vi.mock('../../api/authUrl', () => ({
|
||||
fetchImageAsBlob: vi.fn().mockResolvedValue('blob:mock-url'),
|
||||
clearImageQueue: vi.fn(),
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
tripId: 1,
|
||||
startDate: '2025-03-01',
|
||||
endDate: '2025-03-10',
|
||||
};
|
||||
|
||||
// Reusable provider object to configure a connected Immich instance
|
||||
const immichAddon = {
|
||||
id: 'immich',
|
||||
name: 'Immich',
|
||||
type: 'photo_provider',
|
||||
enabled: true,
|
||||
config: { status_get: '/integrations/memories/immich/status' },
|
||||
};
|
||||
|
||||
// Handlers that simulate a connected provider with no photos/links
|
||||
const connectedHandlers = [
|
||||
http.get('/api/addons', () =>
|
||||
HttpResponse.json({ addons: [immichAddon] })
|
||||
),
|
||||
http.get('/api/integrations/memories/immich/status', () =>
|
||||
HttpResponse.json({ connected: true })
|
||||
),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({ photos: [] })
|
||||
),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
|
||||
HttpResponse.json({ links: [] })
|
||||
),
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
// Seed a default logged-in user
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'me' }) });
|
||||
});
|
||||
|
||||
describe('MemoriesPanel', () => {
|
||||
it('FE-COMP-MEMORIESPANEL-001: Shows loading state on initial render', () => {
|
||||
// Use a delayed response so loading stays true long enough to assert
|
||||
server.use(
|
||||
http.get('/api/addons', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
return HttpResponse.json({ addons: [] });
|
||||
}),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({ photos: [] })
|
||||
),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
|
||||
HttpResponse.json({ links: [] })
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
// Spinner is rendered synchronously — loading state starts as true
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-002: Shows not-connected state when no photo providers are enabled', async () => {
|
||||
server.use(
|
||||
http.get('/api/addons', () => HttpResponse.json({ addons: [] })),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({ photos: [] })
|
||||
),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
|
||||
HttpResponse.json({ links: [] })
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
// "Photo provider not connected" — no providers, falls back to generic label
|
||||
await screen.findByText('Photo provider not connected');
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-003: Displays trip photos from other users', async () => {
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
{
|
||||
asset_id: 'abc',
|
||||
provider: 'immich',
|
||||
user_id: 2,
|
||||
username: 'Alice',
|
||||
shared: 1,
|
||||
added_at: '2025-03-05T10:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
// Alice's username is rendered as an avatar tooltip in the gallery
|
||||
await screen.findByText('Alice');
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-004: Shows empty gallery state when connected but no photos', async () => {
|
||||
server.use(...connectedHandlers);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
// Provider is connected so the gallery renders — but no photos → empty state
|
||||
await screen.findByText('No photos found');
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-005: Album links are displayed in the gallery header', async () => {
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
|
||||
HttpResponse.json({
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
provider: 'immich',
|
||||
album_id: 'a1',
|
||||
album_name: 'Holidays',
|
||||
user_id: 1,
|
||||
username: 'me',
|
||||
sync_enabled: 1,
|
||||
last_synced_at: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
await screen.findByText('Holidays');
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-006: Sync button calls the sync endpoint', async () => {
|
||||
let syncCalled = false;
|
||||
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
|
||||
HttpResponse.json({
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
provider: 'immich',
|
||||
album_id: 'a1',
|
||||
album_name: 'Holidays',
|
||||
user_id: 1,
|
||||
username: 'me',
|
||||
sync_enabled: 1,
|
||||
last_synced_at: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
http.post('/api/integrations/memories/:provider/trips/:tripId/album-links/:linkId/sync', () => {
|
||||
syncCalled = true;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
await screen.findByText('Holidays');
|
||||
|
||||
const syncBtn = screen.getByTitle('Sync album');
|
||||
await userEvent.click(syncBtn);
|
||||
|
||||
await waitFor(() => expect(syncCalled).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-007: Unlink button calls the delete endpoint', async () => {
|
||||
let deleteCalled = false;
|
||||
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
|
||||
HttpResponse.json({
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
provider: 'immich',
|
||||
album_id: 'a1',
|
||||
album_name: 'Holidays',
|
||||
user_id: 1,
|
||||
username: 'me',
|
||||
sync_enabled: 1,
|
||||
last_synced_at: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
http.delete('/api/integrations/memories/unified/trips/:tripId/album-links/:linkId', () => {
|
||||
deleteCalled = true;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
await screen.findByText('Holidays');
|
||||
|
||||
// The unlink button is only shown when link.user_id === currentUser.id
|
||||
const unlinkBtn = screen.getByTitle('Unlink album');
|
||||
await userEvent.click(unlinkBtn);
|
||||
|
||||
await waitFor(() => expect(deleteCalled).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-008: Sort toggle switches between oldest-first and newest-first', async () => {
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
{ photo_id: 1, asset_id: 'photo1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T10:00:00Z' },
|
||||
{ photo_id: 2, asset_id: 'photo2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-10T10:00:00Z' },
|
||||
],
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
// Default sort is ascending ("Oldest first")
|
||||
const sortBtn = await screen.findByText('Oldest first');
|
||||
|
||||
await userEvent.click(sortBtn);
|
||||
|
||||
// After toggle, button label switches to "Newest first"
|
||||
expect(screen.getByText('Newest first')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-009: Photo picker opens when "Add photos" is clicked', async () => {
|
||||
server.use(
|
||||
...connectedHandlers,
|
||||
http.post('/api/integrations/memories/immich/search', () =>
|
||||
HttpResponse.json({ assets: [] })
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
// Wait for the empty gallery to load
|
||||
await screen.findByText('No photos found');
|
||||
|
||||
// Both the header button and gallery CTA say "Add photos" — click the first
|
||||
const addBtns = screen.getAllByText('Add photos');
|
||||
await userEvent.click(addBtns[0]);
|
||||
|
||||
// Picker header is now visible
|
||||
await screen.findByText('Select photos from Immich');
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-010: Picker cancel button closes the picker', async () => {
|
||||
server.use(
|
||||
...connectedHandlers,
|
||||
http.post('/api/integrations/memories/immich/search', () =>
|
||||
HttpResponse.json({ assets: [] })
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
await screen.findByText('No photos found');
|
||||
|
||||
const addBtns = screen.getAllByText('Add photos');
|
||||
await userEvent.click(addBtns[0]);
|
||||
await screen.findByText('Select photos from Immich');
|
||||
|
||||
// Click Cancel in the picker header
|
||||
await userEvent.click(screen.getByText('Cancel'));
|
||||
|
||||
// Gallery is restored
|
||||
await screen.findByText('No photos found');
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-011: Album picker opens when "Link Album" is clicked', async () => {
|
||||
server.use(
|
||||
...connectedHandlers,
|
||||
http.get('/api/integrations/memories/immich/albums', () =>
|
||||
HttpResponse.json({ albums: [] })
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
await screen.findByText('No photos found');
|
||||
|
||||
await userEvent.click(screen.getByText('Link Album'));
|
||||
|
||||
// Album picker header appears
|
||||
await screen.findByText('Select Immich Album');
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-012: Own photos render with share-toggle and private indicator', async () => {
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
{
|
||||
asset_id: 'photo1',
|
||||
provider: 'immich',
|
||||
user_id: 1,
|
||||
username: 'me',
|
||||
shared: 0,
|
||||
added_at: '2025-03-05T10:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
// Share-toggle button appears with correct title (not shared → "Share photos")
|
||||
await screen.findByTitle('Share photos');
|
||||
|
||||
// "Private" label is shown on unshared own photos
|
||||
expect(screen.getByText('Private')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-013: toggleSharing calls the PUT sharing endpoint', async () => {
|
||||
let putCalled = false;
|
||||
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
{
|
||||
asset_id: 'photo1',
|
||||
provider: 'immich',
|
||||
user_id: 1,
|
||||
username: 'me',
|
||||
shared: 0,
|
||||
added_at: '2025-03-05T10:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
http.put('/api/integrations/memories/unified/trips/:tripId/photos/sharing', () => {
|
||||
putCalled = true;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
const shareBtn = await screen.findByTitle('Share photos');
|
||||
await userEvent.click(shareBtn);
|
||||
|
||||
await waitFor(() => expect(putCalled).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-014: removePhoto calls the DELETE photos endpoint', async () => {
|
||||
let deleteCalled = false;
|
||||
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
{
|
||||
asset_id: 'photo1',
|
||||
provider: 'immich',
|
||||
user_id: 1,
|
||||
username: 'me',
|
||||
shared: 1,
|
||||
added_at: '2025-03-05T10:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
http.delete('/api/integrations/memories/unified/trips/:tripId/photos', () => {
|
||||
deleteCalled = true;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
// Wait for the share/stop-sharing button to confirm the gallery has rendered
|
||||
await screen.findByTitle('Stop sharing');
|
||||
|
||||
// The remove button is the second action button in the hover overlay — no title, just an X icon
|
||||
// Get all buttons and click the one after the share toggle
|
||||
const allBtns = screen.getAllByRole('button');
|
||||
const shareIdx = allBtns.findIndex(b => b.getAttribute('title') === 'Stop sharing');
|
||||
// The remove button immediately follows the share button in the DOM
|
||||
await userEvent.click(allBtns[shareIdx + 1]);
|
||||
|
||||
await waitFor(() => expect(deleteCalled).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-015: Picker displays assets grouped by month', async () => {
|
||||
server.use(
|
||||
...connectedHandlers,
|
||||
http.post('/api/integrations/memories/immich/search', () =>
|
||||
HttpResponse.json({
|
||||
assets: [
|
||||
{ id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: 'Paris', country: 'France' },
|
||||
],
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
await screen.findByText('No photos found');
|
||||
|
||||
const [firstAddBtn] = screen.getAllByText('Add photos');
|
||||
await userEvent.click(firstAddBtn);
|
||||
|
||||
await screen.findByText('Select photos from Immich');
|
||||
|
||||
// Month group header appears after photos load
|
||||
await screen.findByText(/March.*2025|2025.*March/);
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-016: Album picker lists available albums with asset count', async () => {
|
||||
server.use(
|
||||
...connectedHandlers,
|
||||
http.get('/api/integrations/memories/immich/albums', () =>
|
||||
HttpResponse.json({
|
||||
albums: [
|
||||
{ id: 'album1', albumName: 'Summer 2025', assetCount: 42 },
|
||||
],
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
await screen.findByText('No photos found');
|
||||
|
||||
await userEvent.click(screen.getByText('Link Album'));
|
||||
|
||||
await screen.findByText('Summer 2025');
|
||||
// Asset count is rendered next to the album name
|
||||
expect(screen.getByText(/42/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-017: ProviderTabs appear in picker when multiple providers are connected', async () => {
|
||||
const immich2Addon = {
|
||||
id: 'immich2',
|
||||
name: 'Immich2',
|
||||
type: 'photo_provider',
|
||||
enabled: true,
|
||||
config: { status_get: '/integrations/memories/immich2/status' },
|
||||
};
|
||||
|
||||
server.use(
|
||||
http.get('/api/addons', () =>
|
||||
HttpResponse.json({ addons: [immichAddon, immich2Addon] })
|
||||
),
|
||||
http.get('/api/integrations/memories/immich/status', () => HttpResponse.json({ connected: true })),
|
||||
http.get('/api/integrations/memories/immich2/status', () => HttpResponse.json({ connected: true })),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => HttpResponse.json({ photos: [] })),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => HttpResponse.json({ links: [] })),
|
||||
http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({ assets: [] })),
|
||||
http.post('/api/integrations/memories/immich2/search', () => HttpResponse.json({ assets: [] })),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
await screen.findByText('No photos found');
|
||||
|
||||
const [firstAddBtn] = screen.getAllByText('Add photos');
|
||||
await userEvent.click(firstAddBtn);
|
||||
|
||||
// With multiple providers the picker header uses the "multiple" translation
|
||||
await screen.findByText('Select Photos');
|
||||
|
||||
// Both provider name tabs are rendered inside the picker
|
||||
expect(screen.getByRole('button', { name: 'Immich' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Immich2' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-018: Location filter dropdown appears when photos have multiple cities', async () => {
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
{ photo_id: 10, asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
|
||||
{ photo_id: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
|
||||
],
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
// Location dropdown shows "All locations" option when there are 2+ distinct cities
|
||||
await screen.findByText('All locations');
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-019: Full picker flow: select photo → confirm dialog → execute add', async () => {
|
||||
let addPhotosCalled = false;
|
||||
|
||||
server.use(
|
||||
...connectedHandlers,
|
||||
http.post('/api/integrations/memories/immich/search', () =>
|
||||
HttpResponse.json({
|
||||
assets: [
|
||||
{ id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: null, country: null },
|
||||
],
|
||||
})
|
||||
),
|
||||
http.post('/api/integrations/memories/unified/trips/:tripId/photos', () => {
|
||||
addPhotosCalled = true;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
await screen.findByText('No photos found');
|
||||
|
||||
const [firstAddBtn] = screen.getAllByText('Add photos');
|
||||
await userEvent.click(firstAddBtn);
|
||||
|
||||
await screen.findByText('Select photos from Immich');
|
||||
|
||||
// Wait for the picker asset thumbnail to render (ProviderImg sets src after blob resolves)
|
||||
// img has alt="" so findByRole('img') won't work — use findByAltText instead
|
||||
const thumbnail = await screen.findByAltText('');
|
||||
|
||||
// Click the thumbnail — bubbles up to the parent div's onClick to select it
|
||||
await userEvent.click(thumbnail);
|
||||
|
||||
// "1 selected" count appears and "Add 1 photos" button is active
|
||||
await screen.findByText(/1\s+selected/);
|
||||
await userEvent.click(screen.getByText('Add 1 photos'));
|
||||
|
||||
// Confirm share dialog appears
|
||||
await screen.findByText('Share with trip members?');
|
||||
|
||||
// Click the confirm "Share photos" button to execute
|
||||
await userEvent.click(screen.getByText('Share photos'));
|
||||
|
||||
await waitFor(() => expect(addPhotosCalled).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-020: "All photos" filter tab makes an unfiltered search', async () => {
|
||||
let searchCount = 0;
|
||||
|
||||
server.use(
|
||||
...connectedHandlers,
|
||||
http.post('/api/integrations/memories/immich/search', () => {
|
||||
searchCount++;
|
||||
return HttpResponse.json({ assets: [] });
|
||||
}),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
await screen.findByText('No photos found');
|
||||
|
||||
const [firstAddBtn] = screen.getAllByText('Add photos');
|
||||
await userEvent.click(firstAddBtn);
|
||||
|
||||
await screen.findByText('Select photos from Immich');
|
||||
|
||||
// Click "All photos" — triggers a second loadPickerPhotos(false) call
|
||||
await userEvent.click(screen.getByText('All photos'));
|
||||
|
||||
await waitFor(() => expect(searchCount).toBeGreaterThan(1));
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-021: Picker with no trip dates shows only "All photos" tab', async () => {
|
||||
server.use(
|
||||
...connectedHandlers,
|
||||
http.post('/api/integrations/memories/immich/search', () =>
|
||||
HttpResponse.json({ assets: [] })
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel tripId={1} startDate={null} endDate={null} />);
|
||||
|
||||
await screen.findByText('No photos found');
|
||||
|
||||
const [firstAddBtn] = screen.getAllByText('Add photos');
|
||||
await userEvent.click(firstAddBtn);
|
||||
|
||||
await screen.findByText('Select photos from Immich');
|
||||
|
||||
// "Trip dates" tab is absent when dates are not set
|
||||
expect(screen.queryByText(/Trip dates/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText('All photos')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-022: Provider with no status_get URL shows not-connected', async () => {
|
||||
server.use(
|
||||
http.get('/api/addons', () =>
|
||||
HttpResponse.json({
|
||||
addons: [
|
||||
{ id: 'myapp', name: 'MyApp', type: 'photo_provider', enabled: true, config: {} },
|
||||
],
|
||||
})
|
||||
),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({ photos: [] })
|
||||
),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
|
||||
HttpResponse.json({ links: [] })
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
// Provider name shown in the not-connected message when exactly 1 enabled provider
|
||||
await screen.findByText('MyApp not connected');
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-023: Picker marks already-added photos with "Added" overlay', async () => {
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
{
|
||||
asset_id: 'asset1',
|
||||
provider: 'immich',
|
||||
user_id: 1,
|
||||
username: 'me',
|
||||
shared: 1,
|
||||
added_at: '2025-03-05T10:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
http.post('/api/integrations/memories/immich/search', () =>
|
||||
HttpResponse.json({
|
||||
assets: [
|
||||
{ id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: null, country: null },
|
||||
],
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
// Gallery shows own photo — "Stop sharing" title confirms it's loaded
|
||||
await screen.findByTitle('Stop sharing');
|
||||
|
||||
// Open picker from the header button (only 1 "Add photos" button since photos > 0)
|
||||
await userEvent.click(screen.getByText('Add photos'));
|
||||
await screen.findByText('Select photos from Immich');
|
||||
|
||||
// The asset already in the gallery shows the "Added" overlay in the picker
|
||||
await screen.findByText('Added');
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-024: Location filter select filters the visible photos', async () => {
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
{ photo_id: 10, asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
|
||||
{ photo_id: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
|
||||
],
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
const select = await screen.findByRole('combobox');
|
||||
|
||||
// Change filter to a specific city
|
||||
await userEvent.selectOptions(select, 'Paris');
|
||||
|
||||
expect(select).toHaveValue('Paris');
|
||||
});
|
||||
|
||||
it("FE-COMP-MEMORIESPANEL-025: Album link from another user shows username but no unlink button", async () => {
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
|
||||
HttpResponse.json({
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
provider: 'immich',
|
||||
album_id: 'a1',
|
||||
album_name: 'Holidays',
|
||||
user_id: 2,
|
||||
username: 'Alice',
|
||||
sync_enabled: 1,
|
||||
last_synced_at: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
|
||||
await screen.findByText('Holidays');
|
||||
|
||||
// Other user's username is shown in parentheses
|
||||
expect(screen.getByText('(Alice)')).toBeInTheDocument();
|
||||
|
||||
// Unlink button is NOT shown for another user's album link
|
||||
expect(screen.queryByTitle('Unlink album')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-026: Linking an album calls the album-links POST endpoint', async () => {
|
||||
let linkCalled = false;
|
||||
// Track whether POST has been made so the GET can return different data
|
||||
let albumLinked = false;
|
||||
|
||||
server.use(
|
||||
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
|
||||
http.get('/api/integrations/memories/immich/albums', () =>
|
||||
HttpResponse.json({
|
||||
albums: [{ id: 'album1', albumName: 'Summer 2025', assetCount: 10 }],
|
||||
})
|
||||
),
|
||||
http.post('/api/integrations/memories/unified/trips/:tripId/album-links', () => {
|
||||
linkCalled = true;
|
||||
albumLinked = true;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
// Return empty before POST, linked album after POST
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => {
|
||||
if (!albumLinked) return HttpResponse.json({ links: [] });
|
||||
return HttpResponse.json({
|
||||
links: [{ id: 1, provider: 'immich', album_id: 'album1', album_name: 'Summer 2025', user_id: 1, username: 'me', sync_enabled: 1, last_synced_at: null }],
|
||||
});
|
||||
}),
|
||||
http.post('/api/integrations/memories/:provider/trips/:tripId/album-links/:linkId/sync', () =>
|
||||
HttpResponse.json({ ok: true })
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
await screen.findByText('No photos found');
|
||||
|
||||
await userEvent.click(screen.getByText('Link Album'));
|
||||
await screen.findByText('Summer 2025');
|
||||
|
||||
// Click the album button to link it (album is not yet linked → button is enabled)
|
||||
await userEvent.click(screen.getByText('Summer 2025'));
|
||||
|
||||
await waitFor(() => expect(linkCalled).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMORIESPANEL-027: Album picker cancel button returns to the gallery', async () => {
|
||||
server.use(
|
||||
...connectedHandlers,
|
||||
http.get('/api/integrations/memories/immich/albums', () =>
|
||||
HttpResponse.json({ albums: [] })
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoriesPanel {...defaultProps} />);
|
||||
await screen.findByText('No photos found');
|
||||
|
||||
await userEvent.click(screen.getByText('Link Album'));
|
||||
await screen.findByText('Select Immich Album');
|
||||
|
||||
// Click Cancel to dismiss without linking
|
||||
await userEvent.click(screen.getByText('Cancel'));
|
||||
|
||||
// Gallery is restored
|
||||
await screen.findByText('No photos found');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,9 +6,10 @@ import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useInAppNotificationStore } from '../../store/inAppNotificationStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildSettings } from '../../../tests/helpers/factories';
|
||||
import type { InAppNotification } from '../../store/inAppNotificationStore';
|
||||
import InAppNotificationItem from './InAppNotificationItem';
|
||||
|
||||
const buildNotification = (overrides = {}) => ({
|
||||
const buildNotification = (overrides: Partial<InAppNotification> = {}): InAppNotification => ({
|
||||
id: 1,
|
||||
type: 'simple',
|
||||
scope: 'trip',
|
||||
@@ -18,15 +19,15 @@ const buildNotification = (overrides = {}) => ({
|
||||
sender_avatar: null,
|
||||
recipient_id: 1,
|
||||
title_key: 'notifications.title',
|
||||
title_params: '{}',
|
||||
title_params: {},
|
||||
text_key: 'notifications.empty',
|
||||
text_params: '{}',
|
||||
text_params: {},
|
||||
positive_text_key: null,
|
||||
negative_text_key: null,
|
||||
response: null,
|
||||
navigate_text_key: null,
|
||||
navigate_target: null,
|
||||
is_read: 0,
|
||||
is_read: false,
|
||||
created_at: new Date().toISOString(),
|
||||
...overrides,
|
||||
});
|
||||
@@ -62,12 +63,12 @@ describe('InAppNotificationItem', () => {
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIF-005: shows Mark as read button for unread notification', () => {
|
||||
render(<InAppNotificationItem notification={buildNotification({ is_read: 0 })} />);
|
||||
render(<InAppNotificationItem notification={buildNotification({ is_read: false })} />);
|
||||
expect(screen.getByTitle('Mark as read')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIF-006: does not show Mark as read button for read notification', () => {
|
||||
render(<InAppNotificationItem notification={buildNotification({ is_read: 1 })} />);
|
||||
render(<InAppNotificationItem notification={buildNotification({ is_read: true })} />);
|
||||
expect(screen.queryByTitle('Mark as read')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -80,7 +81,7 @@ describe('InAppNotificationItem', () => {
|
||||
const user = userEvent.setup();
|
||||
const markRead = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useInAppNotificationStore, { markRead });
|
||||
render(<InAppNotificationItem notification={buildNotification({ id: 42, is_read: 0 })} />);
|
||||
render(<InAppNotificationItem notification={buildNotification({ id: 42, is_read: false })} />);
|
||||
await user.click(screen.getByTitle('Mark as read'));
|
||||
expect(markRead).toHaveBeenCalledWith(42);
|
||||
});
|
||||
@@ -190,7 +191,7 @@ describe('InAppNotificationItem', () => {
|
||||
type: 'navigate',
|
||||
navigate_text_key: 'notifications.title',
|
||||
navigate_target: '/trips/1',
|
||||
is_read: 0,
|
||||
is_read: false,
|
||||
})}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
@@ -53,10 +53,9 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative px-4 py-3 transition-colors"
|
||||
className="relative px-4 py-3 transition-colors border-b border-edge-secondary"
|
||||
style={{
|
||||
background: notification.is_read ? 'transparent' : (dark ? 'rgba(99,102,241,0.07)' : 'rgba(99,102,241,0.05)'),
|
||||
borderBottom: '1px solid var(--border-secondary)',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -71,8 +70,8 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold"
|
||||
style={{ background: dark ? '#27272a' : '#f1f5f9', color: 'var(--text-muted)' }}
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-content-muted"
|
||||
style={{ background: dark ? '#27272a' : '#f1f5f9' }}
|
||||
>
|
||||
{notification.sender_username
|
||||
? notification.sender_username.charAt(0).toUpperCase()
|
||||
@@ -85,11 +84,11 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium leading-snug" style={{ color: 'var(--text-primary)' }}>
|
||||
<p className="text-sm font-medium leading-snug text-content">
|
||||
{hasUnknownTitle ? notification.title_key : titleText}
|
||||
</p>
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<span className="text-xs mr-1" style={{ color: 'var(--text-faint)' }}>
|
||||
<span className="text-xs mr-1 text-content-faint">
|
||||
{relativeTime(notification.created_at, locale)}
|
||||
</span>
|
||||
{!notification.is_read && (
|
||||
@@ -117,7 +116,7 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs mt-0.5 leading-relaxed" style={{ color: 'var(--text-muted)' }}>
|
||||
<p className="text-xs mt-0.5 leading-relaxed text-content-muted">
|
||||
{hasUnknownBody ? notification.text_key : bodyText}
|
||||
</p>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user