/mcp` by default and validated on every MCP request
+- **Consent screen** — user-facing scope selection with grouped permission display
+- **Admin panel** — OAuth sessions management in MCP Access panel with collapsible scope lists
+- **Per-client rate limiting** — configurable rate limits per OAuth client
+- **Addon gating** — MCP tools are only registered when their corresponding addon is enabled
+- **Compound tools** — single-call multi-step workflows (e.g. create day with places in one tool call, fetch full trip context) to reduce MCP round-trips
+- **Surface alignment** — MCP tool schemas and responses kept in sync with the current app state (fewer drifted fields, correct enum sets)
+- **Static token deprecation** — existing MCP tokens still work but surface deprecation notices; migration path to OAuth is documented
+- **Collab sub-feature gating** — MCP tools for chat/notes/polls respect the admin-level collab sub-feature toggles
+
+---
+
+## Self-Service Password Reset
+
+Users can now reset their own password without admin intervention.
+
+- **Email-based flow** — `/forgot-password` issues a single-use reset token delivered via SMTP (or logged to the server console if SMTP is not configured)
+- **MFA-aware** — if the user has MFA enabled, the reset endpoint additionally verifies a TOTP code or backup code before rotating the password
+- **Session invalidation** — resetting the password bumps `users.password_version`, which kicks every existing JWT, MCP static token, and OAuth bearer token for that user out in one shot
+- **Server-side URL building** — the reset link is built from `APP_URL` / `ALLOWED_ORIGINS`, not from request headers, so a spoofed `Host` / `Origin` cannot redirect the link to an attacker-controlled domain
+- **Rate limiting + audit** — per-IP rate limit on `/forgot-password`, all requests audited (including "no such user" so abuse is visible)
+
+---
+
+## Dashboard Redesign
+
+The dashboard has been rebuilt with a mobile-first design language.
+
+### Mobile
+- **Greeting header** — "Good morning, {username}" with notification bell and avatar
+- **Spotlight hero card** — the next upcoming or ongoing trip as a full-width hero with cover image, progress bar (for live trips), stats grid, and frosted-glass action buttons
+- **Quick Actions** — New Trip, Currency Converter, Timezone as icon cards
+- **Trip cards** — cover image with title overlay, status badge (In X days / Starts today / Ongoing / Completed), bottom stats (starts, duration, places, buddies)
+
+### Desktop
+- **Unified header toolbar** — the dashboard, planner, vacay, and journey now share the same toolbar style
+- **Unified card design** — desktop grid cards now match the mobile card style (cover + title overlay + stats)
+- **Hero card** — SpotlightCard with progress bar for ongoing trips, countdown for upcoming, stats grid
+- **Hover actions** — edit/copy/archive/delete buttons appear on hover as frosted-glass icons
+- **Status badges** — CircleCheck icon for completed trips, Clock for upcoming, pulsing dot for ongoing
+
+### Both
+- **BottomNav profile sheet** — slide-up sheet with user info, settings, admin, and logout
+- **Dark mode** — full dark mode support across all new components
+- **Shared PageSidebar** — Settings and Admin pages share a single sidebar component for layout consistency
+
+---
+
+## PWA Offline Mode
+
+TREK now works offline as a Progressive Web App with full data synchronization.
+
+- **IndexedDB (Dexie) storage** — trips, places, assignments, categories, tags, accommodations, reservations, budget items, packing items, files, and trip members cached locally
+- **Offline mutation queue** — changes made offline are queued with monotonic timestamps and replayed on reconnect (FIFO)
+- **Offline dashboard** — trip list loaded from Dexie when network is unavailable
+- **Offline trip planner** — full planner functionality with cached data
+- **Repo layer** — all data access routed through repository layer that falls back to offline storage
+- **Offline banner** — visible indicator with safe-area-inset support for iOS PWA
+- **Idempotency keys** — prevents duplicate mutations on replay, scoped by `(key, user_id, method, path)` so the same key on different endpoints can't leak cached bodies
+- **Offline document downloads** — document downloads work from the PWA cache when the network is unavailable
+
+---
+
+## Transport Reservations: Multi-Day + Map Visualization
+
+- **Multi-day transport reservations** — flights, trains, cruises, car rentals can span multiple days with a dedicated modal and automatic route segmentation across the affected days (#384, #587)
+- **Map visualization** — transport endpoints render on both Leaflet and Mapbox GL maps as clickable badges with IATA codes, great-circle arcs for flights/cruises, straight lines for trains/cars, and a rotating mid-arc stats label (IATA → IATA · distance · duration) on flights
+- **Per-booking route toggle** — each booking in DayPlanSidebar has a "Show booking routes" button; connections only render when toggled on
+- **Check-in time ranges** — hotel bookings now support a check-in window (e.g. "15:00 -- 22:00") with a new `check_in_end` field (#366)
+- **Cascaded delete** — deleting a reservation now cleans up related budget items, file links, and trip_items
+
+---
+
+## Reservations Redesign
+
+The reservations panel has been completely redesigned with a modern, unified layout.
+
+- **Unified toolbar** — title, type filter pills with count badges, and add button in one row with muted background
+- **Type filters** — multi-select filter buttons (Flight, Hotel, Restaurant, etc.) with per-type count badges, persisted in sessionStorage
+- **Responsive grid** — auto-fill layout with max 3 columns that fills full width
+- **Card redesign** — status + type badge in header, labeled fields in rounded boxes, hover shadow
+- **Mobile responsive** — filters hidden on mobile, booking code on separate row, weekday hidden in dates, reduced padding
+
+---
+
+## Apple Wallet pkpass Support
+
+- **.pkpass MIME type** — server correctly serves `application/vnd.apple.pkpass` with the right Content-Type
+- **Upload + download** — .pkpass files can be attached to bookings or places and opened directly in Apple Wallet on iOS
+
+---
+
+## Todo Due-Date Reminders
+
+- **Scheduler** — a new background scheduler scans todos with upcoming due dates and sends one reminder per item (default lead: 3 days)
+- **No spam** — `todo_items.reminded_at` prevents re-sending a reminder for the same item on subsequent scheduler runs
+- **Notification channel aware** — reminders respect the user's notification channel preferences (email, webhook, ntfy)
+
+---
+
+## Collab Sub-Feature Toggles
+
+Individual collab sections can now be toggled on/off from the admin addons page (#604).
+
+- **Admin UI** — sub-toggles for Chat, Notes, Polls, and What's Next under the Collab addon, with icons matching the collab panel tabs
+- **Dynamic desktop layout** — Chat always stays at fixed 380px width; remaining active panels share space equally
+- **Mobile** — disabled tabs are hidden from the tab bar
+- **API** — GET/PUT /admin/collab-features endpoints stored in app_settings
+
+---
+
+## Place Import: KMZ/KML + Naver Maps + Selective GPX
+
+Three ways to import places into your trips.
+
+### KMZ/KML Import
+- **Unified file import modal** — drag-and-drop or file picker for KML, KMZ, and GPX files
+- **KMZ unpacking** — extracts KML from ZIP archive with 50MB decompressed size limit
+- **Folder-to-category mapping** — KML folders are automatically matched to TREK categories
+- **Place deduplication** — skips places that already exist in the trip (by name + coordinates)
+
+### Naver Maps List Import
+- **Always enabled** — no longer requires addon toggle, available alongside Google Maps list import
+- **Shortlink resolution** — resolves naver.me shortlinks to full list URLs
+- **Pagination support** — handles large Naver Maps lists with automatic pagination
+
+### Selective GPX/KML Element Import
+- **Pick what to import** — import modal now lets you choose individual waypoints / tracks / folders instead of an all-or-nothing dump
+- **Performance** — larger files (thousands of points) parse and render without freezing the UI
+
+---
+
+## Search Autocomplete
+
+- **Real-time suggestions** — autocomplete suggestions appear as you type in the place search field
+- **Google Places API** — primary autocomplete provider with location bias
+- **Nominatim fallback** — free fallback when Google API key is not configured
+- **Bounding box bias** — search results biased to the current map viewport
+
+---
+
+## ntfy Notification Channel
+
+- **ntfy as first-class channel** — push notifications via any ntfy server (self-hosted or ntfy.sh)
+- **Admin configuration** — server URL and topic configuration in admin panel with clear token button
+- **Per-user opt-in** — users can enable/disable ntfy in their notification preferences
+- **Full i18n** — ntfy strings translated in all 15 languages
+
+---
+
+## Login & Language
+
+- **Language dropdown on login page** — users can select their preferred language before logging in
+- **Browser auto-detection** — language is automatically detected from browser settings on first visit
+- **DEFAULT_LANGUAGE env var** — configurable default language for the instance, documented across all deployment configs (Docker, Helm, Synology)
+
+---
+
+## Granular Auth Toggles
+
+- **OIDC_ONLY replaced** — split into `DISABLE_LOCAL_LOGIN`, `DISABLE_LOCAL_REGISTRATION`, and `DISABLE_PASSWORD_CHANGE` for fine-grained control over authentication methods
+- Allows mixed setups (e.g., OIDC + local admin account, or OIDC-only with no local registration)
+
+---
+
+## Synology Photos: OTP, SSL Skip & Session Management
+
+- **OTP support** — one-time password field for 2FA-enabled Synology NAS
+- **Skip SSL verification** — toggle for self-signed certificates
+- **Device ID persistence** — prevents repeated 2FA prompts
+- **Session-cleared notification** — routed through unified notification system
+- **Provider URL hint** — contextual help text for Synology URL format
+- **Thumbnail size bump** — default thumbnail size raised from `sm` (240 px) to `m` (320 px) so grids no longer look pixelated on retina
+- **Passphrase support** — shared-album links with passphrases work from the browse UI (#689)
+
+---
+
+## Atlas Improvements
+
+- **Scoped region matching** — region name matching is now scoped by country to prevent cross-country false matches
+- **Expanded country lookup tables** — more countries and regions recognized correctly, including A3 fallback for invalid ISO_A2 codes
+- **Nominatim rate limiting** — shared throttle prevents 429 errors, background region fill, fetch timeout
+- **Stadia Maps fix** — resolved 401 errors on journey and atlas maps
+
+---
+
+## i18n: Full 15-Language Coverage
+
+- **Indonesian added** — complete translation with full parity to English, bringing the total to 15 languages (EN, DE, FR, ES, IT, NL, PL, RU, ZH, ZH-TW, BR, CS, HU, AR, ID)
+- **Comprehensive audit** — every key translated natively, no English fallbacks
+- **OAuth scope labels** — all 24 scopes have localized names and descriptions
+- **Journey addon** — complete coverage for all journal, editor, sharing, and PDF export strings
+- **Mapbox GL settings** — localized labels for renderer toggle, style picker, 3D / quality switches
+- **Ellipsis standardization** — all ellipsis characters normalized to three dots (...)
+
+---
+
+## Vacay Improvements
+
+- **Trip indicator dots** — small blue dots on calendar days where trips are scheduled
+- **Configurable week start** — choose Monday or Sunday as first day of the week (#224)
+- **Holiday overlap** — vacations can now be placed on public holidays
+- **Today marker** — visual indicator for the current day in the calendar
+- **Unified toolbar** — same header style as planner/dashboard/journey
+- **Bottom padding fix** — toolbar no longer overlaps the last row (#533)
+
+---
+
+## iCal Export Improvements
+
+- **Day activities and notes** — iCal export now includes daily activities and notes, not just the trip dates (#375)
+
+---
+
+## Budget Improvements
+
+- **Drag-and-drop reorder** — budget categories and individual items can be reordered via drag-and-drop (#479)
+- **Category legend redesign** — prevents overflow on small screens (#564)
+- **Comma decimal support** — pasting numbers with comma separators works correctly
+- **Table alignment fix** — budget data rows and the "New Entry" row now share column widths (#759)
+
+---
+
+## Packing List Improvements
+
+- **Bulk import + template apply without full reload** — new items appear in place instead of triggering the trip loading screen (#760)
+- **Reservation link cleanup** — packing items linked to deleted reservations stay in the list without the dangling reference
+- **Bag tracking** — keep track of which items live in which bag, with optional weight tracking and per-bag totals
+
+---
+
+## Planner & UX Improvements
+
+- **Emil-style polish pass** — consistent transitions/animations across cards, hover states, and drawer sheets; shared components for toolbars and section headers
+- **Planner drag-and-drop jank fix** — dragging places across days is smooth again on long trips
+- **Unified toolbar header** — dashboard, planner, vacay, and journey share a single toolbar style for visual consistency
+- **Places sidebar polish** — filter counts, compact select UI, tooltip component, "No Category" / "Uncategorized" filter (#607)
+- **Dayplan toolbar polish** — cleaner alignment, weather archive fallback for past trips
+- **Unplanned filter sync** — unplanned filter properly syncs with map markers (#385)
+- **Place notes** — notes textarea in place edit form with proper display in inspector (#596)
+- **Place deduplication** — Google Maps list re-import skips existing places (#543)
+- **File download button** — all file views now include a download button
+- **Note modal** — no longer closes on outside click (#480)
+- **Google Maps links** — use place name + google_place_id for accurate links (#554)
+- **Packing list menu** — no longer cut off by overflow (#557)
+- **Trip date change** — preserving day content when date range changes
+- **PDF export** — render restaurant, event, tour, and other reservation types
+
+---
+
+## Admin Panel Improvements
+
+- **Collab sub-feature toggles** — individual toggles for Chat, Notes, Polls, What's Next
+- **Photo provider icons** — Immich and Synology Photos SVG brand icons in addon manager
+- **Bag tracking icon** — Luggage icon for the bag tracking sub-toggle
+- **Naver List Import** — now always enabled, removed from addon toggles
+- **Shared PageSidebar** — admin pages use the same sidebar layout as Settings
+
+---
+
+## Mobile Improvements
+
+- **Bottom nav fix** — prevent clipping of scrollable content and dialogs
+- **Journey mobile** — compact add-entry button, scrollable settings dialog, iOS PWA fixes, drop hero / inline tab-bar, eager map tiles, trimmed picker labels
+- **Dashboard mobile** — spotlight trip in hero, smaller badges, check icon for completed
+- **Bottom nav dark mode** — consistent dark mode styling
+- **Safe area support** — proper insets for iOS PWA
+
+---
+
+## Documentation & Wiki
+
+- **Full GitHub Wiki** — 74 pages covering setup, deployment, addon docs, troubleshooting, API reference, and MCP
+- **CI sync workflow** — `./wiki/**` in the main repo is auto-synced to the GitHub Wiki on push to `main`
+- **README redesign** — Apple-style hero with animated video, feature tiles, and a screenshot gallery; hero video hosted externally so the repo stays lightweight
+- **MCP compound tools doc** — `MCP.md` documents the compound / multi-step tools
+
+---
+
+## Security
+
+Fifth-pass internal audit. Critical + High + Medium findings addressed in one bundled PR:
+
+- **JWT password_version gate** — a single `verifyJwtAndLoadUser` helper is now used by every auth surface (web session, MCP bearer, file download token, photo route, MFA policy). A password reset bumps `password_version` and invalidates every outstanding session/token for the user in one shot.
+- **MFA policy via cookie** — `require_mfa` now applies to cookie-authenticated SPA sessions too (previously only the `Authorization` header was checked, so the whole SPA bypassed it).
+- **OIDC id_token verification** — full JWKS-based signature verification (iss, aud, exp, nbf) plus `userinfo.sub == id_token.sub` cross-check. `kid` match is strict — no fallback to an arbitrary key.
+- **OIDC invite redemption** — invite-token increment and user INSERT run in a single `db.transaction`; concurrent callbacks cannot double-redeem a single-use invite.
+- **OAuth 2.1 DCR** — redirect_uri allowlist rejects `javascript:` / `data:` / `vbscript:` / `file:` / `blob:` / `about:` / `chrome:` and requires private-use schemes to be reverse-DNS (RFC 8252 §7.1).
+- **OAuth audience binding** — `audience` defaults to the MCP endpoint when no `resource` parameter is sent, so new tokens always carry the correct audience claim.
+- **HSTS on in production** — `NODE_ENV=production` is enough to enable HSTS (previously required `FORCE_HTTPS=true`). `includeSubDomains` stays off by default to avoid breaking apex-domain setups; opt in with `HSTS_INCLUDE_SUBDOMAINS=true`.
+- **Cookie Secure behind proxies** — `trek_session` Secure flag is now derived from `req.secure` (Express's `trust proxy`-aware field), so instances behind Traefik / Caddy / Cloudflare Tunnel get Secure cookies without `FORCE_HTTPS`.
+- **Share-token expiry** — public share tokens default to 90-day TTL. Existing tokens stay NULL (no expiry) so already-distributed links keep working.
+- **Photo route scoping** — share tokens can only unlock photos that belong to the same trip as the token.
+- **Bcrypt MFA backup codes** — backup codes are now bcrypt-hashed at rest. Legacy SHA-256 codes keep working until the user regenerates.
+- **Demo-mode guards** — single `DEMO_EMAILS` registry fixes the drift where `demoUploadBlock` only matched the pre-rename `demo@nomad.app` string.
+- **Filesystem safety** — `permanentDeleteFile` / `emptyTrash` / avatar cleanup use async `fs.promises.rm({ force: true })` and only drop the DB row when the on-disk unlink actually succeeded.
+- **Idempotency store hardening** — key length capped at 128 chars, response bodies over 256 KiB not cached, primary key widened to `(key, user_id, method, path)` so the same key on a different endpoint does not replay an unrelated response.
+- **Permissions cache invalidation** — `restoreFromZip` now drops the permissions cache after a DB swap.
+- **Reset-URL source** — password-reset email URL is built from server-side `APP_URL` / `ALLOWED_ORIGINS`, never from request headers.
+- **Critical DB indexes** — added `trips(user_id)`, `trips(created_at DESC)`, `photos(day_id/place_id)`, `reservations(day_id)`, `share_tokens(token)` and conditional `day_accommodations` / `notifications` indexes.
+
+Upstream CVEs patched:
+
+- **hono** 4.12.9 to 4.12.12 — directory traversal (CVE-2026-39407, CVE-2026-39408), HTTP response splitting, improper input validation (CVE-2026-39410), IP restriction bypass (CVE-2026-39409)
+- **@hono/node-server** 1.19.11 to 1.19.13 — directory traversal (CVE-2026-39406)
+- **nodemailer** 8.0.4 to 8.0.5 — CRLF injection
+
+---
+
+## Bug Fixes
+
+- Fixed OIDC-only mode login/logout loop (#491)
+- Fixed dayplan duplicate reservation display, date off-by-one, and missing day_id on edit
+- Fixed booking date handling and file auth bugs
+- Fixed dayplan time-based auto-sort for places and free reorder for untimed
+- Fixed streaming response end on client disconnect during asset pipe
+- Fixed per-day transport positions for multi-day reservations
+- Fixed stale budget category reset when category no longer exists
+- Fixed trip redirect to plan tab when active tab addon is disabled
+- Fixed reservation price/budget field visibility when budget addon disabled
+- Fixed HEIC photo rendering on non-Safari browsers
+- Fixed CSP path matching for paths ending in /
+- Fixed avatar URLs in notifications, admin panel, and budget
+- Fixed budget member avatars lost after updating item fields
+- Fixed budget table column alignment broken by `display: flex` on `` (#759)
+- Fixed collab notes line break preservation (#608)
+- Fixed weather archive date handling for future trips (#599)
+- Fixed duplicate skeleton entries for multi-day places (#606)
+- Fixed ghost Gallery / `[Trip Photos]` entries in journal timeline and public share (#764)
+- Fixed journey reorder arrows rendering on skeleton suggestions (#763)
+- Fixed journey map OSM tile warning (#627)
+- Fixed journey gallery picker grid collapse on Safari (#717)
+- Fixed content divider placement in journal entries (#624)
+- Fixed local photos wrong provider label (#625)
+- Fixed Synology pagination and album scroll leak (#644)
+- Fixed Stadia Maps 401 on journey and atlas maps (#640)
+- Fixed Nominatim User-Agent and error diagnostics
+- Fixed map tooltips, journey creation, and contributor avatars
+- Fixed notifications SMTP error surfacing, webhook button label, backup timestamp (#537)
+- Fixed stale accommodation_id on reservation update (#522)
+- Fixed hardcoded Immich in toast — now uses provider_name
+- Fixed MCP safeBroadcast recursive call bug
+- Fixed MCP Zod v4 `z.record()` API compatibility in transport tool schemas
+- Fixed Vite module preload polyfill CSP inline script violation
+- Fixed PWA offline session redirect and file download auth (#505, #541)
+- Fixed `FORCE_HTTPS` redirect applying to `/api/health`, breaking container health-checks
+- Fixed journey bugs reported by @roel-de-vries (#722–#736)
+
+---
+
+## Infrastructure
+
+- **Prerelease workflow** — automated prerelease pipeline with major version support, version propagation, and race/orphan tag protection
+- **Helm chart** — moved to `charts/trek/`, published via helm-publisher action to `gh-pages`, `appVersion` used as default image tag
+- **Docker** — workflow improvements, tag management cleanup, `server/data/airports.json` properly included in image after assets refactor
+- **CI** — contributor workflow automation, `npm audit` removal from install steps, manual trigger for prerelease, client test job added alongside server tests with split coverage artifacts
+
+---
+
+## Test Coverage
+
+- **Backend** — expanded to ~87% coverage with comprehensive tests for OAuth, MCP tools, addon gating, services, and session management
+- **Frontend** — expanded to ~82% coverage with tests for dashboard, planner, settings, admin panels, and component interactions
+- **Journey** — 89.5% new code coverage
+
+---
+
+## Contributors
+
+Thanks to everyone who contributed to this release:
+
+- @mauriceboe
+- @jubnl
+- @gravitysc
+- @luojiyin1987
+- @marco783
+- @isaiastavares
+- @tiquis0290
+- @xenocent
+- @gfrcsd
+- @roel-de-vries
+
+---
+
+## Stats
+
+| Metric | Value |
+|--------|-------|
+| Commits | 500+ |
+| Merged PRs | 130+ |
+| Files changed | 700+ |
+| Lines added | 120,000+ |
+| Contributors | 12+ |
+
+---
+
+## Upgrading
+
+```bash
+docker pull mauriceboe/trek:3.0.0
+docker compose up -d
+```
+
+Migrations run automatically on startup. No manual steps required.
+
+**Checklist:**
+1. Update your Immich API key to include `asset.upload` (optional, only needed for Journey upload sync)
+2. If using `OIDC_ONLY`, migrate to `DISABLE_LOCAL_LOGIN` + `DISABLE_LOCAL_REGISTRATION`
+3. Enable the Journey addon in Settings > Addons to start using the travel journal
+4. Try the Mapbox GL renderer in Settings > Map if you want 3D terrain and a proper globe view (requires a free Mapbox access token)
diff --git a/.issue-scratch/release-draft.md b/.issue-scratch/release-draft.md
new file mode 100644
index 00000000..9595d730
--- /dev/null
+++ b/.issue-scratch/release-draft.md
@@ -0,0 +1,405 @@
+
+
+
+# TREK 3.0.0
+
+> **This is the biggest TREK release to date.** Journey turns your trips into rich travel journals. MCP gets full OAuth 2.1 security. The dashboard has been redesigned for mobile-first. And every corner of the app now speaks 15 languages natively.
+
+---
+
+## Breaking Changes
+
+### Photos moved from Trip Planner to Journey
+
+In previous versions, Immich and Synology Photos were integrated directly into the Trip Planner via a "Photos" tab. **This tab has been removed.** Photos are now part of the new **Journey addon**, which is purpose-built for documenting your travels with stories, photos, and maps.
+
+**What this means for you:**
+- **No photos are lost.** The previous integration was read-only — TREK never uploaded to or deleted from your Immich/Synology library. Your photos remain untouched in your photo provider.
+- **Previously linked trip photos are no longer displayed in the Trip Planner.** To view and organize your travel photos, enable the Journey addon (Settings > Addons) and create a Journey linked to your trip.
+- **Journey brings a much richer photo experience:** upload photos directly to TREK, browse and import from Immich/Synology with duplicate detection, reorder photos, view EXIF metadata, and export everything as a PDF photo book.
+
+### New Immich API Key Permissions Required
+
+Journey introduces **photo upload sync** — when you upload a photo to a Journey entry, TREK can optionally sync it to your Immich library. This requires an additional Immich API permission that was not needed before.
+
+**Previous versions required:**
+| Permission | Used for |
+|---|---|
+| `user.read` | Connection test |
+| `asset.read` | Browse photos by date, search |
+| `asset.view` | Stream thumbnails |
+| `asset.download` | Stream originals |
+| `album.read` | List and browse albums |
+| `timeline.read` | Browse timeline buckets |
+
+**New in 3.0.0 — additionally required:**
+| Permission | Used for |
+|---|---|
+| `asset.upload` | Sync uploaded Journey photos to Immich |
+
+> **How to update your Immich API key:** Go to your Immich instance > User Settings > API Keys. Edit your existing TREK key (or create a new one) and ensure `asset.upload` is enabled in addition to the existing permissions. If you don't plan to use Journey's upload sync, the old key will continue to work — the upload simply won't sync to Immich.
+
+**No changes needed for Synology Photos** — Synology uses session-based authentication which inherits the user's full permissions.
+
+### OIDC_ONLY deprecated
+
+The `OIDC_ONLY` environment variable is deprecated. Replace with `DISABLE_LOCAL_LOGIN=true` + `DISABLE_LOCAL_REGISTRATION=true` for equivalent behavior. The old variable still works but will be removed in a future release.
+
+---
+
+
+## Journey Addon — Travel Journal
+
+The headline feature of 3.0.0. Journey is a new global addon that transforms your trips into magazine-style travel stories.
+
+### Core
+- **5-table schema** — journeys, entries, photos, trips, contributors with full relational integrity
+- **Trip-to-Journey sync engine** — link one or more trips to a journey; skeleton entries and photos are synced automatically
+- **Timeline, Gallery, and Map views** — browse entries chronologically, as a photo grid, or on an interactive map with SVG pin markers
+- **Entry editor** — markdown toolbar, custom date picker, location search (Nominatim/Google Maps), mood (Amazing/Good/Neutral/Rough), weather (Sunny to Snowy), and Pros & Cons sections
+
+### Photos
+- **Immich & Synology browser** — browse by trip dates, custom range, or album with duplicate detection
+- **Photo upload** — direct upload with drag-and-drop, reorder (Make 1st), and delete
+- **EXIF metadata** — displayed in lightbox for Immich photos
+- **Thumbnail to original fallback** — seamless resolution upgrade everywhere
+- **HEIC rendering fix** — serve fullsize thumbnail for original to fix HEIC rendering on non-Safari browsers
+- **Contributor photo access** — invited contributors can view all journey photos even without their own Immich/Synology connection (owner credentials are used for the proxy)
+
+### Sharing & Export
+- **Public share links** — token-based access with language picker, no login required
+- **Public photo proxy** — validates share token instead of auth for photo streaming
+- **PDF photo book export** — Polarsteps-inspired layout with cover, day chapters, photo grids, and stories
+
+### Collaboration
+- **Contributors** — invite users as editors or viewers
+- **Trip linking/unlinking** — manage synced trips from Journey Settings and Desktop Sidebar
+- **Cover image** — upload or pick from journey photos
+
+### Frontend
+- **JourneyPage** — frontpage with hero card, active journey stats, trip suggestions ("Trip just ended — turn it into a Journey")
+- **JourneyDetailPage** — full timeline/gallery/map with inline entry editing
+- **JourneyPublicPage** — public share view with language picker and read-only timeline
+
+---
+
+## MCP: OAuth 2.1 & Granular Scopes
+
+MCP authentication has been completely rebuilt around the OAuth 2.1 specification.
+
+- **OAuth 2.1 authorization server** — full PKCE flow with authorization codes, access tokens, refresh tokens, and token rotation with replay detection
+- **Granular scopes** — 24 scopes across 11 groups (trips, places, atlas, packing, todos, budget, reservations, collab, notifications, vacay, geo/weather) with per-scope read/write/delete control
+- **Dynamic Client Registration (DCR)** — RFC 7591 endpoint at POST /oauth/register for browser-initiated and public clients
+- **Consent screen** — user-facing scope selection with grouped permission display
+- **Admin panel** — OAuth sessions management in MCP Access panel with collapsible scope lists
+- **Per-client rate limiting** — configurable rate limits per OAuth client
+- **Addon gating** — MCP tools are only registered when their corresponding addon is enabled
+- **Static token deprecation** — existing MCP tokens still work but surface deprecation notices; migration path to OAuth is documented
+- **Security hardening** — Critical + High + Medium findings addressed (token storage, PKCE enforcement, scope validation)
+
+---
+
+## Dashboard Redesign
+
+The dashboard has been rebuilt with a mobile-first design language.
+
+### Mobile
+- **Greeting header** — "Good morning, {username}" with notification bell and avatar
+- **Spotlight hero card** — the next upcoming or ongoing trip as a full-width hero with cover image, progress bar (for live trips), stats grid, and frosted-glass action buttons
+- **Quick Actions** — New Trip, Currency Converter, Timezone as icon cards
+- **Trip cards** — cover image with title overlay, status badge (In X days / Starts today / Ongoing / Completed), bottom stats (starts, duration, places, buddies)
+
+### Desktop
+- **Unified card design** — desktop grid cards now match the mobile card style (cover + title overlay + stats)
+- **Hero card** — SpotlightCard with progress bar for ongoing trips, countdown for upcoming, stats grid
+- **Hover actions** — edit/copy/archive/delete buttons appear on hover as frosted-glass icons
+- **Status badges** — CircleCheck icon for completed trips, Clock for upcoming, pulsing dot for ongoing
+
+### Both
+- **BottomNav profile sheet** — slide-up sheet with user info, settings, admin, and logout
+- **Dark mode** — full dark mode support across all new components
+
+---
+
+## PWA Offline Mode
+
+TREK now works offline as a Progressive Web App with full data synchronization.
+
+- **IndexedDB (Dexie) storage** — trips, places, assignments, categories, tags, accommodations, reservations, budget items, packing items, files, and trip members cached locally
+- **Offline mutation queue** — changes made offline are queued with monotonic timestamps and replayed on reconnect (FIFO)
+- **Offline dashboard** — trip list loaded from Dexie when network is unavailable
+- **Offline trip planner** — full planner functionality with cached data
+- **Repo layer** — all data access routed through repository layer that falls back to offline storage
+- **Offline banner** — visible indicator with safe-area-inset support for iOS PWA
+- **Idempotency keys** — prevents duplicate mutations on replay (Migration 100)
+
+---
+
+## Reservations Redesign
+
+The reservations panel has been completely redesigned with a modern, unified layout.
+
+- **Unified toolbar** — title, type filter pills with count badges, and add button in one row with muted background
+- **Type filters** — multi-select filter buttons (Flight, Hotel, Restaurant, etc.) with per-type count badges, persisted in sessionStorage
+- **Responsive grid** — auto-fill layout with max 3 columns that fills full width
+- **Card redesign** — status + type badge in header, labeled fields in rounded boxes, hover shadow
+- **Check-in time ranges** — hotel bookings now support a check-in window (e.g. "15:00 -- 22:00") with a new check_in_end field (#366)
+- **Mobile responsive** — filters hidden on mobile, booking code on separate row, weekday hidden in dates, reduced padding
+
+---
+
+## Collab Sub-Feature Toggles
+
+Individual collab sections can now be toggled on/off from the admin addons page (#604).
+
+- **Admin UI** — sub-toggles for Chat, Notes, Polls, and What's Next under the Collab addon, with icons matching the collab panel tabs
+- **Dynamic desktop layout** — Chat always stays at fixed 380px width; remaining active panels share space equally
+- **Mobile** — disabled tabs are hidden from the tab bar
+- **API** — GET/PUT /admin/collab-features endpoints stored in app_settings
+
+---
+
+## Place Import: KMZ/KML & Naver Maps
+
+Two new ways to import places into your trips.
+
+### KMZ/KML Import
+- **Unified file import modal** — drag-and-drop or file picker for KML, KMZ, and GPX files
+- **KMZ unpacking** — extracts KML from ZIP archive with 50MB decompressed size limit
+- **Folder-to-category mapping** — KML folders are automatically matched to TREK categories
+- **Place deduplication** — skips places that already exist in the trip (by name + coordinates)
+
+### Naver Maps List Import
+- **Always enabled** — no longer requires addon toggle, available alongside Google Maps list import
+- **Shortlink resolution** — resolves naver.me shortlinks to full list URLs
+- **Pagination support** — handles large Naver Maps lists with automatic pagination
+
+---
+
+## Search Autocomplete
+
+- **Real-time suggestions** — autocomplete suggestions appear as you type in the place search field
+- **Google Places API** — primary autocomplete provider with location bias
+- **Nominatim fallback** — free fallback when Google API key is not configured
+- **Bounding box bias** — search results biased to the current map viewport
+
+---
+
+## ntfy Notification Channel
+
+- **ntfy as first-class channel** — push notifications via any ntfy server (self-hosted or ntfy.sh)
+- **Admin configuration** — server URL and topic configuration in admin panel with clear token button
+- **Per-user opt-in** — users can enable/disable ntfy in their notification preferences
+- **Full i18n** — ntfy strings translated in all 15 languages
+
+---
+
+## Login & Language
+
+- **Language dropdown on login page** — users can select their preferred language before logging in
+- **Browser auto-detection** — language is automatically detected from browser settings on first visit
+- **DEFAULT_LANGUAGE env var** — configurable default language for the instance, documented across all deployment configs (Docker, Helm, Synology)
+
+---
+
+## Granular Auth Toggles
+
+- **OIDC_ONLY replaced** — split into DISABLE_LOCAL_LOGIN, DISABLE_LOCAL_REGISTRATION, and DISABLE_PASSWORD_CHANGE for fine-grained control over authentication methods
+- Allows mixed setups (e.g., OIDC + local admin account, or OIDC-only with no local registration)
+
+---
+
+## Synology Photos: OTP, SSL Skip & Session Management
+
+- **OTP support** — one-time password field for 2FA-enabled Synology NAS
+- **Skip SSL verification** — toggle for self-signed certificates
+- **Device ID persistence** — prevents repeated 2FA prompts
+- **Session-cleared notification** — routed through unified notification system
+- **Provider URL hint** — contextual help text for Synology URL format
+
+---
+
+## Atlas Improvements
+
+- **Scoped region matching** — region name matching is now scoped by country to prevent cross-country false matches
+- **Expanded country lookup tables** — more countries and regions recognized correctly, including A3 fallback for invalid ISO_A2 codes
+- **Nominatim rate limiting** — shared throttle prevents 429 errors, background region fill, fetch timeout
+- **Stadia Maps fix** — resolved 401 errors on journey and atlas maps
+
+---
+
+## i18n: Full 15-Language Coverage
+
+- **Indonesian added** — complete translation with full parity to English, bringing the total to 15 languages (EN, DE, FR, ES, IT, NL, PL, RU, ZH, ZH-TW, BR, CS, HU, AR, ID)
+- **Comprehensive audit** — every key translated natively, no English fallbacks
+- **OAuth scope labels** — all 24 scopes have localized names and descriptions
+- **Journey addon** — complete coverage for all journal, editor, sharing, and PDF export strings
+- **Ellipsis standardization** — all ellipsis characters normalized to three dots (...)
+
+---
+
+## Vacay Improvements
+
+- **Trip indicator dots** — small blue dots on calendar days where trips are scheduled
+- **Configurable week start** — choose Monday or Sunday as first day of the week (#224)
+- **Holiday overlap** — vacations can now be placed on public holidays
+- **Today marker** — visual indicator for the current day in the calendar
+- **Bottom padding fix** — toolbar no longer overlaps the last row (#533)
+
+---
+
+## iCal Export Improvements
+
+- **Day activities and notes** — iCal export now includes daily activities and notes, not just the trip dates (#375)
+
+---
+
+## Budget Improvements
+
+- **Drag-and-drop reorder** — budget categories and individual items can be reordered via drag-and-drop (#479)
+- **Category legend redesign** — prevents overflow on small screens (#564)
+- **Comma decimal support** — pasting numbers with comma separators works correctly
+
+---
+
+## Planner & UX Improvements
+
+- **Collapsible day detail panel** — day detail panel can be collapsed/expanded in the planner
+- **Uncategorized filter** — "No Category" option in category dropdown to find places without a category (#607)
+- **Map multi-category filter** — filter syncs with map view for uncategorized places
+- **Unplanned filter sync** — unplanned filter properly syncs with map markers (#385)
+- **Place notes** — notes textarea in place edit form with proper display in inspector (#596)
+- **Place deduplication** — Google Maps list re-import skips existing places (#543)
+- **File download button** — all file views now include a download button
+- **Note modal** — no longer closes on outside click (#480)
+- **Google Maps links** — use place name + google_place_id for accurate links (#554)
+- **Packing list menu** — no longer cut off by overflow (#557)
+- **Trip date change** — preserving day content when date range changes
+- **PDF export** — render restaurant, event, tour, and other reservation types
+
+---
+
+## Admin Panel Improvements
+
+- **Collab sub-feature toggles** — individual toggles for Chat, Notes, Polls, What's Next
+- **Photo provider icons** — Immich and Synology Photos SVG brand icons in addon manager
+- **Bag tracking icon** — Luggage icon for the bag tracking sub-toggle
+- **Naver List Import** — now always enabled, removed from addon toggles
+
+---
+
+## Mobile Improvements
+
+- **Bottom nav fix** — prevent clipping of scrollable content and dialogs
+- **Journey mobile** — compact add-entry button, scrollable settings dialog, iOS PWA fixes
+- **Dashboard mobile** — spotlight trip in hero, smaller badges, check icon for completed
+- **Bottom nav dark mode** — consistent dark mode styling
+- **Safe area support** — proper insets for iOS PWA
+
+---
+
+## Test Coverage
+
+- **Backend** — expanded to ~87% coverage with comprehensive tests for OAuth, MCP tools, addon gating, services, and session management
+- **Frontend** — expanded to ~82% coverage with tests for dashboard, planner, settings, admin panels, and component interactions
+- **Journey** — 89.5% new code coverage
+- **CI** — client test job added alongside server tests with split coverage artifacts
+
+---
+
+## Bug Fixes
+
+- Fixed OIDC-only mode login/logout loop (#491)
+- Fixed dayplan duplicate reservation display, date off-by-one, and missing day_id on edit
+- Fixed booking date handling and file auth bugs
+- Fixed dayplan time-based auto-sort for places and free reorder for untimed
+- Fixed streaming response end on client disconnect during asset pipe
+- Fixed per-day transport positions for multi-day reservations
+- Fixed stale budget category reset when category no longer exists
+- Fixed trip redirect to plan tab when active tab addon is disabled
+- Fixed reservation price/budget field visibility when budget addon disabled
+- Fixed HEIC photo rendering on non-Safari browsers
+- Fixed CSP path matching for paths ending in /
+- Fixed avatar URLs in notifications, admin panel, and budget
+- Fixed budget member avatars lost after updating item fields
+- Fixed collab notes line break preservation (#608)
+- Fixed weather archive date handling for future trips (#599)
+- Fixed duplicate skeleton entries for multi-day places (#606)
+- Fixed ghost Gallery entries in journal timeline and public share
+- Fixed journey map OSM tile warning (#627)
+- Fixed content divider placement in journal entries (#624)
+- Fixed local photos wrong provider label (#625)
+- Fixed Synology pagination and album scroll leak (#644)
+- Fixed Stadia Maps 401 on journey and atlas maps (#640)
+- Fixed Nominatim User-Agent and error diagnostics
+- Fixed map tooltips, journey creation, and contributor avatars
+- Fixed notifications SMTP error surfacing, webhook button label, backup timestamp (#537)
+- Fixed stale accommodation_id on reservation update (#522)
+- Fixed hardcoded Immich in toast — now uses provider_name
+- Fixed MCP safeBroadcast recursive call bug
+- Fixed Vite module preload polyfill CSP inline script violation
+- Fixed PWA offline session redirect and file download auth (#505, #541)
+
+---
+
+## Security
+
+- **hono** 4.12.9 to 4.12.12 — fixes directory traversal (CVE-2026-39407, CVE-2026-39408), HTTP response splitting, improper input validation (CVE-2026-39410), and IP restriction bypass (CVE-2026-39409)
+- **@hono/node-server** 1.19.11 to 1.19.13 — fixes directory traversal (CVE-2026-39406)
+- **nodemailer** 8.0.4 to 8.0.5 — fixes CRLF injection
+- **OAuth 2.1 hardening** — token storage, PKCE enforcement, scope intersection validation
+- **Google Maps regex** — replaced too-permissive regex with safer utility function
+
+---
+
+## Infrastructure
+
+- **Prerelease workflow** — automated prerelease pipeline with major version support, version propagation, and race/orphan tag protection
+- **Helm chart** — moved to charts/trek/, published via helm-publisher action to gh-pages, appVersion used as default image tag
+- **Docker** — workflow improvements, tag management cleanup
+- **CI** — contributor workflow automation, npm audit removal from install steps, manual trigger for prerelease
+
+---
+
+## Contributors
+
+Thanks to everyone who contributed to this release:
+
+- @mauriceboe
+- @jubnl
+- @gravitysc
+- @luojiyin1987
+- @marco783
+- @isaiastavares
+- @tiquis0290
+- @xenocent
+- @gfrcsd
+
+---
+
+## Stats
+
+| Metric | Value |
+|--------|-------|
+| Commits | 280+ |
+| Merged PRs | 49 |
+| Files changed | 500+ |
+| Lines added | 108,000+ |
+| Contributors | 12 |
+
+---
+
+## Upgrading
+
+```bash
+docker pull mauriceboe/trek:3.0.0
+docker compose up -d
+```
+
+Migrations run automatically on startup. No manual steps required.
+
+**Checklist:**
+1. Update your Immich API key to include `asset.upload` (optional, only needed for Journey upload sync)
+2. If using `OIDC_ONLY`, migrate to `DISABLE_LOCAL_LOGIN` + `DISABLE_LOCAL_REGISTRATION`
+3. Enable the Journey addon in Settings > Addons to start using the travel journal
+
diff --git a/Dockerfile b/Dockerfile
index c16cc7b8..bd9de728 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -6,7 +6,18 @@ RUN npm ci
COPY client/ ./
RUN npm run build
-# Stage 2: Production server
+# Stage 2: Build server (TypeScript -> dist via tsc + tsc-alias)
+# --ignore-scripts: tsc only transpiles, so we skip native builds (better-sqlite3)
+# here; the production stage builds the native module.
+FROM node:24-alpine AS server-builder
+WORKDIR /app
+COPY server/package*.json ./
+RUN npm ci --ignore-scripts
+COPY server/ ./
+RUN npm run build
+
+# Stage 3: Production server (runs the compiled JS — NestJS DI needs the
+# decorator metadata that tsc emits; the old tsx runtime did not).
FROM node:24-alpine
WORKDIR /app
@@ -19,12 +30,11 @@ RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
apk del python3 make g++ && \
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
-COPY server/ ./
+COPY --from=server-builder /app/dist ./dist
COPY --from=client-builder /app/client/dist ./public
COPY --from=client-builder /app/client/public/fonts ./public/fonts
-RUN rm -f package-lock.json && \
- mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
+RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data && \
chown -R node:node /app
@@ -39,4 +49,4 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://localhost:3000/api/health || exit 1
ENTRYPOINT ["dumb-init", "--"]
-CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; exec su-exec node node --import tsx src/index.ts"]
+CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; exec su-exec node node dist/index.js"]
diff --git a/README.md b/README.md
index f3dfae0f..b1b68317 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
-
+
diff --git a/SECURITY.md b/SECURITY.md
index 218618a8..e2ce0414 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -14,7 +14,7 @@ Only the latest version receives security updates. Please update to the latest r
If you discover a security vulnerability, please report it responsibly:
1. **Do not** open a public issue
-2. Emails: **mauriceboe@icloud.com**, **trek-security@jubnl.ch**
+2. Email: **report@liketrek.com**
3. Include a description of the vulnerability and steps to reproduce
You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible.
diff --git a/charts/trek/Chart.yaml b/charts/trek/Chart.yaml
index ddad80e7..bb6721d3 100644
--- a/charts/trek/Chart.yaml
+++ b/charts/trek/Chart.yaml
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
-version: 3.0.18
+version: 3.0.22
description: Minimal Helm chart for TREK app
-appVersion: "3.0.18"
+appVersion: "3.0.22"
diff --git a/client/package-lock.json b/client/package-lock.json
index f829027e..a0f8b6e5 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -1,16 +1,17 @@
{
"name": "trek-client",
- "version": "3.0.18",
+ "version": "3.0.22",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trek-client",
- "version": "3.0.18",
+ "version": "3.0.22",
"dependencies": {
"@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7",
"dexie": "^4.4.2",
+ "heic-to": "^1.4.2",
"leaflet": "^1.9.4",
"lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0",
@@ -27,6 +28,7 @@
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"topojson-client": "^3.1.0",
+ "zod": "^4.3.6",
"zustand": "^4.5.2"
},
"devDependencies": {
@@ -2152,9 +2154,6 @@
"arm"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2172,9 +2171,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2192,9 +2188,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2210,9 +2203,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2230,9 +2220,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2250,9 +2237,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2270,9 +2254,6 @@
"arm"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2296,9 +2277,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2322,9 +2300,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2346,9 +2321,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2372,9 +2344,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2398,9 +2367,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3159,9 +3125,6 @@
"arm"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3176,9 +3139,6 @@
"arm"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3193,9 +3153,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3210,9 +3167,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3227,9 +3181,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3244,9 +3195,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3261,9 +3209,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3278,9 +3223,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3295,9 +3237,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3312,9 +3251,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3329,9 +3265,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3344,9 +3277,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3361,9 +3291,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -5827,6 +5754,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/heic-to": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/heic-to/-/heic-to-1.4.2.tgz",
+ "integrity": "sha512-y69thwxfNcEm2Vk8lbOD/cMabnvMJyOREfJYiCHcXCDqlfcPyJoBhyRc8+iDe1B95LRfpbTOpzxzY1xbRkdwBA==",
+ "license": "LGPL-3.0"
+ },
"node_modules/hsl-to-hex": {
"version": "1.0.0",
"license": "MIT",
@@ -11041,6 +10974,15 @@
"version": "3.2.1",
"license": "MIT"
},
+ "node_modules/zod": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
"node_modules/zustand": {
"version": "4.5.7",
"license": "MIT",
diff --git a/client/package.json b/client/package.json
index ab8585b7..0be4aaba 100644
--- a/client/package.json
+++ b/client/package.json
@@ -1,6 +1,6 @@
{
"name": "trek-client",
- "version": "3.0.18",
+ "version": "3.0.22",
"private": true,
"type": "module",
"scripts": {
@@ -18,6 +18,7 @@
"@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7",
"dexie": "^4.4.2",
+ "heic-to": "^1.4.2",
"leaflet": "^1.9.4",
"lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0",
@@ -34,6 +35,7 @@
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"topojson-client": "^3.1.0",
+ "zod": "^4.3.6",
"zustand": "^4.5.2"
},
"devDependencies": {
diff --git a/client/src/api/client.ts b/client/src/api/client.ts
index 837ed16b..106a7f36 100644
--- a/client/src/api/client.ts
+++ b/client/src/api/client.ts
@@ -1,4 +1,5 @@
import axios, { AxiosInstance } from 'axios'
+import type { WeatherResult } from '@trek/shared'
import { getSocketId } from './websocket'
import { isReachable, probeNow } from '../sync/connectivity'
import en from '../i18n/translations/en'
@@ -209,7 +210,7 @@ export const oauthApi = {
clients: {
list: () => apiClient.get('/oauth/clients').then(r => r.data),
- create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) =>
+ create: (data: { name: string; redirect_uris?: string[]; allowed_scopes: string[]; allows_client_credentials?: boolean }) =>
apiClient.post('/oauth/clients', data).then(r => r.data),
rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).then(r => r.data),
delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data),
@@ -407,8 +408,20 @@ export const journeyApi = {
reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds }).then(r => r.data),
// Photos
- uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
- uploadGalleryPhotos: (journeyId: number, formData: FormData) => apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
+ uploadPhotos: (entryId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
+ apiClient.post(`/journeys/entries/${entryId}/photos`, formData, {
+ headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
+ timeout: 0,
+ onUploadProgress: opts?.onUploadProgress,
+ signal: opts?.signal,
+ }).then(r => r.data),
+ uploadGalleryPhotos: (journeyId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
+ apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, {
+ headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
+ timeout: 0,
+ 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),
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),
@@ -489,8 +502,8 @@ export const reservationsApi = {
}
export const weatherApi = {
- get: (lat: number, lng: number, date: string) => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data),
- getDetailed: (lat: number, lng: number, date: string, lang?: string) => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
+ get: (lat: number, lng: number, date: string): Promise => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data),
+ getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
}
export const configApi = {
diff --git a/client/src/components/Journey/MobileEntryView.tsx b/client/src/components/Journey/MobileEntryView.tsx
index 766f8b7f..fc340f4a 100644
--- a/client/src/components/Journey/MobileEntryView.tsx
+++ b/client/src/components/Journey/MobileEntryView.tsx
@@ -52,7 +52,7 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
const dateStr = date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })
return (
-
+
{/* Top bar */}
>(new Map())
const locationMarkerRef = useRef(null)
const reservationOverlayRef = useRef(null)
+ const routeLabelMarkersRef = useRef([])
// Refs so the reservation overlay always sees the latest callback /
// options without forcing a full overlay rebuild on every prop change.
const onReservationClickRef = useRef(onReservationClick)
@@ -442,6 +444,35 @@ export function MapViewGL({
src.setData({ type: 'FeatureCollection', features })
}, [route])
+ // Travel-time pills between consecutive places. The GL map accepted the
+ // routeSegments prop but never drew anything, so the labels that Leaflet
+ // shows were missing here (#850). Render them as HTML markers, matching the
+ // Leaflet pill styling.
+ useEffect(() => {
+ const map = mapRef.current
+ if (!map || !mapReady) return
+ routeLabelMarkersRef.current.forEach(m => m.remove())
+ routeLabelMarkersRef.current = []
+ for (const seg of routeSegments) {
+ if (!seg.mid || (!seg.walkingText && !seg.drivingText)) continue
+ const el = document.createElement('div')
+ el.style.pointerEvents = 'none'
+ el.innerHTML = `
+
${seg.walkingText ?? ''}
+
|
+
${seg.drivingText ?? ''}
+
`
+ const m = new mapboxgl.Marker({ element: el, anchor: 'center' })
+ .setLngLat([seg.mid[1], seg.mid[0]])
+ .addTo(map)
+ routeLabelMarkersRef.current.push(m)
+ }
+ return () => {
+ routeLabelMarkersRef.current.forEach(m => m.remove())
+ routeLabelMarkersRef.current = []
+ }
+ }, [routeSegments, mapReady])
+
// Update GPX geometries
useEffect(() => {
const map = mapRef.current
diff --git a/client/src/components/Map/mapboxSetup.ts b/client/src/components/Map/mapboxSetup.ts
index b3fc9071..a77fa2d0 100644
--- a/client/src/components/Map/mapboxSetup.ts
+++ b/client/src/components/Map/mapboxSetup.ts
@@ -8,13 +8,15 @@ export function isStandardFamily(style: string): boolean {
return style === 'mapbox://styles/mapbox/standard' || style === 'mapbox://styles/mapbox/standard-satellite'
}
-// Terrain is only genuinely useful for the satellite imagery styles — on
-// clean flat styles like streets/light/dark it nudges route lines onto
-// the DEM while our HTML markers stay at Z=0, which causes the visible
-// offset when the map is pitched. Restrict terrain to satellite.
+// Terrain is only genuinely useful for styles that benefit from elevation
+// data. On flat vector styles (streets/light/dark) it nudges route lines
+// onto the DEM while HTML markers stay at Z=0, causing a visible drift
+// when the map is pitched. Satellite and Outdoors are the intended styles
+// for terrain; markers are re-pinned by syncMarkerAltitudes().
export function wantsTerrain(style: string): boolean {
return style === 'mapbox://styles/mapbox/satellite-v9'
|| style === 'mapbox://styles/mapbox/satellite-streets-v12'
+ || style === 'mapbox://styles/mapbox/outdoors-v12'
}
// 3D can be added to every style now — the standard family has it built-in
diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx
index e5700b93..ce5ae349 100644
--- a/client/src/components/PDF/TripPDF.tsx
+++ b/client/src/components/PDF/TripPDF.tsx
@@ -5,6 +5,7 @@ import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship
import { accommodationsApi, mapsApi } from '../../api/client'
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
+import { splitReservationDateTime } from '../../utils/formatters'
function renderLucideIcon(icon:LucideIcon, props = {}) {
if (!_renderToStaticMarkup) return ''
@@ -216,7 +217,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
const phase = pdfGetSpanPhase(r, day.id)
const spanLabel = pdfGetSpanLabel(r, phase)
const displayTime = pdfGetDisplayTime(r, day.id)
- const time = displayTime?.includes('T') ? displayTime.split('T')[1]?.substring(0, 5) : ''
+ const time = splitReservationDateTime(displayTime).time ?? ''
const titleHtml = `${spanLabel ? escHtml(spanLabel) + ': ' : ''}${escHtml(r.title)}`
return `
diff --git a/client/src/components/Packing/PackingListPanel.test.tsx b/client/src/components/Packing/PackingListPanel.test.tsx
index 2e1414ec..2dbbb01c 100644
--- a/client/src/components/Packing/PackingListPanel.test.tsx
+++ b/client/src/components/Packing/PackingListPanel.test.tsx
@@ -8,7 +8,21 @@ import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip, buildPackingItem } from '../../../tests/helpers/factories';
-import PackingListPanel from './PackingListPanel';
+import PackingListPanel, { itemWeight } from './PackingListPanel';
+
+describe('itemWeight (bag total weight calc)', () => {
+ it('FE-COMP-PACKING-030: multiplies unit weight by quantity', () => {
+ expect(itemWeight({ weight_grams: 120, quantity: 3 })).toBe(360);
+ });
+ it('FE-COMP-PACKING-031: defaults quantity to 1 when missing', () => {
+ expect(itemWeight({ weight_grams: 250 })).toBe(250);
+ });
+ it('FE-COMP-PACKING-032: contributes 0 when weight is missing or zero', () => {
+ expect(itemWeight({ quantity: 5 })).toBe(0);
+ expect(itemWeight({ weight_grams: 0, quantity: 5 })).toBe(0);
+ expect(itemWeight({})).toBe(0);
+ });
+});
beforeEach(() => {
resetAllStores();
diff --git a/client/src/components/Packing/PackingListPanel.tsx b/client/src/components/Packing/PackingListPanel.tsx
index 9311cbc1..267fd55a 100644
--- a/client/src/components/Packing/PackingListPanel.tsx
+++ b/client/src/components/Packing/PackingListPanel.tsx
@@ -69,6 +69,10 @@ function katColor(kat, allCategories) {
interface PackingBag { id: number; trip_id: number; name: string; color: string; weight_limit_grams: number | null; user_id?: number | null; assigned_username?: string | null }
+/** Weight an item contributes to a total: unit weight times quantity (defaults: 0 g, qty 1). */
+export const itemWeight = (i: { weight_grams?: number | null; quantity?: number | null }): number =>
+ (i.weight_grams || 0) * (i.quantity || 1)
+
// ── Bag Card ──────────────────────────────────────────────────────────────
interface BagCardProps {
@@ -1311,8 +1315,8 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
{bags.map(bag => {
const bagItems = items.filter(i => i.bag_id === bag.id)
- const totalWeight = bagItems.reduce((sum, i) => sum + (i.weight_grams || 0), 0)
- const maxWeight = bag.weight_limit_grams || Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + (i.weight_grams || 0), 0)), 1)
+ const totalWeight = bagItems.reduce((sum, i) => sum + itemWeight(i), 0)
+ const maxWeight = bag.weight_limit_grams || Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + itemWeight(i), 0)), 1)
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
return (
handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} compact />
@@ -1322,7 +1326,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
{/* Unassigned */}
{(() => {
const unassigned = items.filter(i => !i.bag_id)
- const unassignedWeight = unassigned.reduce((s, i) => s + (i.weight_grams || 0), 0)
+ const unassignedWeight = unassigned.reduce((s, i) => s + itemWeight(i), 0)
if (unassigned.length === 0) return null
return (
@@ -1342,7 +1346,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
{t('packing.totalWeight')}
- {(() => { const w = items.reduce((s, i) => s + (i.weight_grams || 0), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}
+ {(() => { const w = items.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}
@@ -1380,8 +1384,8 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
{bags.map(bag => {
const bagItems = items.filter(i => i.bag_id === bag.id)
- const totalWeight = bagItems.reduce((sum, i) => sum + (i.weight_grams || 0), 0)
- const maxWeight = Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + (i.weight_grams || 0), 0)), 1)
+ const totalWeight = bagItems.reduce((sum, i) => sum + itemWeight(i), 0)
+ const maxWeight = Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + itemWeight(i), 0)), 1)
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
return (
handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} />
@@ -1391,7 +1395,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
{/* Unassigned */}
{(() => {
const unassigned = items.filter(i => !i.bag_id)
- const unassignedWeight = unassigned.reduce((s, i) => s + (i.weight_grams || 0), 0)
+ const unassignedWeight = unassigned.reduce((s, i) => s + itemWeight(i), 0)
if (unassigned.length === 0) return null
return (
@@ -1411,7 +1415,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
{t('packing.totalWeight')}
- {(() => { const w = items.reduce((s, i) => s + (i.weight_grams || 0), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}
+ {(() => { const w = items.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}
diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx
index 407db408..48fe7e70 100644
--- a/client/src/components/Planner/DayDetailPanel.tsx
+++ b/client/src/components/Planner/DayDetailPanel.tsx
@@ -13,6 +13,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { getLocaleForLanguage, useTranslation } from '../../i18n'
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
import { isDayInAccommodationRange } from '../../utils/dayOrder'
+import { splitReservationDateTime } from '../../utils/formatters'
const WEATHER_ICON_MAP = {
Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle,
@@ -57,9 +58,10 @@ interface DayDetailPanelProps {
rightWidth?: number
collapsed?: boolean
onToggleCollapse?: () => void
+ mobile?: boolean
}
-export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse }: DayDetailPanelProps) {
+export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse, mobile = false }: DayDetailPanelProps) {
const { t, language, locale } = useTranslation()
const can = useCanDo()
const tripObj = useTripStore((s) => s.trip)
@@ -173,7 +175,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
return (
-
+
{
const dayAssignments = assignments[String(day.id)] || []
- const dayReservations = reservations.filter(r => dayAssignments.some(a => a.id === r.assignment_id))
+ const dayReservations = reservations.filter(r => {
+ if (r.type === 'hotel') return false
+ if (r.assignment_id && dayAssignments.some(a => a.id === r.assignment_id)) return true
+ return r.day_id === day.id
+ })
if (dayReservations.length === 0) return null
return (
@@ -305,12 +311,17 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
{r.title}
{linkedAssignment?.place && · {linkedAssignment.place.name} }
- {r.reservation_time?.includes('T') && (
-
- {new Date(r.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })}
- {r.reservation_end_time && ` – ${fmtTime(r.reservation_end_time)}`}
-
- )}
+ {(() => {
+ const { time: startTime } = splitReservationDateTime(r.reservation_time)
+ const { time: endTime } = splitReservationDateTime(r.reservation_end_time)
+ if (!startTime && !endTime) return null
+ return (
+
+ {startTime ? formatTime12(startTime, is12h) : ''}
+ {endTime ? ` – ${formatTime12(endTime, is12h)}` : ''}
+
+ )
+ })()}
)
})}
diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx
index 633e1866..7e2d9f2f 100644
--- a/client/src/components/Planner/DayPlanSidebar.tsx
+++ b/client/src/components/Planner/DayPlanSidebar.tsx
@@ -28,7 +28,7 @@ import {
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems,
type MergedItem,
} from '../../utils/dayMerge'
-import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
+import { formatDate, formatTime, dayTotalCost, currencyDecimals, splitReservationDateTime } from '../../utils/formatters'
import { useDayNotes } from '../../hooks/useDayNotes'
import Tooltip from '../shared/Tooltip'
import type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult } from '../../types'
@@ -1487,15 +1487,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
}}>
{(() => { const RI = RES_ICONS[res.type] || Ticket; return
})()}
{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}
- {res.reservation_time?.includes('T') && (
-
- {new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
- {res.reservation_end_time && ` – ${(() => {
- const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (res.reservation_time.split('T')[0] + 'T' + res.reservation_end_time)
- return new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
- })()}`}
-
- )}
+ {(() => {
+ const { time: st } = splitReservationDateTime(res.reservation_time)
+ const { time: et } = splitReservationDateTime(res.reservation_end_time)
+ if (!st && !et) return null
+ return (
+
+ {st ? formatTime(st, locale, timeFormat) : ''}
+ {et ? ` – ${formatTime(et, locale, timeFormat)}` : ''}
+
+ )
+ })()}
{(() => {
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
if (!meta) return null
@@ -1722,18 +1724,20 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{res.title}
- {displayTime?.includes('T') && (
-
-
- {new Date(displayTime).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
- {spanPhase === 'single' && res.reservation_end_time && (() => {
- const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (displayTime.split('T')[0] + 'T' + res.reservation_end_time)
- return ` – ${new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`
- })()}
- {meta.departure_timezone && spanPhase === 'start' && ` ${meta.departure_timezone}`}
- {meta.arrival_timezone && spanPhase === 'end' && ` ${meta.arrival_timezone}`}
-
- )}
+ {(() => {
+ const { time: dispTime } = splitReservationDateTime(displayTime)
+ const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
+ if (!dispTime && !endTime) return null
+ return (
+
+
+ {dispTime ? formatTime(dispTime, locale, timeFormat) : ''}
+ {spanPhase === 'single' && endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''}
+ {meta.departure_timezone && spanPhase === 'start' && ` ${meta.departure_timezone}`}
+ {meta.arrival_timezone && spanPhase === 'end' && ` ${meta.arrival_timezone}`}
+
+ )
+ })()}
{subtitle && (
@@ -1782,8 +1786,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }}
onDrop={e => {
e.preventDefault(); e.stopPropagation()
- const { noteId: fromNoteId, assignmentId: fromAssignmentId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
- if (fromReservationId && fromDayId !== day.id) {
+ const { placeId, noteId: fromNoteId, assignmentId: fromAssignmentId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
+ if (placeId) {
+ // New place dropped onto a note: insert it among the
+ // assignments at the note's position (after the places
+ // above it), so it lands right where the note sits.
+ const tm = getMergedItems(day.id)
+ const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
+ const pos = tm.slice(0, noteIdx).filter(i => i.type === 'place').length
+ onAssignToDay?.(parseInt(placeId), day.id, pos)
+ setDropTargetKey(null); window.__dragData = null
+ } else if (fromReservationId && fromDayId !== day.id) {
const r = reservations.find(x => x.id === Number(fromReservationId))
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
@@ -2094,13 +2107,19 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{res.title}
- {res.reservation_time?.includes('T')
- ? new Date(res.reservation_time).toLocaleString(locale, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
- : res.reservation_time
- ? new Date(res.reservation_time + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
+ {(() => {
+ const { date, time } = splitReservationDateTime(res.reservation_time)
+ const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
+ const dateStr = date
+ ? new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
: ''
- }
- {res.reservation_end_time?.includes('T') && ` – ${new Date(res.reservation_end_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`}
+ const timeStr = time ? formatTime(time, locale, timeFormat) : ''
+ const endStr = endTime ? formatTime(endTime, locale, timeFormat) : ''
+ const parts: string[] = []
+ if (dateStr) parts.push(dateStr)
+ if (timeStr) parts.push(timeStr + (endStr ? ` – ${endStr}` : ''))
+ return parts.join(', ')
+ })()}
c.id === place.category_id)
const dayAssignments = selectedDayId ? (assignments[String(selectedDayId)] || []) : []
- const assignmentInDay = selectedDayId ? dayAssignments.find(a => a.place?.id === place.id) : null
+ const assignmentInDay = selectedDayId
+ ? ((selectedAssignmentId ? dayAssignments.find(a => a.id === selectedAssignmentId) : null)
+ ?? dayAssignments.find(a => a.place?.id === place.id))
+ : null
const openingHours = googleDetails?.opening_hours || null
const openNow = googleDetails?.open_now ?? null
@@ -344,7 +348,7 @@ export default function PlaceInspector({
{/* Description / Summary */}
{(place.description || googleDetails?.summary) && (
- {place.description || googleDetails?.summary || ''}
+ {place.description || googleDetails?.summary || ''}
)}
@@ -378,21 +382,29 @@ export default function PlaceInspector({
{res.title}
- {res.reservation_time && (
-
-
{t('reservations.date')}
-
{new Date((res.reservation_time.includes('T') ? res.reservation_time.split('T')[0] : res.reservation_time) + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}
-
- )}
- {res.reservation_time?.includes('T') && (
-
-
{t('reservations.time')}
-
- {new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
- {res.reservation_end_time && ` – ${res.reservation_end_time}`}
-
-
- )}
+ {(() => {
+ const { date, time: startTime } = splitReservationDateTime(res.reservation_time)
+ const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
+ return (
+ <>
+ {date && (
+
+
{t('reservations.date')}
+
{new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}
+
+ )}
+ {(startTime || endTime) && (
+
+
{t('reservations.time')}
+
+ {startTime ? formatTime(startTime, locale, timeFormat) : ''}
+ {endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''}
+
+
+ )}
+ >
+ )
+ })()}
{res.confirmation_number && (
{t('reservations.confirmationCode')}
diff --git a/client/src/components/Planner/ReservationsPanel.test.tsx b/client/src/components/Planner/ReservationsPanel.test.tsx
index 2dcd9c86..0f9e01a6 100644
--- a/client/src/components/Planner/ReservationsPanel.test.tsx
+++ b/client/src/components/Planner/ReservationsPanel.test.tsx
@@ -389,4 +389,51 @@ describe('ReservationsPanel', () => {
expect(screen.getByText('Pending 2')).toBeInTheDocument();
expect(screen.getByText('Pending 3')).toBeInTheDocument();
});
+
+ it('FE-PLANNER-RESP-041: dateless transport with legacy T-prefix shows time without "Invalid Date"', () => {
+ const day = buildDay({ date: null, day_number: 25 } as any);
+ const r = buildReservation({
+ title: 'Cruise test',
+ type: 'cruise',
+ status: 'pending',
+ reservation_time: 'T10:00',
+ reservation_end_time: 'T18:00',
+ day_id: day.id,
+ end_day_id: day.id,
+ } as any);
+ render(
);
+ expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument();
+ expect(screen.getByText(/10:00/)).toBeInTheDocument();
+ });
+
+ it('FE-PLANNER-RESP-042: dateless transport with bare time format shows time without "Invalid Date"', () => {
+ const day = buildDay({ date: null, day_number: 3 } as any);
+ const r = buildReservation({
+ title: 'Car rental',
+ type: 'car',
+ status: 'pending',
+ reservation_time: '09:00',
+ reservation_end_time: '17:00',
+ day_id: day.id,
+ end_day_id: day.id,
+ } as any);
+ render(
);
+ expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument();
+ expect(screen.getByText(/09:00/)).toBeInTheDocument();
+ });
+
+ it('FE-PLANNER-RESP-043: dated transport still shows date and time correctly', () => {
+ const day = buildDay({ date: '2026-07-15', day_number: 1 });
+ const r = buildReservation({
+ title: 'Flight out',
+ type: 'flight',
+ status: 'confirmed',
+ reservation_time: '2026-07-15T08:30',
+ reservation_end_time: '2026-07-15T10:45',
+ day_id: day.id,
+ } as any);
+ render(
);
+ expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument();
+ expect(screen.getByText(/08:30/)).toBeInTheDocument();
+ });
});
diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx
index 7dc1a686..a341cc21 100644
--- a/client/src/components/Planner/ReservationsPanel.tsx
+++ b/client/src/components/Planner/ReservationsPanel.tsx
@@ -15,6 +15,7 @@ import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
+import { splitReservationDateTime, formatTime } from '../../utils/formatters'
interface AssignmentLookupEntry {
dayNumber: number
@@ -99,17 +100,13 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
}
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
- const fmtDate = (str) => {
- const dateOnly = str.includes('T') ? str.split('T')[0] : str
- return new Date(dateOnly + 'T00:00:00Z').toLocaleDateString(locale, { ...(isMobile ? {} : { weekday: 'short' }), day: 'numeric', month: 'short', timeZone: 'UTC' })
- }
- const fmtTime = (str) => {
- const d = new Date(str)
- return d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
- }
+ const startDt = splitReservationDateTime(r.reservation_time)
+ const endDt = splitReservationDateTime(r.reservation_end_time)
+ const fmtDate = (date: string) =>
+ new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { ...(isMobile ? {} : { weekday: 'short' }), day: 'numeric', month: 'short', timeZone: 'UTC' })
- const hasDate = !!r.reservation_time
- const hasTime = r.reservation_time?.includes('T')
+ const hasDate = !!startDt.date
+ const hasTime = !!(startDt.time || endDt.time)
const hasCode = !!r.confirmation_number
const dateCols = [hasDate, hasTime, hasCode].filter(Boolean).length
@@ -233,31 +230,25 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
)}
{/* Date / Time row */}
- {hasDate && (
-
-
-
{t('reservations.date')}
-
- {fmtDate(r.reservation_time)}
- {(() => {
- const endDatePart = r.reservation_end_time
- ? r.reservation_end_time.includes('T')
- ? r.reservation_end_time.split('T')[0]
- : /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_end_time)
- ? r.reservation_end_time
- : null
- : null
- return endDatePart && endDatePart !== r.reservation_time.split('T')[0]
- })() && (
- <> – {fmtDate(r.reservation_end_time)}>
- )}
+ {(hasDate || hasTime) && (
+
+ {hasDate && (
+
+
{t('reservations.date')}
+
+ {fmtDate(startDt.date!)}
+ {endDt.date && endDt.date !== startDt.date && (
+ <> – {fmtDate(endDt.date)}>
+ )}
+
-
+ )}
{hasTime && (
{t('reservations.time')}
- {fmtTime(r.reservation_time)}{r.reservation_end_time ? ` – ${r.reservation_end_time.includes('T') ? fmtTime(r.reservation_end_time) : fmtTime(r.reservation_time.split('T')[0] + 'T' + r.reservation_end_time)}` : ''}
+ {formatTime(startDt.time, locale, timeFormat)}
+ {endDt.time ? ` – ${formatTime(endDt.time, locale, timeFormat)}` : ''}
)}
@@ -316,8 +307,8 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform })
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat })
- if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: fmtTime('2000-01-01T' + meta.check_in_time) + (meta.check_in_end_time ? ` – ${fmtTime('2000-01-01T' + meta.check_in_end_time)}` : '') })
- if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: fmtTime('2000-01-01T' + meta.check_out_time) })
+ if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: formatTime(meta.check_in_time, locale, timeFormat) + (meta.check_in_end_time ? ` – ${formatTime(meta.check_in_end_time, locale, timeFormat)}` : '') })
+ if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: formatTime(meta.check_out_time, locale, timeFormat) })
if (cells.length === 0) return null
return (
1 ? `repeat(${Math.min(cells.length, 3)}, 1fr)` : '1fr' }}>
diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx
index 367a25cc..8b8ca7bf 100644
--- a/client/src/components/Planner/TransportModal.tsx
+++ b/client/src/components/Planner/TransportModal.tsx
@@ -10,7 +10,7 @@ import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore'
-import { formatDate } from '../../utils/formatters'
+import { formatDate, splitReservationDateTime } from '../../utils/formatters'
import { openFile } from '../../utils/fileDownload'
import apiClient from '../../api/client'
import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types'
@@ -141,8 +141,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
status: reservation.status || 'pending',
start_day_id: reservation.day_id ?? '',
end_day_id: reservation.end_day_id ?? '',
- departure_time: reservation.reservation_time?.split('T')[1]?.slice(0, 5) ?? '',
- arrival_time: reservation.reservation_end_time?.split('T')[1]?.slice(0, 5) ?? '',
+ departure_time: splitReservationDateTime(reservation.reservation_time).time ?? '',
+ arrival_time: splitReservationDateTime(reservation.reservation_end_time).time ?? '',
confirmation_number: reservation.confirmation_number || '',
notes: reservation.notes || '',
meta_airline: meta.airline || '',
@@ -179,7 +179,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
const buildTime = (day: Day | undefined, time: string): string | null => {
if (!time) return null
- return day?.date ? `${day.date}T${time}` : `T${time}`
+ return day?.date ? `${day.date}T${time}` : time
}
const metadata: Record
= {}
diff --git a/client/src/components/Settings/IntegrationsTab.tsx b/client/src/components/Settings/IntegrationsTab.tsx
index 430da0f6..6daf674f 100644
--- a/client/src/components/Settings/IntegrationsTab.tsx
+++ b/client/src/components/Settings/IntegrationsTab.tsx
@@ -69,6 +69,7 @@ interface OAuthClient {
client_id: string
redirect_uris: string[]
allowed_scopes: string[]
+ allows_client_credentials: boolean
created_at: string
client_secret?: string // only present on create
}
@@ -117,6 +118,7 @@ export default function IntegrationsTab(): React.ReactElement {
const [oauthRotating, setOauthRotating] = useState(false)
// oauthScopesOpen is managed internally by ScopeGroupPicker
const [oauthScopesExpanded, setOauthScopesExpanded] = useState>({})
+ const [oauthIsMachine, setOauthIsMachine] = useState(false)
// MCP sub-tab state
const [activeMcpTab, setActiveMcpTab] = useState<'oauth' | 'apitokens'>('oauth')
@@ -214,16 +216,23 @@ export default function IntegrationsTab(): React.ReactElement {
}, [mcpEnabled])
const handleCreateOAuthClient = async () => {
- if (!oauthNewName.trim() || !oauthNewUris.trim()) return
+ if (!oauthNewName.trim()) return
+ if (!oauthIsMachine && !oauthNewUris.trim()) return
setOauthCreating(true)
try {
- const uris = oauthNewUris.split('\n').map(u => u.trim()).filter(Boolean)
- const d = await oauthApi.clients.create({ name: oauthNewName.trim(), redirect_uris: uris, allowed_scopes: oauthNewScopes })
+ const uris = oauthIsMachine ? [] : oauthNewUris.split('\n').map(u => u.trim()).filter(Boolean)
+ const d = await oauthApi.clients.create({
+ name: oauthNewName.trim(),
+ redirect_uris: uris,
+ allowed_scopes: oauthNewScopes,
+ ...(oauthIsMachine ? { allows_client_credentials: true } : {}),
+ })
setOauthCreatedClient(d.client)
setOauthClients(prev => [...prev, { ...d.client, client_secret: undefined }])
setOauthNewName('')
setOauthNewUris('')
setOauthNewScopes([])
+ setOauthIsMachine(false)
} catch {
toast.error(t('settings.oauth.toast.createError'))
} finally {
@@ -342,7 +351,7 @@ export default function IntegrationsTab(): React.ReactElement {
{t('settings.oauth.clientsHint')}
-
{ setOauthCreateOpen(true); setOauthCreatedClient(null); setOauthNewName(''); setOauthNewUris(''); setOauthNewScopes([]) }}
+ { setOauthCreateOpen(true); setOauthCreatedClient(null); setOauthNewName(''); setOauthNewUris(''); setOauthNewScopes([]); setOauthIsMachine(false) }}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors bg-slate-900 text-white hover:bg-slate-700">
{t('settings.oauth.createClient')}
@@ -360,7 +369,15 @@ export default function IntegrationsTab(): React.ReactElement {
-
{client.name}
+
+
{client.name}
+ {client.allows_client_credentials && (
+
+ {t('settings.oauth.badge.machine')}
+
+ )}
+
{t('settings.oauth.clientId')}: {client.client_id}
{t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)}
@@ -616,15 +633,26 @@ export default function IntegrationsTab(): React.ReactElement {
autoFocus />
-
-
{t('settings.oauth.modal.redirectUris')}
-
+
+ setOauthIsMachine(e.target.checked)}
+ className="mt-0.5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />
+
+
{t('settings.oauth.modal.machineClient')}
+
{t('settings.oauth.modal.machineClientHint')}
+
+
+
+ {!oauthIsMachine && (
+
+
{t('settings.oauth.modal.redirectUris')}
+
+ )}
{t('settings.oauth.modal.scopes')}
@@ -638,7 +666,7 @@ export default function IntegrationsTab(): React.ReactElement {
{t('common.cancel')}
{oauthCreating ? t('settings.oauth.modal.creating') : t('settings.oauth.modal.create')}
@@ -681,6 +709,12 @@ export default function IntegrationsTab(): React.ReactElement {
+ {oauthCreatedClient?.allows_client_credentials && (
+
+ {t('settings.oauth.modal.machineClientUsage')}
+
+ )}
+
{ setOauthCreateOpen(false); setOauthCreatedClient(null) }}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700">
diff --git a/client/src/components/shared/PlaceAvatar.tsx b/client/src/components/shared/PlaceAvatar.tsx
index a98fa31d..cc55d85a 100644
--- a/client/src/components/shared/PlaceAvatar.tsx
+++ b/client/src/components/shared/PlaceAvatar.tsx
@@ -18,6 +18,7 @@ interface PlaceAvatarProps {
export default React.memo(function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
const [photoSrc, setPhotoSrc] = useState(place.image_url || null)
const [visible, setVisible] = useState(false)
+ const imageUrlFailed = useRef(false)
const ref = useRef(null)
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
@@ -86,7 +87,18 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
alt={place.name}
decoding="async"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
- onError={() => setPhotoSrc(null)}
+ onError={() => {
+ if (!imageUrlFailed.current && photoSrc === place.image_url && (place.google_place_id || place.osm_id)) {
+ imageUrlFailed.current = true
+ const photoId = place.google_place_id || place.osm_id!
+ const cacheKey = `refetch:${photoId}`
+ fetchPhoto(cacheKey, photoId, place.lat ?? undefined, place.lng ?? undefined, place.name,
+ entry => { setPhotoSrc(entry.thumbDataUrl || entry.photoUrl) }
+ )
+ } else {
+ setPhotoSrc(null)
+ }
+ }}
/>
)
diff --git a/client/src/i18n/TranslationContext.tsx b/client/src/i18n/TranslationContext.tsx
index e796bcce..adf7795d 100644
--- a/client/src/i18n/TranslationContext.tsx
+++ b/client/src/i18n/TranslationContext.tsx
@@ -16,6 +16,7 @@ import ar from './translations/ar'
import br from './translations/br'
import cs from './translations/cs'
import pl from './translations/pl'
+import ja from './translations/ja'
import { SUPPORTED_LANGUAGES, SupportedLanguageCode } from './supportedLanguages'
export { SUPPORTED_LANGUAGES }
@@ -24,7 +25,7 @@ type TranslationStrings = Record = {
- de, en, es, fr, hu, it, tr, ru, zh, 'zh-TW': zhTw, nl, id, ar, br, cs, pl,
+ de, en, es, fr, hu, it, tr, ru, zh, 'zh-TW': zhTw, nl, id, ar, br, cs, pl, ja,
}
// Derived from SUPPORTED_LANGUAGES — add new languages there, not here.
@@ -39,7 +40,7 @@ export function getLocaleForLanguage(language: string): string {
export function getIntlLanguage(language: string): string {
if (language === 'br') return 'pt-BR'
- return ['de', 'es', 'fr', 'hu', 'it', 'tr', 'ru', 'zh', 'zh-TW', 'nl', 'ar', 'cs', 'pl', 'id'].includes(language) ? language : 'en'
+ return ['de', 'es', 'fr', 'hu', 'it', 'tr', 'ru', 'zh', 'zh-TW', 'nl', 'ar', 'cs', 'pl', 'id', 'ja'].includes(language) ? language : 'en'
}
export function isRtlLanguage(language: string): boolean {
diff --git a/client/src/i18n/supportedLanguages.ts b/client/src/i18n/supportedLanguages.ts
index 59741fc4..bce41aba 100644
--- a/client/src/i18n/supportedLanguages.ts
+++ b/client/src/i18n/supportedLanguages.ts
@@ -15,6 +15,7 @@ export const SUPPORTED_LANGUAGES = [
{ value: 'tr', label: 'Türkçe', locale: 'tr-TR' },
{ value: 'ar', label: 'العربية', locale: 'ar-SA' },
{ value: 'id', label: 'Bahasa Indonesia', locale: 'id-ID' },
+ { value: 'ja', label: '日本語', locale: 'ja-JP' },
] as const
export type SupportedLanguageCode = typeof SUPPORTED_LANGUAGES[number]['value']
diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts
index 390b05c0..c9a54cec 100644
--- a/client/src/i18n/translations/ar.ts
+++ b/client/src/i18n/translations/ar.ts
@@ -330,6 +330,10 @@ const ar: Record = {
'settings.oauth.toast.revoked': 'تم إلغاء الجلسة',
'settings.oauth.toast.revokeError': 'فشل إلغاء الجلسة',
'settings.oauth.toast.rotateError': 'فشل تجديد سر العميل',
+ 'settings.oauth.modal.machineClient': 'عميل آلي (بدون تسجيل دخول عبر المتصفح)',
+ 'settings.oauth.modal.machineClientHint': 'استخدام منحة client_credentials — لا تحتاج إلى عناوين إعادة التوجيه. يُصدر الرمز المميز مباشرةً عبر client_id + client_secret ويعمل بصلاحياتك ضمن النطاقات المحددة.',
+ 'settings.oauth.modal.machineClientUsage': 'للحصول على رمز مميز: POST /oauth/token مع grant_type=client_credentials وclient_id وclient_secret. بدون متصفح، بدون رمز تحديث.',
+ 'settings.oauth.badge.machine': 'آلي',
'settings.account': 'الحساب',
'settings.about': 'حول',
'settings.about.reportBug': 'الإبلاغ عن خطأ',
@@ -1674,6 +1678,7 @@ const ar: Record = {
'journey.settings.failedToDelete': 'فشل في الحذف',
'journey.entries.deleteTitle': 'حذف الإدخال',
'journey.photosUploaded': 'تم رفع {count} صورة',
+ 'journey.photosUploadFailed': 'فشل رفع بعض الصور',
'journey.photosAdded': 'تمت إضافة {count} صورة',
'journey.picker.tripPeriod': 'فترة الرحلة',
'journey.picker.dateRange': 'نطاق التاريخ',
@@ -1705,8 +1710,11 @@ const ar: Record = {
// Journey Entry Editor
'journey.editor.discardChangesConfirm': 'لديك تغييرات غير محفوظة. هل تريد تجاهلها؟',
+ 'journey.editor.uploadFailed': 'فشل رفع الصور',
'journey.editor.uploadPhotos': 'رفع صور',
'journey.editor.uploading': '...جارٍ الرفع',
+ 'journey.editor.uploadingProgress': 'جارٍ الرفع {done}/{total}…',
+ 'journey.editor.uploadPartialFailed': 'فشل رفع {failed} من {total} — احفظ مجدداً للمحاولة',
'journey.editor.fromGallery': 'من المعرض',
'journey.editor.addAnother': 'إضافة آخر',
'journey.editor.makeFirst': 'جعله الأول',
diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts
index 0757c3d2..7fa534c1 100644
--- a/client/src/i18n/translations/br.ts
+++ b/client/src/i18n/translations/br.ts
@@ -402,6 +402,10 @@ const br: Record = {
'settings.oauth.toast.revoked': 'Sessão revogada',
'settings.oauth.toast.revokeError': 'Falha ao revogar sessão',
'settings.oauth.toast.rotateError': 'Falha ao renovar segredo do cliente',
+ 'settings.oauth.modal.machineClient': 'Cliente de máquina (sem login no navegador)',
+ 'settings.oauth.modal.machineClientHint': 'Usa o grant client_credentials — sem URIs de redirecionamento. O token é emitido diretamente via client_id + client_secret e age como você dentro dos escopos selecionados.',
+ 'settings.oauth.modal.machineClientUsage': 'Obter token: POST /oauth/token com grant_type=client_credentials, client_id e client_secret. Sem navegador, sem refresh token.',
+ 'settings.oauth.badge.machine': 'máquina',
'settings.mustChangePassword': 'Você deve alterar sua senha antes de continuar. Defina uma nova senha abaixo.',
// Login
@@ -2077,8 +2081,11 @@ const br: Record = {
'journey.synced.places': 'lugares',
'journey.synced.synced': 'sincronizado',
'journey.editor.discardChangesConfirm': 'Você tem alterações não salvas. Descartá-las?',
+ 'journey.editor.uploadFailed': 'Falha ao enviar fotos',
'journey.editor.uploadPhotos': 'Enviar fotos',
'journey.editor.uploading': 'Enviando...',
+ 'journey.editor.uploadingProgress': 'Enviando {done}/{total}…',
+ 'journey.editor.uploadPartialFailed': '{failed} de {total} fotos falharam — salve novamente para tentar',
'journey.editor.fromGallery': 'Da galeria',
'journey.editor.allPhotosAdded': 'Todas as fotos já foram adicionadas',
'journey.editor.writeStory': 'Escreva sua história...',
@@ -2169,6 +2176,7 @@ const br: Record = {
'journey.settings.failedToDelete': 'Falha ao excluir',
'journey.entries.deleteTitle': 'Excluir entrada',
'journey.photosUploaded': '{count} fotos enviadas',
+ 'journey.photosUploadFailed': 'Algumas fotos não foram enviadas',
'journey.photosAdded': '{count} fotos adicionadas',
'journey.public.notFound': 'Não encontrado',
'journey.public.notFoundMessage': 'Esta jornada não existe ou o link expirou.',
diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts
index a14b633d..92ea45b9 100644
--- a/client/src/i18n/translations/cs.ts
+++ b/client/src/i18n/translations/cs.ts
@@ -281,6 +281,10 @@ const cs: Record = {
'settings.oauth.toast.revoked': 'Relace odvolána',
'settings.oauth.toast.revokeError': 'Odvolání relace se nezdařilo',
'settings.oauth.toast.rotateError': 'Obnovení tajného klíče klienta se nezdařilo',
+ 'settings.oauth.modal.machineClient': 'Strojový klient (bez přihlášení v prohlížeči)',
+ 'settings.oauth.modal.machineClientHint': 'Používá grant client_credentials — bez URI pro přesměrování. Token je vydán přímo přes client_id + client_secret a funguje jako vy v rámci vybraných oborů.',
+ 'settings.oauth.modal.machineClientUsage': 'Získat token: POST /oauth/token s grant_type=client_credentials, client_id a client_secret. Bez prohlížeče, bez obnovovacího tokenu.',
+ 'settings.oauth.badge.machine': 'strojový',
'settings.account': 'Účet',
'settings.about': 'O aplikaci',
'settings.about.reportBug': 'Nahlásit chybu',
@@ -2082,8 +2086,11 @@ const cs: Record = {
'journey.synced.places': 'místa',
'journey.synced.synced': 'synchronizováno',
'journey.editor.discardChangesConfirm': 'Máte neuložené změny. Zahodit?',
+ 'journey.editor.uploadFailed': 'Nahrávání fotek selhalo',
'journey.editor.uploadPhotos': 'Nahrát fotky',
'journey.editor.uploading': 'Nahrávání...',
+ 'journey.editor.uploadingProgress': 'Nahrávání {done}/{total}…',
+ 'journey.editor.uploadPartialFailed': '{failed} z {total} fotek selhalo — uložte znovu pro opakování',
'journey.editor.fromGallery': 'Z galerie',
'journey.editor.allPhotosAdded': 'Všechny fotky již přidány',
'journey.editor.writeStory': 'Napište svůj příběh...',
@@ -2174,6 +2181,7 @@ const cs: Record = {
'journey.settings.failedToDelete': 'Smazání se nezdařilo',
'journey.entries.deleteTitle': 'Smazat záznam',
'journey.photosUploaded': '{count} fotografií nahráno',
+ 'journey.photosUploadFailed': 'Některé fotky se nepodařilo nahrát',
'journey.photosAdded': '{count} fotografií přidáno',
'journey.public.notFound': 'Nenalezeno',
'journey.public.notFoundMessage': 'Tento cestovní deník neexistuje nebo odkaz vypršel.',
diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts
index cbb6d153..a1399ed4 100644
--- a/client/src/i18n/translations/de.ts
+++ b/client/src/i18n/translations/de.ts
@@ -330,6 +330,10 @@ const de: Record = {
'settings.oauth.toast.revoked': 'Session widerrufen',
'settings.oauth.toast.revokeError': 'Session konnte nicht widerrufen werden',
'settings.oauth.toast.rotateError': 'Client-Secret konnte nicht erneuert werden',
+ 'settings.oauth.modal.machineClient': 'Maschineller Client (kein Browser-Login)',
+ 'settings.oauth.modal.machineClientHint': 'Verwendet den client_credentials Grant — keine Redirect-URIs erforderlich. Das Token wird direkt über client_id + client_secret ausgestellt und handelt in Ihrem Namen innerhalb der gewählten Scopes.',
+ 'settings.oauth.modal.machineClientUsage': 'Token abrufen: POST /oauth/token mit grant_type=client_credentials, client_id und client_secret. Kein Browser, kein Refresh-Token.',
+ 'settings.oauth.badge.machine': 'Maschine',
'settings.account': 'Konto',
'settings.about': 'Über',
'settings.about.reportBug': 'Bug melden',
@@ -2085,8 +2089,11 @@ const de: Record = {
'journey.synced.places': 'Orte',
'journey.synced.synced': 'synchronisiert',
'journey.editor.discardChangesConfirm': 'Du hast ungespeicherte Änderungen. Verwerfen?',
+ 'journey.editor.uploadFailed': 'Foto-Upload fehlgeschlagen',
'journey.editor.uploadPhotos': 'Fotos hochladen',
'journey.editor.uploading': 'Hochladen...',
+ 'journey.editor.uploadingProgress': 'Hochladen {done}/{total}…',
+ 'journey.editor.uploadPartialFailed': '{failed} von {total} Fotos fehlgeschlagen — erneut speichern zum Wiederholen',
'journey.editor.fromGallery': 'Aus Galerie',
'journey.editor.allPhotosAdded': 'Alle Fotos bereits hinzugefügt',
'journey.editor.writeStory': 'Erzähle deine Geschichte...',
@@ -2181,6 +2188,7 @@ const de: Record = {
'journey.settings.failedToDelete': 'Löschen fehlgeschlagen',
'journey.entries.deleteTitle': 'Eintrag löschen',
'journey.photosUploaded': '{count} Fotos hochgeladen',
+ 'journey.photosUploadFailed': 'Einige Fotos konnten nicht hochgeladen werden',
'journey.photosAdded': '{count} Fotos hinzugefügt',
'journey.public.notFound': 'Nicht gefunden',
'journey.public.notFoundMessage': 'Diese Journey existiert nicht oder der Link ist abgelaufen.',
diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts
index ce8321a6..a1c514f6 100644
--- a/client/src/i18n/translations/en.ts
+++ b/client/src/i18n/translations/en.ts
@@ -403,6 +403,10 @@ const en: Record = {
'settings.oauth.toast.revoked': 'Session revoked',
'settings.oauth.toast.revokeError': 'Failed to revoke session',
'settings.oauth.toast.rotateError': 'Failed to rotate client secret',
+ 'settings.oauth.modal.machineClient': 'Machine client (no browser login)',
+ 'settings.oauth.modal.machineClientHint': 'Use client_credentials grant — no redirect URIs needed. The token is issued directly via client_id + client_secret and acts as you within the selected scopes.',
+ 'settings.oauth.modal.machineClientUsage': 'Get a token: POST /oauth/token with grant_type=client_credentials, client_id, and client_secret. No browser, no refresh token.',
+ 'settings.oauth.badge.machine': 'machine',
'settings.account': 'Account',
'settings.about': 'About',
'settings.about.reportBug': 'Report a Bug',
@@ -2111,8 +2115,11 @@ const en: Record = {
// Journey Entry Editor
'journey.editor.discardChangesConfirm': 'You have unsaved changes. Discard them?',
+ 'journey.editor.uploadFailed': 'Photo upload failed',
'journey.editor.uploadPhotos': 'Upload photos',
'journey.editor.uploading': 'Uploading...',
+ 'journey.editor.uploadingProgress': 'Uploading {done}/{total}…',
+ 'journey.editor.uploadPartialFailed': '{failed} of {total} photos failed — save again to retry',
'journey.editor.fromGallery': 'From Gallery',
'journey.editor.allPhotosAdded': 'All photos already added',
'journey.editor.writeStory': 'Write your story...',
@@ -2219,6 +2226,7 @@ const en: Record = {
'journey.settings.failedToDelete': 'Failed to delete',
'journey.entries.deleteTitle': 'Delete Entry',
'journey.photosUploaded': '{count} photos uploaded',
+ 'journey.photosUploadFailed': 'Some photos failed to upload',
'journey.photosAdded': '{count} photos added',
// Journey — Public Page
diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts
index a66bdfb6..b1865a73 100644
--- a/client/src/i18n/translations/es.ts
+++ b/client/src/i18n/translations/es.ts
@@ -326,6 +326,10 @@ const es: Record = {
'settings.oauth.toast.revoked': 'Sesión revocada',
'settings.oauth.toast.revokeError': 'Error al revocar la sesión',
'settings.oauth.toast.rotateError': 'Error al renovar el secreto del cliente',
+ 'settings.oauth.modal.machineClient': 'Cliente de máquina (sin inicio de sesión en el navegador)',
+ 'settings.oauth.modal.machineClientHint': 'Usa el grant client_credentials — sin URIs de redirección. El token se emite directamente vía client_id + client_secret y actúa como tú dentro de los alcances seleccionados.',
+ 'settings.oauth.modal.machineClientUsage': 'Obtener token: POST /oauth/token con grant_type=client_credentials, client_id y client_secret. Sin navegador, sin token de actualización.',
+ 'settings.oauth.badge.machine': 'máquina',
'settings.account': 'Cuenta',
'settings.about': 'Acerca de',
'settings.about.reportBug': 'Reportar un error',
@@ -2084,8 +2088,11 @@ const es: Record = {
'journey.synced.places': 'lugares',
'journey.synced.synced': 'sincronizado',
'journey.editor.discardChangesConfirm': 'Tienes cambios sin guardar. ¿Descartarlos?',
+ 'journey.editor.uploadFailed': 'Error al subir fotos',
'journey.editor.uploadPhotos': 'Subir fotos',
'journey.editor.uploading': 'Subiendo...',
+ 'journey.editor.uploadingProgress': 'Subiendo {done}/{total}…',
+ 'journey.editor.uploadPartialFailed': '{failed} de {total} fotos fallaron — guarda de nuevo para reintentar',
'journey.editor.fromGallery': 'Desde galería',
'journey.editor.allPhotosAdded': 'Todas las fotos ya fueron añadidas',
'journey.editor.writeStory': 'Escribe tu historia...',
@@ -2176,6 +2183,7 @@ const es: Record = {
'journey.settings.failedToDelete': 'Error al eliminar',
'journey.entries.deleteTitle': 'Eliminar entrada',
'journey.photosUploaded': '{count} fotos subidas',
+ 'journey.photosUploadFailed': 'Algunas fotos no se pudieron subir',
'journey.photosAdded': '{count} fotos añadidas',
'journey.public.notFound': 'No encontrado',
'journey.public.notFoundMessage': 'Esta travesía no existe o el enlace ha expirado.',
diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts
index c7cd1605..8337a67c 100644
--- a/client/src/i18n/translations/fr.ts
+++ b/client/src/i18n/translations/fr.ts
@@ -325,6 +325,10 @@ const fr: Record = {
'settings.oauth.toast.revoked': 'Session révoquée',
'settings.oauth.toast.revokeError': 'Impossible de révoquer la session',
'settings.oauth.toast.rotateError': 'Impossible de renouveler le secret client',
+ 'settings.oauth.modal.machineClient': 'Client machine (sans connexion navigateur)',
+ 'settings.oauth.modal.machineClientHint': 'Utilise le grant client_credentials — aucune URI de redirection requise. Le token est émis directement via client_id + client_secret et agit en votre nom dans les portées sélectionnées.',
+ 'settings.oauth.modal.machineClientUsage': 'Obtenir un token : POST /oauth/token avec grant_type=client_credentials, client_id et client_secret. Sans navigateur, sans token de rafraîchissement.',
+ 'settings.oauth.badge.machine': 'machine',
'settings.account': 'Compte',
'settings.about': 'À propos',
'settings.about.reportBug': 'Signaler un bug',
@@ -2078,8 +2082,11 @@ const fr: Record = {
'journey.synced.places': 'lieux',
'journey.synced.synced': 'synchronisé',
'journey.editor.discardChangesConfirm': 'Vous avez des modifications non enregistrées. Les ignorer ?',
+ 'journey.editor.uploadFailed': 'Échec du téléversement des photos',
'journey.editor.uploadPhotos': 'Téléverser des photos',
'journey.editor.uploading': 'Envoi...',
+ 'journey.editor.uploadingProgress': 'Téléversement {done}/{total}…',
+ 'journey.editor.uploadPartialFailed': '{failed} sur {total} photos ont échoué — sauvegardez à nouveau pour réessayer',
'journey.editor.fromGallery': 'Depuis la galerie',
'journey.editor.allPhotosAdded': 'Toutes les photos ont déjà été ajoutées',
'journey.editor.writeStory': 'Écrivez votre histoire...',
@@ -2170,6 +2177,7 @@ const fr: Record = {
'journey.settings.failedToDelete': 'Échec de la suppression',
'journey.entries.deleteTitle': "Supprimer l'entrée",
'journey.photosUploaded': '{count} photos téléversées',
+ 'journey.photosUploadFailed': "Certaines photos n'ont pas pu être téléversées",
'journey.photosAdded': '{count} photos ajoutées',
'journey.public.notFound': 'Introuvable',
'journey.public.notFoundMessage': 'Ce journal n\'existe pas ou le lien a expiré.',
diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts
index f8046fab..18450c69 100644
--- a/client/src/i18n/translations/hu.ts
+++ b/client/src/i18n/translations/hu.ts
@@ -280,6 +280,10 @@ const hu: Record = {
'settings.oauth.toast.revoked': 'Munkamenet visszavonva',
'settings.oauth.toast.revokeError': 'A munkamenet visszavonása sikertelen',
'settings.oauth.toast.rotateError': 'A kliens titok megújítása sikertelen',
+ 'settings.oauth.modal.machineClient': 'Gépi kliens (böngészős bejelentkezés nélkül)',
+ 'settings.oauth.modal.machineClientHint': 'client_credentials grant használata — nincs szükség átirányítási URI-kra. A token közvetlenül client_id + client_secret segítségével kerül kiállításra, és a kiválasztott hatókörökön belül az Ön nevében jár el.',
+ 'settings.oauth.modal.machineClientUsage': 'Token lekérése: POST /oauth/token a grant_type=client_credentials, client_id és client_secret értékekkel. Böngésző és frissítési token nélkül.',
+ 'settings.oauth.badge.machine': 'gépi',
'settings.account': 'Fiók',
'settings.about': 'Névjegy',
'settings.about.reportBug': 'Hiba bejelentése',
@@ -2079,8 +2083,11 @@ const hu: Record = {
'journey.synced.places': 'helyszín',
'journey.synced.synced': 'szinkronizálva',
'journey.editor.discardChangesConfirm': 'Mentetlen módosításaid vannak. Elveted?',
+ 'journey.editor.uploadFailed': 'A fotók feltöltése sikertelen',
'journey.editor.uploadPhotos': 'Fotók feltöltése',
'journey.editor.uploading': 'Feltöltés...',
+ 'journey.editor.uploadingProgress': 'Feltöltés {done}/{total}…',
+ 'journey.editor.uploadPartialFailed': '{failed} / {total} fotó sikertelen — mentsd el újra a próbálkozáshoz',
'journey.editor.fromGallery': 'Galériából',
'journey.editor.allPhotosAdded': 'Minden fotó már hozzáadva',
'journey.editor.writeStory': 'Írd meg a történeted...',
@@ -2171,6 +2178,7 @@ const hu: Record = {
'journey.settings.failedToDelete': 'Törlés sikertelen',
'journey.entries.deleteTitle': 'Bejegyzés törlése',
'journey.photosUploaded': '{count} fotó feltöltve',
+ 'journey.photosUploadFailed': 'Néhány fotót nem sikerült feltölteni',
'journey.photosAdded': '{count} fotó hozzáadva',
'journey.public.notFound': 'Nem található',
'journey.public.notFoundMessage': 'Ez az útinapló nem létezik vagy a link lejárt.',
diff --git a/client/src/i18n/translations/id.ts b/client/src/i18n/translations/id.ts
index 112d17fc..283c4e88 100644
--- a/client/src/i18n/translations/id.ts
+++ b/client/src/i18n/translations/id.ts
@@ -387,6 +387,10 @@ const id: Record = {
'settings.oauth.toast.revoked': 'Sesi dicabut',
'settings.oauth.toast.revokeError': 'Gagal mencabut sesi',
'settings.oauth.toast.rotateError': 'Gagal memutar ulang client secret',
+ 'settings.oauth.modal.machineClient': 'Klien mesin (tanpa login browser)',
+ 'settings.oauth.modal.machineClientHint': 'Menggunakan grant client_credentials — tidak perlu URI pengalihan. Token diterbitkan langsung melalui client_id + client_secret dan bertindak sebagai Anda dalam cakupan yang dipilih.',
+ 'settings.oauth.modal.machineClientUsage': 'Dapatkan token: POST /oauth/token dengan grant_type=client_credentials, client_id, dan client_secret. Tanpa browser, tanpa refresh token.',
+ 'settings.oauth.badge.machine': 'mesin',
'settings.account': 'Akun',
'settings.about': 'Tentang',
'settings.about.reportBug': 'Laporkan Bug',
@@ -2094,8 +2098,11 @@ const id: Record = {
// Journey Entry Editor
'journey.editor.discardChangesConfirm': 'Anda memiliki perubahan yang belum disimpan. Buang?',
+ 'journey.editor.uploadFailed': 'Gagal mengunggah foto',
'journey.editor.uploadPhotos': 'Unggah foto',
'journey.editor.uploading': 'Mengunggah...',
+ 'journey.editor.uploadingProgress': 'Mengunggah {done}/{total}…',
+ 'journey.editor.uploadPartialFailed': '{failed} dari {total} foto gagal — simpan lagi untuk mencoba ulang',
'journey.editor.fromGallery': 'Dari Galeri',
'journey.editor.allPhotosAdded': 'Semua foto sudah ditambahkan',
'journey.editor.writeStory': 'Tulis kisahmu...',
@@ -2198,6 +2205,7 @@ const id: Record = {
'journey.settings.failedToDelete': 'Gagal menghapus',
'journey.entries.deleteTitle': 'Hapus Entri',
'journey.photosUploaded': '{count} foto diunggah',
+ 'journey.photosUploadFailed': 'Beberapa foto gagal diunggah',
'journey.photosAdded': '{count} foto ditambahkan',
// Journey — Public Page
diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts
index 2ac5424f..3e6aeae7 100644
--- a/client/src/i18n/translations/it.ts
+++ b/client/src/i18n/translations/it.ts
@@ -280,6 +280,10 @@ const it: Record = {
'settings.oauth.toast.revoked': 'Sessione revocata',
'settings.oauth.toast.revokeError': 'Impossibile revocare la sessione',
'settings.oauth.toast.rotateError': 'Impossibile rinnovare il segreto client',
+ 'settings.oauth.modal.machineClient': 'Client macchina (senza login nel browser)',
+ 'settings.oauth.modal.machineClientHint': 'Usa il grant client_credentials — nessun URI di reindirizzamento necessario. Il token viene emesso direttamente tramite client_id + client_secret e agisce come te negli ambiti selezionati.',
+ 'settings.oauth.modal.machineClientUsage': 'Ottieni token: POST /oauth/token con grant_type=client_credentials, client_id e client_secret. Senza browser, senza token di aggiornamento.',
+ 'settings.oauth.badge.machine': 'macchina',
'settings.account': 'Account',
'settings.about': 'Informazioni',
'settings.about.reportBug': 'Segnala un bug',
@@ -2079,8 +2083,11 @@ const it: Record = {
'journey.synced.places': 'luoghi',
'journey.synced.synced': 'sincronizzato',
'journey.editor.discardChangesConfirm': 'Hai modifiche non salvate. Vuoi scartarle?',
+ 'journey.editor.uploadFailed': 'Caricamento foto non riuscito',
'journey.editor.uploadPhotos': 'Carica foto',
'journey.editor.uploading': 'Caricamento...',
+ 'journey.editor.uploadingProgress': 'Caricamento {done}/{total}…',
+ 'journey.editor.uploadPartialFailed': '{failed} di {total} foto non riuscite — salva di nuovo per riprovare',
'journey.editor.fromGallery': 'Dalla galleria',
'journey.editor.allPhotosAdded': 'Tutte le foto sono già state aggiunte',
'journey.editor.writeStory': 'Scrivi la tua storia...',
@@ -2171,6 +2178,7 @@ const it: Record = {
'journey.settings.failedToDelete': 'Eliminazione non riuscita',
'journey.entries.deleteTitle': 'Elimina voce',
'journey.photosUploaded': '{count} foto caricate',
+ 'journey.photosUploadFailed': 'Alcune foto non sono state caricate',
'journey.photosAdded': '{count} foto aggiunte',
'journey.public.notFound': 'Non trovato',
'journey.public.notFoundMessage': 'Questo diario non esiste o il link è scaduto.',
diff --git a/client/src/i18n/translations/ja.ts b/client/src/i18n/translations/ja.ts
new file mode 100644
index 00000000..51eb31b4
--- /dev/null
+++ b/client/src/i18n/translations/ja.ts
@@ -0,0 +1,2432 @@
+const ja: Record = {
+ // Common
+ 'common.save': '保存',
+ 'common.showMore': 'もっと見る',
+ 'common.showLess': '閉じる',
+ 'common.cancel': 'キャンセル',
+ 'common.clear': 'クリア',
+ 'common.delete': '削除',
+ 'common.edit': '編集',
+ 'common.add': '追加',
+ 'common.loading': '読み込み中…',
+ 'common.import': 'インポート',
+ 'common.select': '選択',
+ 'common.selectAll': 'すべて選択',
+ 'common.deselectAll': 'すべて解除',
+ 'common.error': 'エラー',
+ 'common.unknownError': '不明なエラー',
+ 'common.tooManyAttempts': '試行回数が多すぎます。時間をおいて再度お試しください。',
+ 'common.back': '戻る',
+ 'common.all': 'すべて',
+ 'common.close': '閉じる',
+ 'common.open': '開く',
+ 'common.upload': 'アップロード',
+ 'common.search': '検索',
+ 'common.confirm': '確認',
+ 'common.ok': 'OK',
+ 'common.yes': 'はい',
+ 'common.no': 'いいえ',
+ 'common.or': 'または',
+ 'common.none': 'なし',
+ 'common.date': '日付',
+ 'common.rename': '名前を変更',
+ 'common.discardChanges': '変更をキャンセル',
+ 'common.discard': 'キャンセル',
+ 'common.name': '名前',
+ 'common.email': 'メールアドレス',
+ 'common.password': 'パスワード',
+ 'common.saving': '保存中…',
+ 'common.justNow': 'たった今',
+ 'common.hoursAgo': '{count}時間前',
+ 'common.daysAgo': '{count}日前',
+ 'common.saved': '保存しました',
+ 'trips.memberRemoved': '{username} を削除しました',
+ 'trips.memberRemoveError': '削除に失敗しました',
+ 'trips.memberAdded': '{username} を追加しました',
+ 'trips.memberAddError': '追加に失敗しました',
+ 'trips.reminder': 'リマインダー',
+ 'trips.reminderNone': 'なし',
+ 'trips.reminderDay': '日',
+ 'trips.reminderDays': '日',
+ 'trips.reminderCustom': 'カスタム',
+ 'trips.reminderDaysBefore': '出発前',
+ 'trips.reminderDisabledHint': '旅行のリマインダーは無効です。管理 > 設定 > 通知から有効にしてください。',
+ 'common.update': '更新',
+ 'common.change': '変更',
+ 'common.uploading': 'アップロード中…',
+ 'common.backToPlanning': 'プランに戻る',
+ 'common.reset': 'リセット',
+ 'common.expand': '展開',
+ 'common.collapse': '折りたたむ',
+
+ // Navbar
+ 'nav.trip': '旅行',
+ 'nav.share': '共有',
+ 'nav.settings': '設定',
+ 'nav.admin': '管理',
+ 'nav.logout': 'ログアウト',
+ 'nav.lightMode': 'ライトモード',
+ 'nav.darkMode': 'ダークモード',
+ 'nav.autoMode': '自動',
+ 'nav.administrator': '管理者',
+
+// Dashboard
+ 'dashboard.title': 'マイ旅行',
+ 'dashboard.subtitle.loading': '旅行を読み込み中...',
+ 'dashboard.subtitle.trips': '{count}件の旅行({archived}件アーカイブ)',
+ 'dashboard.subtitle.empty': '最初の旅行を始めましょう',
+ 'dashboard.subtitle.activeOne': '進行中の旅行 {count}件',
+ 'dashboard.subtitle.activeMany': '進行中の旅行 {count}件',
+ 'dashboard.subtitle.archivedSuffix': ' · アーカイブ {count}件',
+ 'dashboard.newTrip': '新しい旅行',
+ 'dashboard.gridView': 'グリッド表示',
+ 'dashboard.listView': 'リスト表示',
+ 'dashboard.currency': '通貨',
+ 'dashboard.timezone': 'タイムゾーン',
+ 'dashboard.localTime': '現地',
+ 'dashboard.timezoneCustomTitle': 'カスタムタイムゾーン',
+ 'dashboard.timezoneCustomLabelPlaceholder': 'ラベル(任意)',
+ 'dashboard.timezoneCustomTzPlaceholder': '例:America/New_York',
+ 'dashboard.timezoneCustomAdd': '追加',
+ 'dashboard.timezoneCustomErrorEmpty': 'タイムゾーンIDを入力してください',
+ 'dashboard.timezoneCustomErrorInvalid': '無効なタイムゾーンです(例:Europe/Berlin)',
+ 'dashboard.timezoneCustomErrorDuplicate': 'すでに追加されています',
+ 'dashboard.emptyTitle': '旅行はまだありません',
+ 'dashboard.emptyText': '最初の旅行を作成して計画を始めましょう!',
+ 'dashboard.emptyButton': '最初の旅行を作成',
+ 'dashboard.nextTrip': '次の旅行',
+ 'dashboard.shared': '共有済み',
+ 'dashboard.sharedBy': '{name}さんが共有',
+ 'dashboard.days': '日数',
+ 'dashboard.places': '場所',
+ 'dashboard.members': '同行者',
+ 'dashboard.archive': 'アーカイブ',
+ 'dashboard.copyTrip': 'コピー',
+ 'dashboard.copySuffix': 'コピー',
+ 'dashboard.restore': '復元',
+ 'dashboard.archived': 'アーカイブ済み',
+ 'dashboard.status.ongoing': '進行中',
+ 'dashboard.status.today': '今日',
+ 'dashboard.status.tomorrow': '明日',
+ 'dashboard.status.past': '過去',
+ 'dashboard.status.daysLeft': '残り{count}日',
+ 'dashboard.toast.loadError': '旅行の読み込みに失敗しました',
+ 'dashboard.toast.created': '旅行を作成しました!',
+ 'dashboard.toast.createError': '旅行の作成に失敗しました',
+ 'dashboard.toast.updated': '旅行を更新しました!',
+ 'dashboard.toast.updateError': '旅行の更新に失敗しました',
+ 'dashboard.toast.deleted': '旅行を削除しました',
+ 'dashboard.toast.deleteError': '旅行の削除に失敗しました',
+ 'dashboard.toast.archived': '旅行をアーカイブしました',
+ 'dashboard.toast.archiveError': '旅行のアーカイブに失敗しました',
+ 'dashboard.toast.restored': '旅行を復元しました',
+ 'dashboard.toast.restoreError': '旅行の復元に失敗しました',
+ 'dashboard.toast.copied': '旅行をコピーしました!',
+ 'dashboard.toast.copyError': '旅行のコピーに失敗しました',
+ 'dashboard.confirm.delete': '旅行「{title}」を削除しますか?すべての場所と計画は完全に削除されます。',
+ 'dashboard.confirm.copy.title': 'この旅行をコピーしますか?',
+ 'dashboard.confirm.copy.willCopy': 'コピーされる内容',
+ 'dashboard.confirm.copy.will1': '日数、訪問先 & 日ごとの割り当て',
+ 'dashboard.confirm.copy.will2': '宿泊施設 & 予約',
+ 'dashboard.confirm.copy.will3': '予算項目 & カテゴリの順序',
+ 'dashboard.confirm.copy.will4': '持ち物リスト(未チェックのみ)',
+ 'dashboard.confirm.copy.will5': 'TODO(未割り当て & 未チェック)',
+ 'dashboard.confirm.copy.will6': '日ごとのメモ',
+ 'dashboard.confirm.copy.wontCopy': 'コピーされない内容',
+ 'dashboard.confirm.copy.wont1': '共同編集者 & メンバー割り当て',
+ 'dashboard.confirm.copy.wont2': '共同ノート、投票 & メッセージ',
+ 'dashboard.confirm.copy.wont3': 'ファイル & 写真',
+ 'dashboard.confirm.copy.wont4': '共有トークン',
+ 'dashboard.confirm.copy.confirm': '旅行をコピー',
+ 'dashboard.editTrip': '旅行を編集',
+ 'dashboard.createTrip': '新しい旅行を作成',
+ 'dashboard.tripTitle': 'タイトル',
+ 'dashboard.tripTitlePlaceholder': '例:日本の夏',
+ 'dashboard.tripDescription': '説明',
+ 'dashboard.tripDescriptionPlaceholder': 'この旅行について',
+ 'dashboard.startDate': '開始日',
+ 'dashboard.endDate': '終了日',
+ 'dashboard.dayCount': '日数',
+ 'dashboard.dayCountHint': '日付が未設定の場合に作成する日数です。',
+ 'dashboard.noDateHint': '日付未設定 — 既定で7日分が作成されます。後で変更できます。',
+ 'dashboard.coverImage': 'カバー画像',
+ 'dashboard.addCoverImage': 'カバー画像を追加(またはドラッグ&ドロップ)',
+ 'dashboard.addMembers': '同行者',
+ 'dashboard.addMember': 'メンバーを追加',
+ 'dashboard.coverSaved': 'カバー画像を保存しました',
+ 'dashboard.coverUploadError': 'アップロードに失敗しました',
+ 'dashboard.coverRemoveError': '削除に失敗しました',
+ 'dashboard.titleRequired': 'タイトルは必須です',
+ 'dashboard.endDateError': '終了日は開始日より後にしてください',
+
+ // Settings
+ 'settings.title': '設定',
+ 'settings.subtitle': '個人設定を管理',
+ 'settings.tabs.display': '表示',
+ 'settings.tabs.map': '地図',
+ 'settings.tabs.notifications': '通知',
+ 'settings.tabs.integrations': '連携',
+ 'settings.tabs.account': 'アカウント',
+ 'settings.tabs.offline': 'オフライン',
+ 'settings.tabs.about': '情報',
+ 'settings.map': '地図',
+ 'settings.mapTemplate': '地図テンプレート',
+ 'settings.mapTemplatePlaceholder.select': 'テンプレートを選択…',
+ 'settings.mapDefaultHint': '空欄の場合は OpenStreetMap(既定)を使用',
+ 'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
+ 'settings.mapHint': '地図タイルのURLテンプレート',
+ 'settings.mapProvider': '地図プロバイダー',
+ 'settings.mapProviderHint': '旅程プランナーと日記地図に影響します。Atlas は常に Leaflet を使用します。',
+ 'settings.mapLeafletSubtitle': 'クラシックな2D、任意のラスタータイル',
+ 'settings.mapMapboxSubtitle': 'ベクタータイル、3D建物・地形',
+ 'settings.mapExperimental': '実験的',
+ 'settings.mapMapboxToken': 'Mapbox アクセストークン',
+ 'settings.mapMapboxTokenHint': 'mapbox.com の公開トークン(pk.*)',
+ 'settings.mapMapboxTokenLink': 'mapbox.com → Access tokens',
+ 'settings.mapStyle': '地図スタイル',
+ 'settings.mapStylePlaceholder': 'Mapboxスタイルを選択',
+ 'settings.mapStyleHint': 'プリセットまたは mapbox://styles/USER/ID のURL',
+ 'settings.map3dBuildings': '3D建物・地形',
+ 'settings.map3dHint': 'ピッチ+実際の3D押し出し表示。衛星含む全スタイルで動作。',
+ 'settings.mapHighQuality': '高品質モード',
+ 'settings.mapHighQualityHint': 'アンチエイリアス+地球投影で、より鮮明でリアルに表示。',
+ 'settings.mapHighQualityWarning': '低性能デバイスではパフォーマンスに影響する場合があります。',
+ 'settings.mapTipLabel': 'ヒント:',
+ 'settings.mapTip': '右クリック+ドラッグで回転/傾き。中クリックで場所を追加(右クリックは回転用)。',
+ 'settings.latitude': '緯度',
+ 'settings.longitude': '経度',
+ 'settings.saveMap': '地図を保存',
+ 'settings.apiKeys': 'APIキー',
+ 'settings.mapsKey': 'Google Maps APIキー',
+ 'settings.mapsKeyHint': '場所検索用。Places API(新)が必要。console.cloud.google.com で取得',
+ 'settings.weatherKey': 'OpenWeatherMap APIキー',
+ 'settings.weatherKeyHint': '天気情報用。openweathermap.org/api で無料取得',
+ 'settings.keyPlaceholder': 'キーを入力…',
+ 'settings.configured': '設定済み',
+ 'settings.saveKeys': 'キーを保存',
+ 'settings.display': '表示',
+ 'settings.colorMode': 'カラーモード',
+ 'settings.light': 'ライト',
+ 'settings.dark': 'ダーク',
+ 'settings.auto': '自動',
+ 'settings.language': '言語',
+ 'settings.temperature': '温度単位',
+ 'settings.timeFormat': '時刻形式',
+ 'settings.routeCalculation': '経路計算',
+ 'settings.bookingLabels': '予約ルートのラベル',
+ 'settings.bookingLabelsHint': '地図に駅・空港名を表示。オフ時はアイコンのみ。',
+ 'settings.blurBookingCodes': '予約コードをぼかす',
+ 'settings.notifications': '通知',
+ 'settings.notifyTripInvite': '旅行の招待',
+ 'settings.notifyBookingChange': '予約の変更',
+ 'settings.notifyTripReminder': '旅行リマインダー',
+ 'settings.notifyTodoDue': 'ToDoの期限',
+ 'settings.notifyVacayInvite': 'Vacay fusion の招待',
+ 'settings.notifyPhotosShared': '共有写真(Immich)',
+ 'settings.notifyCollabMessage': 'チャットメッセージ(Collab)',
+ 'settings.notifyPackingTagged': '持ち物リスト:割り当て',
+ 'settings.notifyWebhook': 'Webhook通知',
+ 'settings.notifyVersionAvailable': '新しいバージョン',
+ 'settings.notificationPreferences.email': 'メール',
+ 'settings.notificationPreferences.webhook': 'Webhook',
+ 'settings.notificationPreferences.inapp': 'アプリ内',
+ 'settings.notificationPreferences.ntfy': 'Ntfy',
+ 'settings.notificationPreferences.noChannels': '通知チャネルが未設定です。管理者に設定を依頼してください。',
+ 'settings.webhookUrl.label': 'Webhook URL',
+ 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
+ 'settings.webhookUrl.hint': 'Discord、Slack、または独自のWebhook URLを入力してください。',
+ 'settings.webhookUrl.saved': 'Webhook URLを保存しました',
+ 'settings.webhookUrl.test': 'テスト',
+ 'settings.webhookUrl.testSuccess': 'テストWebhookを送信しました',
+ 'settings.webhookUrl.testFailed': 'テストWebhookに失敗しました',
+ 'settings.ntfyUrl.topicLabel': 'Ntfy トピック',
+ 'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
+ 'settings.ntfyUrl.serverLabel': 'Ntfy サーバーURL(任意)',
+ 'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
+ 'settings.ntfyUrl.hint': 'ntfyトピックを入力してください。サーバー未入力時は管理者設定の既定値を使用します。',
+ 'settings.ntfyUrl.tokenLabel': 'アクセストークン(任意)',
+ 'settings.ntfyUrl.tokenHint': 'パスワード保護トピックに必要です。',
+ 'settings.ntfyUrl.saved': 'Ntfy設定を保存しました',
+ 'settings.ntfyUrl.test': 'テスト',
+ 'settings.ntfyUrl.testSuccess': 'テスト通知を送信しました',
+ 'settings.ntfyUrl.testFailed': 'テスト通知に失敗しました',
+ 'settings.ntfyUrl.tokenCleared': 'アクセストークンを削除しました',
+ 'admin.notifications.title': '通知',
+ 'admin.notifications.hint': '通知チャネルを1つ選択してください。同時に有効にできるのは1つだけです。',
+ 'admin.notifications.none': '無効',
+ 'admin.notifications.email': 'メール(SMTP)',
+ 'admin.notifications.webhook': 'Webhook',
+ 'admin.notifications.ntfy': 'Ntfy',
+ 'admin.ntfy.hint': 'ユーザーが独自のntfyトピックを設定できるようにします。下で既定サーバーを設定してください。',
+ 'admin.notifications.save': '通知設定を保存',
+ 'admin.notifications.saved': '通知設定を保存しました',
+ 'admin.notifications.testWebhook': 'Webhookテスト送信',
+ 'admin.notifications.testWebhookSuccess': 'テストWebhookを送信しました',
+ 'admin.notifications.testWebhookFailed': 'テストWebhookに失敗しました',
+ 'admin.notifications.testNtfy': 'ntfyテスト送信',
+ 'admin.notifications.testNtfySuccess': 'テストntfyを送信しました',
+ 'admin.notifications.testNtfyFailed': 'テストntfyに失敗しました',
+ 'admin.notifications.emailPanel.title': 'メール(SMTP)',
+ 'admin.notifications.webhookPanel.title': 'Webhook',
+ 'admin.notifications.inappPanel.title': 'アプリ内',
+ 'admin.notifications.inappPanel.hint': 'アプリ内通知は常に有効で、全体では無効にできません。',
+ 'admin.notifications.adminWebhookPanel.title': '管理者Webhook',
+ 'admin.notifications.adminWebhookPanel.hint': '管理者通知専用のWebhookです(例:バージョン通知)。常に送信されます。',
+ 'admin.notifications.adminWebhookPanel.saved': '管理者Webhook URLを保存しました',
+ 'admin.notifications.adminWebhookPanel.testSuccess': 'テストWebhookを送信しました',
+ 'admin.notifications.adminWebhookPanel.testFailed': 'テストWebhookに失敗しました',
+ 'admin.notifications.adminWebhookPanel.alwaysOnHint': 'URLが設定されていると常に送信されます',
+ 'admin.notifications.adminNtfyPanel.title': '管理者Ntfy',
+ 'admin.notifications.adminNtfyPanel.hint': '管理者通知専用のntfyトピックです。',
+ 'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy サーバーURL',
+ 'admin.notifications.adminNtfyPanel.serverHint': 'ユーザー通知の既定サーバーとしても使用されます。',
+ 'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
+ 'admin.notifications.adminNtfyPanel.topicLabel': '管理者トピック',
+ 'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
+ 'admin.notifications.adminNtfyPanel.tokenLabel': 'アクセストークン(任意)',
+ 'admin.notifications.adminNtfyPanel.tokenCleared': '管理者アクセストークンを削除しました',
+ 'admin.notifications.adminNtfyPanel.saved': '管理者ntfy設定を保存しました',
+ 'admin.notifications.adminNtfyPanel.test': 'ntfyテスト送信',
+ 'admin.notifications.adminNtfyPanel.testSuccess': 'テストntfyを送信しました',
+ 'admin.notifications.adminNtfyPanel.testFailed': 'テストntfyに失敗しました',
+ 'admin.notifications.adminNtfyPanel.alwaysOnHint': 'トピック設定時は常に送信されます',
+ 'admin.notifications.adminNotificationsHint': '管理者専用通知の配信先を設定します。',
+ 'admin.notifications.tripReminders.title': '旅行リマインダー',
+ 'admin.notifications.tripReminders.hint': '旅行開始前に通知を送信します(旅行側の設定が必要)。',
+ 'admin.notifications.tripReminders.enabled': '旅行リマインダー有効',
+ 'admin.notifications.tripReminders.disabled': '旅行リマインダー無効',
+ 'admin.smtp.title': 'メール・通知',
+ 'admin.smtp.hint': 'メール通知送信用のSMTP設定。',
+ 'admin.smtp.testButton': 'テストメール送信',
+ 'admin.webhook.hint': 'ユーザーが独自のWebhook URLを設定できるようにします。',
+ 'admin.smtp.testSuccess': 'テストメールを送信しました',
+ 'admin.smtp.testFailed': 'テストメールに失敗しました',
+ 'settings.notificationsDisabled': '通知が未設定です。管理者に有効化を依頼してください。',
+ 'settings.notificationsActive': '有効なチャネル',
+ 'settings.notificationsManagedByAdmin': '通知イベントは管理者が設定します。',
+ 'dayplan.icsTooltip': 'カレンダーを書き出し(ICS)',
+ 'share.linkTitle': '公開リンク',
+ 'share.linkHint': 'ログイン不要で閲覧できるリンクを作成します(閲覧のみ)。',
+ 'share.createLink': 'リンク作成',
+ 'share.deleteLink': 'リンク削除',
+ 'share.createError': 'リンクを作成できませんでした',
+ 'common.copy': 'コピー',
+ 'common.copied': 'コピーしました',
+ 'share.permMap': '地図・プラン',
+ 'share.permBookings': '予約',
+ 'share.permPacking': '持ち物',
+ 'shared.expired': 'リンクが無効',
+ 'shared.expiredHint': 'この共有リンクは無効です。',
+ 'shared.readOnly': '読み取り専用',
+ 'shared.tabPlan': 'プラン',
+ 'shared.tabBookings': '予約',
+ 'shared.tabPacking': '持ち物',
+ 'shared.tabBudget': '予算',
+ 'shared.tabChat': 'チャット',
+ 'shared.days': '日',
+ 'shared.places': '場所',
+ 'shared.other': 'その他',
+ 'shared.totalBudget': '合計予算',
+ 'shared.messages': 'メッセージ',
+ 'shared.sharedVia': '共有元',
+ 'shared.confirmed': '確定',
+ 'shared.pending': '保留',
+ 'share.permBudget': '予算',
+ 'share.permCollab': 'チャット',
+ 'settings.on': 'オン',
+ 'settings.off': 'オフ',
+ 'settings.mcp.title': 'MCP設定',
+ 'settings.mcp.endpoint': 'MCPエンドポイント',
+ 'settings.mcp.clientConfig': 'クライアント設定',
+ 'settings.mcp.clientConfigHint': ' を下のAPIトークンに置き換えてください。',
+ 'settings.mcp.clientConfigHintOAuth': ' と をOAuth 2.1の認証情報に置き換えてください。',
+ 'settings.mcp.copy': 'コピー',
+ 'settings.mcp.copied': 'コピーしました!',
+ 'settings.mcp.apiTokens': 'APIトークン',
+ 'settings.mcp.createToken': '新しいトークン',
+ 'settings.mcp.noTokens': 'トークンがありません。作成してください。',
+ 'settings.mcp.tokenCreatedAt': '作成日',
+ 'settings.mcp.tokenUsedAt': '最終使用',
+ 'settings.mcp.deleteTokenTitle': 'トークン削除',
+ 'settings.mcp.deleteTokenMessage': 'このトークンは即時無効になります。',
+ 'settings.mcp.modal.createTitle': 'APIトークン作成',
+ 'settings.mcp.modal.tokenName': 'トークン名',
+ 'settings.mcp.modal.tokenNamePlaceholder': '例:Claude Desktop',
+ 'settings.mcp.modal.creating': '作成中…',
+ 'settings.mcp.modal.create': '作成',
+ 'settings.mcp.modal.createdTitle': 'トークン作成完了',
+ 'settings.mcp.modal.createdWarning': '表示は一度きりです。今すぐ保存してください。',
+ 'settings.mcp.modal.done': '完了',
+ 'settings.mcp.toast.created': 'トークンを作成しました',
+ 'settings.mcp.toast.createError': 'トークン作成に失敗しました',
+ 'settings.mcp.toast.deleted': 'トークンを削除しました',
+ 'settings.mcp.toast.deleteError': 'トークン削除に失敗しました',
+ 'settings.mcp.apiTokensDeprecated': 'APIトークンは非推奨です。OAuth 2.1 クライアントを使用してください。',
+ 'settings.oauth.clients': 'OAuth 2.1 クライアント',
+ 'settings.oauth.clientsHint': '第三者アプリが接続できるよう登録します。',
+ 'settings.oauth.createClient': '新規クライアント',
+ 'settings.oauth.noClients': '登録されたクライアントはありません。',
+ 'settings.oauth.clientId': 'クライアントID',
+ 'settings.oauth.clientSecret': 'クライアントシークレット',
+ 'settings.oauth.deleteClient': 'クライアント削除',
+ 'settings.oauth.deleteClientMessage': 'このクライアントは完全に削除されます。',
+ 'settings.oauth.rotateSecret': 'シークレット更新',
+ 'settings.oauth.rotateSecretMessage': '新しいシークレットを生成します。',
+ 'settings.oauth.rotateSecretConfirm': '更新',
+ 'settings.oauth.rotateSecretConfirming': '更新中…',
+ 'settings.oauth.rotateSecretDoneTitle': '新しいシークレット',
+ 'settings.oauth.rotateSecretDoneWarning': '表示は一度きりです。今すぐ保存してください。',
+ 'settings.oauth.activeSessions': '有効なセッション',
+ 'settings.oauth.sessionScopes': 'スコープ',
+ 'settings.oauth.sessionExpires': '有効期限',
+ 'settings.oauth.revoke': '取り消し',
+ 'settings.oauth.revokeSession': 'セッション取り消し',
+ 'settings.oauth.revokeSessionMessage': 'このセッションのアクセスを即時無効にします。',
+ 'settings.oauth.modal.createTitle': 'OAuthクライアント登録',
+ 'settings.oauth.modal.presets': '簡単設定',
+ 'settings.oauth.modal.clientName': 'アプリ名',
+ 'settings.oauth.modal.clientNamePlaceholder': '例:Claude Web',
+ 'settings.oauth.modal.redirectUris': 'リダイレクトURI',
+ 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback',
+ 'settings.oauth.modal.redirectUrisHint': '1行につき1つ。HTTPS必須。',
+ 'settings.oauth.modal.scopes': '許可スコープ',
+ 'settings.oauth.modal.scopesHint': 'list_trips と get_trip_summary は常に利用可能です。',
+ 'settings.oauth.modal.selectAll': 'すべて選択',
+ 'settings.oauth.modal.deselectAll': 'すべて解除',
+ 'settings.oauth.modal.creating': '登録中…',
+ 'settings.oauth.modal.create': '登録',
+ 'settings.oauth.modal.createdTitle': '登録完了',
+ 'settings.oauth.modal.createdWarning': 'シークレットは一度しか表示されません。',
+ 'settings.oauth.toast.createError': '登録に失敗しました',
+ 'settings.oauth.toast.deleted': 'クライアントを削除しました',
+ 'settings.oauth.toast.deleteError': '削除に失敗しました',
+ 'settings.oauth.toast.revoked': 'セッションを取り消しました',
+ 'settings.oauth.toast.revokeError': '取り消しに失敗しました',
+ 'settings.oauth.toast.rotateError': '更新に失敗しました',
+ 'settings.account': 'アカウント',
+ 'settings.about': '情報',
+ 'settings.about.reportBug': '不具合報告',
+ 'settings.about.reportBugHint': '問題を見つけたらお知らせください',
+ 'settings.about.featureRequest': '機能リクエスト',
+ 'settings.about.featureRequestHint': '新機能を提案',
+ 'settings.about.wikiHint': 'ドキュメント・ガイド',
+ 'settings.about.supporters.badge': '月額サポーター',
+ 'settings.about.supporters.title': 'TREKの旅仲間',
+ 'settings.about.supporters.subtitle': '皆さんの支援がTREKの未来を支えています。',
+ 'settings.about.supporters.since': '{date}からサポート',
+ 'settings.about.supporters.tierEmpty': '最初の一人に',
+ 'settings.about.supporter.tier.noReturnTicket': '片道切符',
+ 'settings.about.supporter.tier.lostLuggageVip': 'ロストラゲージVIP',
+ 'settings.about.supporter.tier.businessClassDreamer': 'ビジネスクラスの夢',
+ 'settings.about.supporter.tier.budgetTraveller': '節約トラベラー',
+ 'settings.about.supporter.tier.hostelBunkmate': 'ホステル仲間',
+ 'settings.about.description': 'TREKはセルフホスト型の旅行プランナーです。',
+ 'settings.about.madeWith': 'Made with',
+ 'settings.about.madeBy': 'by Maurice とオープンソースコミュニティ。',
+ 'settings.username': 'ユーザー名',
+ 'settings.email': 'メール',
+ 'settings.role': '役割',
+ 'settings.roleAdmin': '管理者',
+ 'settings.oidcLinked': '連携先',
+ 'settings.changePassword': 'パスワード変更',
+ 'settings.currentPassword': '現在のパスワード',
+ 'settings.currentPasswordRequired': '現在のパスワードが必要です',
+ 'settings.newPassword': '新しいパスワード',
+ 'settings.confirmPassword': '新しいパスワード(確認)',
+ 'settings.updatePassword': 'パスワード更新',
+ 'settings.passwordRequired': '現在と新しいパスワードを入力してください',
+ 'settings.passwordTooShort': '8文字以上必要です',
+ 'settings.passwordMismatch': 'パスワードが一致しません',
+ 'settings.passwordWeak': '大文字・小文字・数字・記号を含めてください',
+ 'settings.passwordChanged': 'パスワードを変更しました',
+ 'settings.mustChangePassword': '続行するにはパスワード変更が必要です。',
+ 'settings.deleteAccount': 'アカウント削除',
+ 'settings.deleteAccountTitle': 'アカウントを削除しますか?',
+ 'settings.deleteAccountWarning': 'すべてのデータが完全に削除されます。',
+ 'settings.deleteAccountConfirm': '完全に削除',
+ 'settings.deleteBlockedTitle': '削除できません',
+ 'settings.deleteBlockedMessage': '唯一の管理者です。別のユーザーを管理者にしてください。',
+ 'settings.roleUser': 'ユーザー',
+ 'settings.saveProfile': 'プロフィールを保存',
+ 'settings.toast.mapSaved': '地図設定を保存しました',
+ 'settings.toast.keysSaved': 'APIキーを保存しました',
+ 'settings.toast.displaySaved': '表示設定を保存しました',
+ 'settings.toast.profileSaved': 'プロフィールを保存しました',
+ 'settings.uploadAvatar': 'プロフィール画像をアップロード',
+ 'settings.removeAvatar': 'プロフィール画像を削除',
+ 'settings.avatarUploaded': 'プロフィール画像を更新しました',
+ 'settings.avatarRemoved': 'プロフィール画像を削除しました',
+ 'settings.avatarError': 'アップロードに失敗しました',
+ 'settings.mfa.title': '二要素認証(2FA)',
+ 'settings.mfa.description': 'サインイン時に追加の認証を行います。',
+ 'settings.mfa.requiredByPolicy': '管理者により2FAが必須です。',
+ 'settings.mfa.backupTitle': 'バックアップコード',
+ 'settings.mfa.backupDescription': '認証アプリが使えない場合に使用します。',
+ 'settings.mfa.backupWarning': '今すぐ保存してください。各コードは1回限りです。',
+ 'settings.mfa.backupCopy': 'コードをコピー',
+ 'settings.mfa.backupDownload': 'TXTでダウンロード',
+ 'settings.mfa.backupPrint': '印刷 / PDF',
+ 'settings.mfa.backupCopied': 'バックアップコードをコピーしました',
+ 'settings.mfa.enabled': '2FAは有効です。',
+ 'settings.mfa.disabled': '2FAは無効です。',
+ 'settings.mfa.setup': '認証アプリを設定',
+ 'settings.mfa.scanQr': 'QRコードをスキャンするか、手動で入力してください。',
+ 'settings.mfa.secretLabel': 'シークレットキー(手動入力)',
+ 'settings.mfa.codePlaceholder': '6桁コード',
+ 'settings.mfa.enable': '2FAを有効化',
+ 'settings.mfa.cancelSetup': 'キャンセル',
+ 'settings.mfa.disableTitle': '2FAを無効化',
+ 'settings.mfa.disableHint': 'パスワードと現在のコードを入力してください。',
+ 'settings.mfa.disable': '2FAを無効化',
+ 'settings.mfa.toastEnabled': '2FAを有効にしました',
+ 'settings.mfa.toastDisabled': '2FAを無効にしました',
+ 'settings.mfa.demoBlocked': 'デモモードでは利用できません',
+
+ // Login
+ 'login.error': 'ログインに失敗しました。認証情報を確認してください。',
+ 'login.tagline': 'あなたの旅行。\nあなたの計画。',
+ 'login.description': 'インタラクティブなマップ、予算、リアルタイム同期で、みんなで旅行を計画。',
+ 'login.features.maps': 'インタラクティブマップ',
+ 'login.features.mapsDesc': 'Google Places、経路、クラスタリング',
+ 'login.features.realtime': 'リアルタイム同期',
+ 'login.features.realtimeDesc': 'WebSocketで共同計画',
+ 'login.features.budget': '予算管理',
+ 'login.features.budgetDesc': 'カテゴリ、グラフ、人数別費用',
+ 'login.features.collab': 'コラボレーション',
+ 'login.features.collabDesc': '複数ユーザーで旅行を共有',
+ 'login.features.packing': '持ち物リスト',
+ 'login.features.packingDesc': 'カテゴリ、進捗、提案',
+ 'login.features.bookings': '予約',
+ 'login.features.bookingsDesc': '航空券、ホテル、レストランなど',
+ 'login.features.files': 'ドキュメント',
+ 'login.features.filesDesc': '書類のアップロードと管理',
+ 'login.features.routes': 'スマート経路',
+ 'login.features.routesDesc': '自動最適化&Google Maps書き出し',
+ 'login.selfHosted': 'セルフホスト · オープンソース · データはあなたのもの',
+ 'login.title': 'サインイン',
+ 'login.subtitle': 'おかえりなさい',
+ 'login.signingIn': 'サインイン中…',
+ 'login.signIn': 'サインイン',
+ 'login.createAdmin': '管理者アカウントを作成',
+ 'login.createAdminHint': 'TREKの最初の管理者アカウントを設定します。',
+ 'login.setNewPassword': '新しいパスワードを設定',
+ 'login.setNewPasswordHint': '続行する前にパスワードを変更してください。',
+ 'login.createAccount': 'アカウントを作成',
+ 'login.createAccountHint': '新しいアカウントを登録。',
+ 'login.creating': '作成中…',
+ 'login.noAccount': 'アカウントをお持ちでないですか?',
+ 'login.hasAccount': 'すでにアカウントをお持ちですか?',
+ 'login.register': '登録',
+ 'login.emailPlaceholder': 'your@email.com',
+ 'login.username': 'ユーザー名',
+ 'login.oidc.registrationDisabled': '登録は無効です。管理者に連絡してください。',
+ 'login.oidc.noEmail': 'プロバイダーからメールが取得できませんでした。',
+ 'login.oidc.tokenFailed': '認証に失敗しました。',
+ 'login.oidc.invalidState': 'セッションが無効です。もう一度お試しください。',
+ 'login.demoFailed': 'デモログインに失敗しました',
+ 'login.oidcSignIn': '{name}でサインイン',
+ 'login.oidcOnly': 'パスワード認証は無効です。SSOプロバイダーでサインインしてください。',
+ 'login.oidcLoggedOut': 'ログアウトしました。SSOプロバイダーで再度サインインしてください。',
+ 'login.demoHint': 'デモを試す — 登録不要',
+ 'login.mfaTitle': '二要素認証',
+ 'login.mfaSubtitle': '認証アプリの6桁コードを入力してください。',
+ 'login.mfaCodeLabel': '確認コード',
+ 'login.mfaCodeRequired': '認証アプリのコードを入力してください。',
+ 'login.mfaHint': 'Google Authenticator、Authy などのTOTPアプリを開いてください。',
+ 'login.mfaBack': '← サインインに戻る',
+ 'login.mfaVerify': '確認',
+ 'login.invalidInviteLink': '無効または期限切れの招待リンクです',
+ 'login.oidcFailed': 'OIDCログインに失敗しました',
+ 'login.usernameRequired': 'ユーザー名を入力してください',
+ 'login.passwordMinLength': 'パスワードは8文字以上である必要があります',
+ 'login.forgotPassword': 'パスワードを忘れた場合',
+ 'login.forgotPasswordTitle': 'パスワードをリセット',
+ 'login.forgotPasswordBody': '登録時のメールアドレスを入力してください。アカウントが存在する場合、リセット用リンクを送信します。',
+ 'login.forgotPasswordSubmit': 'リセットリンクを送信',
+ 'login.forgotPasswordSentTitle': 'メールを確認してください',
+ 'login.forgotPasswordSentBody': '該当するアカウントがある場合、リセットリンクを送信しました。リンクの有効期限は60分です。',
+ 'login.forgotPasswordSmtpHintOff': '注意:管理者がSMTPを設定していないため、リセットリンクはメールではなくサーバーコンソールに出力されます。',
+ 'login.backToLogin': 'ログインに戻る',
+ 'login.newPassword': '新しいパスワード',
+ 'login.confirmPassword': '新しいパスワード(確認)',
+ 'login.passwordsDontMatch': 'パスワードが一致しません',
+ 'login.mfaCode': '2FAコード',
+ 'login.resetPasswordTitle': '新しいパスワードを設定',
+ 'login.resetPasswordBody': '以前使用していない強力なパスワードを設定してください(8文字以上)。',
+ 'login.resetPasswordMfaBody': '2FAコードまたはバックアップコードを入力してリセットを完了してください。',
+ 'login.resetPasswordSubmit': 'パスワードをリセット',
+ 'login.resetPasswordVerify': '確認してリセット',
+ 'login.resetPasswordSuccessTitle': 'パスワードを更新しました',
+ 'login.resetPasswordSuccessBody': '新しいパスワードでログインできます。',
+ 'login.resetPasswordInvalidLink': '無効なリセットリンク',
+ 'login.resetPasswordInvalidLinkBody': 'リンクが無効または破損しています。新しいリンクをリクエストしてください。',
+ 'login.resetPasswordFailed': 'リセットに失敗しました。リンクの有効期限が切れている可能性があります。',
+
+ // Register
+ 'register.passwordMismatch': 'パスワードが一致しません',
+ 'register.passwordTooShort': 'パスワードは8文字以上必要です',
+ 'register.failed': '登録に失敗しました',
+ 'register.getStarted': '始める',
+ 'register.subtitle': 'アカウントを作成して、理想の旅行計画を始めましょう。',
+ 'register.feature1': '無制限の旅行プラン',
+ 'register.feature2': 'インタラクティブなマップ表示',
+ 'register.feature3': '場所とカテゴリを管理',
+ 'register.feature4': '予約を管理',
+ 'register.feature5': '持ち物リストを作成',
+ 'register.feature6': '写真・ファイルを保存',
+ 'register.createAccount': 'アカウントを作成',
+ 'register.startPlanning': '旅行計画を始める',
+ 'register.minChars': '最小6文字',
+ 'register.confirmPassword': 'パスワード確認',
+ 'register.repeatPassword': 'パスワードを再入力',
+ 'register.registering': '登録中...',
+ 'register.register': '登録',
+ 'register.hasAccount': 'すでにアカウントをお持ちですか?',
+ 'register.signIn': 'サインイン',
+
+ // Admin
+ 'admin.title': '管理',
+ 'admin.subtitle': 'ユーザー管理とシステム設定',
+ 'admin.tabs.users': 'ユーザー',
+ 'admin.tabs.categories': 'カテゴリ',
+ 'admin.tabs.backup': 'バックアップ',
+ 'admin.tabs.notifications': '通知',
+ 'admin.tabs.audit': '監査',
+ 'admin.stats.users': 'ユーザー',
+ 'admin.stats.trips': '旅行',
+ 'admin.stats.places': '場所',
+ 'admin.stats.photos': '写真',
+ 'admin.stats.files': 'ファイル',
+ 'admin.table.user': 'ユーザー',
+ 'admin.table.email': 'メール',
+ 'admin.table.role': '権限',
+ 'admin.table.created': '作成日',
+ 'admin.table.lastLogin': '最終ログイン',
+ 'admin.table.actions': '操作',
+ 'admin.you': '(あなた)',
+ 'admin.editUser': 'ユーザーを編集',
+ 'admin.newPassword': '新しいパスワード',
+ 'admin.newPasswordHint': '空欄の場合は現在のパスワードを維持',
+ 'admin.deleteUser': 'ユーザー「{name}」を削除しますか?すべての旅行が完全に削除されます。',
+ 'admin.deleteUserTitle': 'ユーザー削除',
+ 'admin.newPasswordPlaceholder': '新しいパスワードを入力…',
+ 'admin.toast.loadError': '管理データの読み込みに失敗しました',
+ 'admin.toast.userUpdated': 'ユーザーを更新しました',
+ 'admin.toast.updateError': '更新に失敗しました',
+ 'admin.toast.userDeleted': 'ユーザーを削除しました',
+ 'admin.toast.deleteError': '削除に失敗しました',
+ 'admin.toast.cannotDeleteSelf': '自分のアカウントは削除できません',
+ 'admin.toast.userCreated': 'ユーザーを作成しました',
+ 'admin.toast.createError': 'ユーザー作成に失敗しました',
+ 'admin.toast.fieldsRequired': 'ユーザー名、メール、パスワードは必須です',
+ 'admin.createUser': 'ユーザーを作成',
+ 'admin.invite.title': '招待リンク',
+ 'admin.invite.subtitle': '使い切り登録リンクを作成',
+ 'admin.invite.create': 'リンク作成',
+ 'admin.invite.createAndCopy': '作成してコピー',
+ 'admin.invite.empty': '招待リンクがまだありません',
+ 'admin.invite.maxUses': '最大使用回数',
+ 'admin.invite.expiry': '有効期限',
+ 'admin.invite.uses': '使用済み',
+ 'admin.invite.expiresAt': '期限',
+ 'admin.invite.createdBy': '作成者',
+ 'admin.invite.active': '有効',
+ 'admin.invite.expired': '期限切れ',
+ 'admin.invite.usedUp': '使用済み',
+ 'admin.invite.copied': '招待リンクをコピーしました',
+ 'admin.invite.copyLink': 'リンクをコピー',
+ 'admin.invite.deleted': '招待リンクを削除しました',
+ 'admin.invite.createError': '招待リンクの作成に失敗しました',
+ 'admin.invite.deleteError': '招待リンクの削除に失敗しました',
+ 'admin.tabs.settings': '設定',
+ 'admin.allowRegistration': '登録を許可',
+ 'admin.allowRegistrationHint': '新規ユーザーの自己登録を許可します',
+ 'admin.authMethods': '認証方法',
+ 'admin.passwordLogin': 'パスワードログイン',
+ 'admin.passwordLoginHint': 'メールとパスワードでのログインを許可',
+ 'admin.passwordRegistration': 'パスワード登録',
+ 'admin.passwordRegistrationHint': 'メールとパスワードでの新規登録を許可',
+ 'admin.oidcLogin': 'SSOログイン',
+ 'admin.oidcLoginHint': 'SSOでのログインを許可',
+ 'admin.oidcRegistration': 'SSO自動登録',
+ 'admin.oidcRegistrationHint': '新しいSSOユーザーを自動作成',
+ 'admin.envOverrideHint': 'パスワードログイン設定は OIDC_ONLY 環境変数で制御されています。',
+ 'admin.lockoutWarning': '少なくとも1つのログイン方法を有効にしてください',
+ 'admin.requireMfa': '二要素認証(2FA)を必須にする',
+ 'admin.requireMfaHint': '2FA未設定のユーザーは、利用前に設定が必要です。',
+ 'admin.apiKeys': 'APIキー',
+ 'admin.apiKeysHint': '任意。写真や天気などの拡張データを有効化します。',
+ 'admin.mapsKey': 'Google Maps APIキー',
+ 'admin.mapsKeyHint': '場所検索に必要。console.cloud.google.com で取得',
+ 'admin.mapsKeyHintLong': 'APIキーなしではOpenStreetMapを使用します。Google APIキーがあれば写真、評価、営業時間も表示できます。',
+ 'admin.recommended': '推奨',
+ 'admin.weatherKey': 'OpenWeatherMap APIキー',
+ 'admin.weatherKeyHint': '天気データ用。openweathermap.org で無料',
+ 'admin.validateKey': 'テスト',
+ 'admin.keyValid': '接続済み',
+ 'admin.keyInvalid': '無効',
+ 'admin.keySaved': 'APIキーを保存しました',
+ 'admin.oidcTitle': 'シングルサインオン(OIDC)',
+ 'admin.oidcSubtitle': 'Google、Apple、Authentik、Keycloak などでログインを許可します。',
+ 'admin.oidcDisplayName': '表示名',
+ 'admin.oidcIssuer': 'Issuer URL',
+ 'admin.oidcIssuerHint': 'OpenID ConnectのIssuer URL(例:https://accounts.google.com)',
+ 'admin.oidcSaved': 'OIDC設定を保存しました',
+ 'admin.oidcOnlyMode': 'パスワード認証を無効化',
+ 'admin.oidcOnlyModeHint': '有効にするとSSOのみ使用可能になり、パスワードログインと登録は無効になります。',
+
+ // File Types
+ 'admin.fileTypes': '許可するファイル形式',
+ 'admin.fileTypesHint': 'ユーザーがアップロードできるファイル形式を設定します。',
+ 'admin.fileTypesFormat': '拡張子をカンマ区切り(例:jpg,png,pdf,doc)。すべて許可する場合は *。',
+ 'admin.fileTypesSaved': 'ファイル形式の設定を保存しました',
+
+ 'admin.placesPhotos.title': '場所の写真',
+ 'admin.placesPhotos.subtitle': 'Google Places APIから写真を取得します。APIクォータ節約のため無効にできます。Wikimediaの写真には影響しません。',
+ 'admin.placesAutocomplete.title': '場所のオートコンプリート',
+ 'admin.placesAutocomplete.subtitle': '検索候補にGoogle Places APIを使用します。APIクォータ節約のため無効にできます。',
+ 'admin.placesDetails.title': '場所の詳細',
+ 'admin.placesDetails.subtitle': 'Google Places APIから営業時間、評価、ウェブサイトなどの詳細情報を取得します。APIクォータ節約のため無効にできます。',
+// Packing Templates & Bag Tracking
+ 'admin.bagTracking.title': 'バッグ管理',
+ 'admin.bagTracking.subtitle': '持ち物の重量とバッグ割り当てを有効化',
+ 'admin.collab.chat.title': 'チャット',
+ 'admin.collab.chat.subtitle': '旅行の共同作業用リアルタイムメッセージ',
+ 'admin.collab.notes.title': 'ノート',
+ 'admin.collab.notes.subtitle': '共有ノートとドキュメント',
+ 'admin.collab.polls.title': '投票',
+ 'admin.collab.polls.subtitle': 'グループ投票・アンケート',
+ 'admin.collab.whatsnext.title': '次にすること',
+ 'admin.collab.whatsnext.subtitle': 'アクティビティ提案と次のステップ',
+ 'admin.tabs.config': 'カスタマイズ',
+ 'admin.tabs.defaults': 'ユーザーのデフォルト',
+ 'admin.defaultSettings.title': '既定のユーザー設定',
+ 'admin.defaultSettings.description': 'インスタンス全体の既定値を設定します。設定を変更していないユーザーにはこれらの値が適用されます。各ユーザーの設定変更は常に優先されます。',
+ 'admin.defaultSettings.saved': '既定値を保存しました',
+ 'admin.defaultSettings.reset': '組み込みの既定値に戻す',
+ 'admin.defaultSettings.resetToBuiltIn': 'リセット',
+ 'admin.tabs.templates': '持ち物テンプレート',
+ 'admin.packingTemplates.title': '持ち物テンプレート',
+ 'admin.packingTemplates.subtitle': '旅行で使い回せる持ち物リストを作成',
+ 'admin.packingTemplates.create': '新規テンプレート',
+ 'admin.packingTemplates.namePlaceholder': 'テンプレート名(例:ビーチ旅行)',
+ 'admin.packingTemplates.empty': 'テンプレートはまだありません',
+ 'admin.packingTemplates.items': 'アイテム',
+ 'admin.packingTemplates.categories': 'カテゴリ',
+ 'admin.packingTemplates.itemName': 'アイテム名',
+ 'admin.packingTemplates.itemCategory': 'カテゴリ',
+ 'admin.packingTemplates.categoryName': 'カテゴリ名(例:衣類)',
+ 'admin.packingTemplates.addCategory': 'カテゴリを追加',
+ 'admin.packingTemplates.created': 'テンプレートを作成しました',
+ 'admin.packingTemplates.deleted': 'テンプレートを削除しました',
+ 'admin.packingTemplates.loadError': 'テンプレートの読み込みに失敗しました',
+ 'admin.packingTemplates.createError': 'テンプレートの作成に失敗しました',
+ 'admin.packingTemplates.deleteError': 'テンプレートの削除に失敗しました',
+ 'admin.packingTemplates.saveError': '保存に失敗しました',
+
+// Addons
+ 'admin.tabs.addons': 'アドオン',
+ 'admin.addons.title': 'アドオン',
+ 'admin.addons.subtitle': '機能を有効/無効にしてTREKをカスタマイズします。',
+ 'admin.addons.catalog.packing.name': 'リスト',
+ 'admin.addons.catalog.packing.description': '旅行用の持ち物リストとToDo',
+ 'admin.addons.catalog.budget.name': '予算',
+ 'admin.addons.catalog.budget.description': '支出の管理と予算計画',
+ 'admin.addons.catalog.documents.name': 'ドキュメント',
+ 'admin.addons.catalog.documents.description': '旅行書類の保存・管理',
+ 'admin.addons.catalog.vacay.name': 'Vacay',
+ 'admin.addons.catalog.vacay.description': 'カレンダー表示の個人休暇プランナー',
+ 'admin.addons.catalog.atlas.name': 'Atlas',
+ 'admin.addons.catalog.atlas.description': '訪問国と旅行統計の世界地図',
+ 'admin.addons.catalog.collab.name': 'Collab',
+ 'admin.addons.catalog.collab.description': 'リアルタイムのメモ、投票、チャット',
+ 'admin.addons.catalog.memories.name': '写真(Immich)',
+ 'admin.addons.catalog.memories.description': 'Immichで旅行写真を共有',
+ 'admin.addons.catalog.mcp.name': 'MCP',
+ 'admin.addons.catalog.mcp.description': 'AI連携のためのModel Context Protocol',
+ 'admin.addons.subtitleBefore': '機能を有効/無効にして ',
+ 'admin.addons.subtitleAfter': ' をカスタマイズします。',
+ 'admin.addons.enabled': '有効',
+ 'admin.addons.disabled': '無効',
+ 'admin.addons.type.trip': '旅行',
+ 'admin.addons.type.global': '全体',
+ 'admin.addons.type.integration': '連携',
+ 'admin.addons.tripHint': '各旅行内のタブとして利用可能',
+ 'admin.addons.globalHint': 'メインナビの独立セクションとして利用可能',
+ 'admin.addons.integrationHint': '専用ページのないバックエンド/API連携',
+ 'admin.addons.toast.updated': 'アドオンを更新しました',
+ 'admin.addons.toast.error': 'アドオンの更新に失敗しました',
+ 'admin.addons.noAddons': '利用可能なアドオンはありません',
+// Weather info
+ 'admin.weather.title': '天気データ',
+ 'admin.weather.badge': '2026年3月24日以降',
+ 'admin.weather.description': 'TREKは天気データにOpen‑Meteoを使用しています。無料でオープンソース、APIキーは不要です。',
+ 'admin.weather.forecast': '16日間予報',
+ 'admin.weather.forecastDesc': '以前は5日(OpenWeatherMap)',
+ 'admin.weather.climate': '過去の気候データ',
+ 'admin.weather.climateDesc': '16日以降は過去85年の平均値',
+ 'admin.weather.requests': '1日10,000リクエスト',
+ 'admin.weather.requestsDesc': '無料、APIキー不要',
+ 'admin.weather.locationHint': '各日の座標付き最初の場所を基準にします。場所が未割り当ての場合は、場所一覧の任意の場所を参照します。',
+
+ // GitHub
+ 'admin.tabs.mcpTokens': 'MCPトークン',
+ 'admin.mcpTokens.title': 'MCPトークン',
+ 'admin.mcpTokens.subtitle': 'すべてのユーザーのAPIトークンを管理',
+ 'admin.mcpTokens.sectionTitle': 'API トークン',
+ 'admin.mcpTokens.owner': '所有者',
+ 'admin.mcpTokens.tokenName': 'トークン名',
+ 'admin.mcpTokens.created': '作成日',
+ 'admin.mcpTokens.lastUsed': '最終使用',
+ 'admin.mcpTokens.never': '未使用',
+ 'admin.mcpTokens.empty': 'MCPトークンはまだ作成されていません',
+ 'admin.mcpTokens.deleteTitle': 'トークンを削除',
+ 'admin.mcpTokens.deleteMessage': 'このトークンは即座に失効します。ユーザーはこのトークン経由のMCPアクセスを失います。',
+ 'admin.mcpTokens.deleteSuccess': 'トークンを削除しました',
+ 'admin.mcpTokens.deleteError': 'トークンの削除に失敗しました',
+ 'admin.mcpTokens.loadError': 'トークンの読み込みに失敗しました',
+ 'admin.oauthSessions.sectionTitle': 'OAuthセッション',
+ 'admin.oauthSessions.clientName': 'クライアント',
+ 'admin.oauthSessions.owner': '所有者',
+ 'admin.oauthSessions.scopes': 'スコープ',
+ 'admin.oauthSessions.created': '作成日時',
+ 'admin.oauthSessions.empty': '有効なOAuthセッションはありません',
+ 'admin.oauthSessions.revokeTitle': 'セッションを無効化',
+ 'admin.oauthSessions.revokeMessage': 'このOAuthセッションは即時無効化され、クライアントはMCPへのアクセスを失います。',
+ 'admin.oauthSessions.revokeSuccess': 'セッションを無効化しました',
+ 'admin.oauthSessions.revokeError': 'セッションの無効化に失敗しました',
+ 'admin.oauthSessions.loadError': 'OAuthセッションの読み込みに失敗しました',
+ 'admin.tabs.github': 'GitHub',
+
+ 'admin.audit.subtitle': 'セキュリティおよび管理イベント(バックアップ、ユーザー、MFA、設定)。',
+ 'admin.audit.empty': '監査ログはまだありません。',
+ 'admin.audit.refresh': '更新',
+ 'admin.audit.loadMore': 'さらに読み込む',
+ 'admin.audit.showing': '{count}件表示 · 全{total}件',
+ 'admin.audit.col.time': '時刻',
+ 'admin.audit.col.user': 'ユーザー',
+ 'admin.audit.col.action': '操作',
+ 'admin.audit.col.resource': '対象',
+ 'admin.audit.col.ip': 'IP',
+ 'admin.audit.col.details': '詳細',
+ 'admin.github.title': 'リリース履歴',
+ 'admin.github.subtitle': '{repo} の最新アップデート',
+ 'admin.github.latest': '最新',
+ 'admin.github.prerelease': 'プレリリース',
+ 'admin.github.showDetails': '詳細を表示',
+ 'admin.github.hideDetails': '詳細を非表示',
+ 'admin.github.loadMore': 'さらに読み込む',
+ 'admin.github.loading': '読み込み中...',
+ 'admin.github.error': 'リリースの読み込みに失敗しました',
+ 'admin.github.by': '作成者',
+ 'admin.github.support': 'TREKの開発を支援',
+
+ 'admin.update.available': '更新があります',
+ 'admin.update.text': 'TREK {version} が利用可能です。現在は {current} を使用しています。',
+ 'admin.update.button': 'GitHubで見る',
+ 'admin.update.install': '更新をインストール',
+ 'admin.update.confirmTitle': '更新をインストールしますか?',
+ 'admin.update.confirmText': 'TREKを {current} から {version} に更新します。更新後、サーバーは自動的に再起動します。',
+ 'admin.update.dataInfo': 'すべてのデータ(旅行、ユーザー、APIキー、アップロード、Vacay、Atlas、予算)は保持されます。',
+ 'admin.update.warning': '再起動中、アプリは短時間利用できません。',
+ 'admin.update.confirm': '今すぐ更新',
+ 'admin.update.installing': '更新中…',
+ 'admin.update.success': '更新が完了しました!サーバーを再起動しています…',
+ 'admin.update.failed': '更新に失敗しました',
+ 'admin.update.backupHint': '更新前にバックアップを作成することをおすすめします。',
+ 'admin.update.backupLink': 'バックアップへ',
+ 'admin.update.howTo': '更新方法',
+ 'admin.update.dockerText': 'TREKはDockerで実行されています。{version} に更新するには、サーバーで次のコマンドを実行してください:',
+ 'admin.update.reloadHint': '数秒後にページを再読み込みしてください。',
+
+// Vacay addon
+ 'vacay.subtitle': '休暇日数の計画と管理',
+ 'vacay.settings': '設定',
+ 'vacay.year': '年',
+ 'vacay.addYear': '翌年を追加',
+ 'vacay.addPrevYear': '前年を追加',
+ 'vacay.removeYear': '年を削除',
+ 'vacay.removeYearConfirm': '{year}年を削除しますか?',
+ 'vacay.removeYearHint': 'この年の休暇データと会社休日はすべて完全に削除されます。',
+ 'vacay.remove': '削除',
+ 'vacay.persons': '人物',
+ 'vacay.noPersons': '人物が追加されていません',
+ 'vacay.addPerson': '人物を追加',
+ 'vacay.editPerson': '人物を編集',
+ 'vacay.removePerson': '人物を削除',
+ 'vacay.removePersonConfirm': '{name}を削除しますか?',
+ 'vacay.removePersonHint': 'この人物のすべての休暇データが完全に削除されます。',
+ 'vacay.personName': '名前',
+ 'vacay.personNamePlaceholder': '名前を入力',
+ 'vacay.color': '色',
+ 'vacay.add': '追加',
+ 'vacay.legend': '凡例',
+ 'vacay.publicHoliday': '祝日',
+ 'vacay.companyHoliday': '会社休日',
+ 'vacay.weekend': '週末',
+ 'vacay.modeVacation': '休暇',
+ 'vacay.modeCompany': '会社休日',
+ 'vacay.entitlement': '付与日数',
+ 'vacay.entitlementDays': '日',
+ 'vacay.used': '使用済み',
+ 'vacay.remaining': '残り',
+ 'vacay.carriedOver': '{year}年から繰越',
+ 'vacay.blockWeekends': '週末を除外',
+ 'vacay.blockWeekendsHint': '週末に休暇を登録できないようにします',
+ 'vacay.weekendDays': '週末',
+ 'vacay.mon': '月',
+ 'vacay.tue': '火',
+ 'vacay.wed': '水',
+ 'vacay.thu': '木',
+ 'vacay.fri': '金',
+ 'vacay.sat': '土',
+ 'vacay.sun': '日',
+ 'vacay.publicHolidays': '祝日',
+ 'vacay.publicHolidaysHint': 'カレンダーに祝日を表示',
+ 'vacay.selectCountry': '国を選択',
+ 'vacay.selectRegion': '地域を選択(任意)',
+ 'vacay.addCalendar': 'カレンダーを追加',
+ 'vacay.calendarLabel': 'ラベル(任意)',
+ 'vacay.calendarColor': '色',
+ 'vacay.noCalendars': '祝日カレンダーはまだありません',
+ 'vacay.companyHolidays': '会社休日',
+ 'vacay.companyHolidaysHint': '会社全体の休日を設定できます',
+ 'vacay.companyHolidaysNoDeduct': '会社休日は休暇日数に含まれません。',
+ 'vacay.weekStart': '週の開始',
+ 'vacay.weekStartHint': 'カレンダーの週を月曜始まりにするか日曜始まりにするかを選択します',
+ 'vacay.carryOver': '繰越',
+ 'vacay.carryOverHint': '残りの休暇日数を翌年に自動で繰り越します',
+ 'vacay.sharing': '共有',
+ 'vacay.sharingHint': '他のTREKユーザーと休暇計画を共有',
+ 'vacay.owner': '所有者',
+ 'vacay.shareEmailPlaceholder': 'TREKユーザーのメール',
+ 'vacay.shareSuccess': '計画を共有しました',
+ 'vacay.shareError': '共有できませんでした',
+ 'vacay.dissolve': '統合を解除',
+ 'vacay.dissolveHint': 'カレンダーを分離します。データは保持されます。',
+ 'vacay.dissolveAction': '解除',
+ 'vacay.dissolved': 'カレンダーを分離しました',
+ 'vacay.fusedWith': '統合相手',
+ 'vacay.you': 'あなた',
+ 'vacay.noData': 'データなし',
+ 'vacay.changeColor': '色を変更',
+ 'vacay.inviteUser': 'ユーザーを招待',
+ 'vacay.inviteHint': '別のTREKユーザーを招待して、休暇カレンダーを共有します。',
+ 'vacay.selectUser': 'ユーザーを選択',
+ 'vacay.sendInvite': '招待を送信',
+ 'vacay.inviteSent': '招待を送信しました',
+ 'vacay.inviteError': '招待を送信できませんでした',
+ 'vacay.pending': '保留中',
+ 'vacay.noUsersAvailable': '利用可能なユーザーがいません',
+ 'vacay.accept': '承認',
+ 'vacay.decline': '拒否',
+ 'vacay.acceptFusion': '承認して統合',
+ 'vacay.inviteTitle': '統合の招待',
+ 'vacay.inviteWantsToFuse': 'が休暇カレンダーの共有を希望しています。',
+ 'vacay.fuseInfo1': '双方が1つの共有カレンダーですべての休暇を確認できます。',
+ 'vacay.fuseInfo2': '双方が互いの予定を作成・編集できます。',
+ 'vacay.fuseInfo3': '双方が予定の削除や付与日数の変更を行えます。',
+ 'vacay.fuseInfo4': '祝日や会社休日などの設定は共有されます。',
+ 'vacay.fuseInfo5': '統合はいつでも解除できます。データは保持されます。',
+ 'nav.myTrips': 'マイ旅行',
+
+ // Atlas addon
+ 'atlas.subtitle': '世界に広がるあなたの旅の足跡',
+ 'atlas.countries': '国',
+ 'atlas.trips': '旅行',
+ 'atlas.places': '場所',
+ 'atlas.unmark': '削除',
+ 'atlas.confirmMark': 'この国を訪問済みにしますか?',
+ 'atlas.confirmUnmark': 'この国を訪問済みリストから削除しますか?',
+ 'atlas.confirmUnmarkRegion': 'この地域を訪問済みリストから削除しますか?',
+ 'atlas.markVisited': '訪問済みにする',
+ 'atlas.markVisitedHint': 'この国を訪問済みリストに追加',
+ 'atlas.markRegionVisitedHint': 'この地域を訪問済みリストに追加',
+ 'atlas.addToBucket': '行きたいリストに追加',
+ 'atlas.addPoi': '場所を追加',
+ 'atlas.searchCountry': '国を検索...',
+ 'atlas.bucketNamePlaceholder': '名前(国・都市・場所など)',
+ 'atlas.month': '月',
+ 'atlas.year': '年',
+ 'atlas.addToBucketHint': '行きたい場所として保存',
+ 'atlas.bucketWhen': '訪問予定はいつですか?',
+ 'atlas.statsTab': '統計',
+ 'atlas.bucketTab': '行きたいリスト',
+ 'atlas.addBucket': '行きたいリストに追加',
+ 'atlas.bucketNotesPlaceholder': 'メモ(任意)',
+ 'atlas.bucketEmpty': '行きたいリストは空です',
+ 'atlas.bucketEmptyHint': '行ってみたい場所を追加しましょう',
+ 'atlas.days': '日',
+ 'atlas.visitedCountries': '訪問国',
+ 'atlas.cities': '都市',
+ 'atlas.noData': '旅行データがありません',
+ 'atlas.noDataHint': '旅行を作成して場所を追加すると、マップに表示されます',
+ 'atlas.lastTrip': '前回の旅行',
+ 'atlas.nextTrip': '次の旅行',
+ 'atlas.daysLeft': '残り日数',
+ 'atlas.streak': '連続',
+ 'atlas.years': '年',
+ 'atlas.yearInRow': '年連続',
+ 'atlas.yearsInRow': '年連続',
+ 'atlas.tripIn': '旅行',
+ 'atlas.tripsIn': '旅行',
+ 'atlas.since': '開始',
+ 'atlas.europe': 'ヨーロッパ',
+ 'atlas.asia': 'アジア',
+ 'atlas.northAmerica': '北アメリカ',
+ 'atlas.southAmerica': '南アメリカ',
+ 'atlas.africa': 'アフリカ',
+ 'atlas.oceania': 'オセアニア',
+ 'atlas.other': 'その他',
+ 'atlas.firstVisit': '最初の旅行',
+ 'atlas.lastVisitLabel': '最後の旅行',
+ 'atlas.tripSingular': '旅行',
+ 'atlas.tripPlural': '旅行',
+ 'atlas.placeVisited': '訪問した場所',
+ 'atlas.placesVisited': '訪問した場所',
+
+// Trip Planner
+ 'trip.tabs.plan': '計画',
+ 'trip.tabs.transports': '移動',
+ 'trip.tabs.reservations': '予約',
+ 'trip.tabs.reservationsShort': '予約',
+ 'trip.tabs.packing': '持ち物リスト',
+ 'trip.tabs.packingShort': '持ち物',
+ 'trip.tabs.lists': 'リスト',
+ 'trip.tabs.listsShort': 'リスト',
+ 'trip.tabs.budget': '予算',
+ 'trip.tabs.files': 'ファイル',
+ 'trip.loading': '旅行を読み込み中...',
+ 'trip.loadingPhotos': '場所の写真を読み込み中...',
+ 'trip.mobilePlan': '計画',
+ 'trip.mobilePlaces': '場所',
+ 'trip.toast.placeUpdated': '場所を更新しました',
+ 'trip.toast.placeAdded': '場所を追加しました',
+ 'trip.toast.placeDeleted': '場所を削除しました',
+ 'trip.toast.selectDay': 'まず日を選択してください',
+ 'trip.toast.assignedToDay': '場所を日に割り当てました',
+ 'trip.toast.reorderError': '並び替えに失敗しました',
+ 'trip.toast.reservationUpdated': '予約を更新しました',
+ 'trip.toast.reservationAdded': '予約を追加しました',
+ 'trip.toast.deleted': '削除しました',
+ 'trip.confirm.deletePlace': 'この場所を削除してもよろしいですか?',
+ 'trip.confirm.deletePlaces': '{count}件の場所を削除してもよろしいですか?',
+ 'trip.toast.placesDeleted': '{count}件の場所を削除しました',
+
+// Day Plan Sidebar
+ 'dayplan.emptyDay': 'この日の予定はありません',
+ 'dayplan.cannotReorderTransport': '時刻が固定された予約は並び替えできません',
+ 'dayplan.confirmRemoveTimeTitle': '時刻を削除しますか?',
+ 'dayplan.confirmRemoveTimeBody': 'この場所には固定時刻({time})があります。移動すると時刻が削除され、自由に並び替えできます。',
+ 'dayplan.confirmRemoveTimeAction': '時刻を削除して移動',
+ 'dayplan.cannotDropOnTimed': '時刻指定の項目の間には配置できません',
+ 'dayplan.cannotBreakChronology': '時刻指定の項目や予約の時系列が崩れます',
+ 'dayplan.addNote': 'メモを追加',
+ 'dayplan.expandAll': 'すべての日を展開',
+ 'dayplan.collapseAll': 'すべての日を折りたたむ',
+ 'dayplan.editNote': 'メモを編集',
+ 'dayplan.noteAdd': 'メモを追加',
+ 'dayplan.noteEdit': 'メモを編集',
+ 'dayplan.noteTitle': 'メモ',
+ 'dayplan.noteSubtitle': '日別メモ',
+ 'dayplan.totalCost': '合計費用',
+ 'dayplan.days': '日',
+ 'dayplan.dayN': '{n}日目',
+ 'dayplan.calculating': '計算中...',
+ 'dayplan.route': 'ルート',
+ 'dayplan.optimize': '最適化',
+ 'dayplan.optimized': 'ルートを最適化しました',
+ 'dayplan.routeError': 'ルートの計算に失敗しました',
+ 'dayplan.toast.needTwoPlaces': 'ルート最適化には2つ以上の場所が必要です',
+ 'dayplan.toast.routeOptimized': 'ルートを最適化しました',
+ 'dayplan.toast.noGeoPlaces': '座標付きの場所がありません',
+ 'dayplan.confirmed': '確定',
+ 'dayplan.pendingRes': '保留',
+ 'dayplan.pdf': 'PDF',
+ 'dayplan.pdfTooltip': '日別計画をPDFで書き出し',
+ 'dayplan.pdfError': 'PDFの書き出しに失敗しました',
+
+ // Places Sidebar
+ 'places.addPlace': '場所/アクティビティを追加',
+ 'places.importFile': 'ファイルをインポート',
+ 'places.sidebarDrop': 'ドロップしてインポート',
+ 'places.importFileHint': 'Google My Maps、Google Earth、GPSトラッカーなどの .gpx、.kml、.kmz ファイルをインポートできます。',
+ 'places.importFileDropHere': 'クリックしてファイルを選択、またはここにドラッグ&ドロップ',
+ 'places.importFileDropActive': 'ドロップして選択',
+ 'places.importFileUnsupported': '対応していないファイル形式です。.gpx、.kml、.kmz を使用してください。',
+ 'places.importFileTooLarge': 'ファイルが大きすぎます。最大 {maxMb} MB までです。',
+ 'places.importFileError': 'インポートに失敗しました',
+ 'places.importAllSkipped': 'すべての場所は既に旅行に含まれています。',
+ 'places.gpxImported': 'GPXから {count} 件の場所をインポートしました',
+ 'places.gpxImportTypes': '何をインポートしますか?',
+ 'places.gpxImportWaypoints': 'ウェイポイント',
+ 'places.gpxImportRoutes': 'ルート',
+ 'places.gpxImportTracks': 'トラック(経路付き)',
+ 'places.gpxImportNoneSelected': '少なくとも1つ選択してください。',
+ 'places.kmlImportTypes': '何をインポートしますか?',
+ 'places.kmlImportPoints': 'ポイント(プレースマーク)',
+ 'places.kmlImportPaths': 'パス(ライン)',
+ 'places.kmlImportNoneSelected': '少なくとも1つ選択してください。',
+ 'places.selectionCount': '{count} 件選択中',
+ 'places.deleteSelected': '選択を削除',
+ 'places.kmlKmzImported': 'KMZ/KMLから {count} 件の場所をインポートしました',
+ 'places.urlResolved': 'URLから場所をインポートしました',
+ 'places.importList': 'リストをインポート',
+ 'places.kmlKmzSummaryValues': 'プレースマーク: {total} • 追加: {created} • スキップ: {skipped}',
+ 'places.importGoogleList': 'Google リスト',
+ 'places.importNaverList': 'Naver リスト',
+ 'places.googleListHint': '共有されたGoogleマップのリストリンクを貼り付けてください。',
+ 'places.googleListImported': '「{list}」から {count} 件の場所をインポートしました',
+ 'places.googleListError': 'Googleマップのリストをインポートできませんでした',
+ 'places.naverListHint': '共有されたNaverマップのリストリンクを貼り付けてください。',
+ 'places.naverListImported': '「{list}」から {count} 件の場所をインポートしました',
+ 'places.naverListError': 'Naverマップのリストをインポートできませんでした',
+ 'places.viewDetails': '詳細を見る',
+ 'places.assignToDay': 'どの日に追加しますか?',
+ 'places.all': 'すべて',
+ 'places.unplanned': '未計画',
+ 'places.filterTracks': 'トラック',
+ 'places.search': '場所を検索…',
+ 'places.allCategories': 'すべてのカテゴリ',
+ 'places.categoriesSelected': 'カテゴリ',
+ 'places.clearFilter': 'フィルター解除',
+ 'places.count': '{count} 件の場所',
+ 'places.countSingular': '1 件の場所',
+ 'places.allPlanned': 'すべての場所が計画済みです',
+ 'places.noneFound': '場所が見つかりません',
+ 'places.editPlace': '場所を編集',
+ 'places.formName': '名前',
+ 'places.formNamePlaceholder': '例:エッフェル塔',
+ 'places.formDescription': '説明',
+ 'places.formDescriptionPlaceholder': '短い説明…',
+ 'places.formAddress': '住所',
+ 'places.formAddressPlaceholder': '通り、都市、国',
+ 'places.formLat': '緯度(例:48.8566)',
+ 'places.formLng': '経度(例:2.3522)',
+ 'places.formCategory': 'カテゴリ',
+ 'places.noCategory': 'カテゴリなし',
+ 'places.categoryNamePlaceholder': 'カテゴリ名',
+ 'places.formTime': '時間',
+ 'places.startTime': '開始',
+ 'places.endTime': '終了',
+ 'places.endTimeBeforeStart': '終了時間が開始時間より前です',
+ 'places.timeCollision': '時間が重複しています:',
+ 'places.formWebsite': 'ウェブサイト',
+ 'places.formNotes': 'メモ',
+ 'places.formNotesPlaceholder': '個人的なメモ…',
+ 'places.formReservation': '予約',
+ 'places.reservationNotesPlaceholder': '予約メモ、確認番号など…',
+ 'places.mapsSearchPlaceholder': '場所を検索…',
+ 'places.mapsSearchError': '場所の検索に失敗しました。',
+ 'places.loadingDetails': '詳細を読み込み中…',
+ 'places.osmHint': 'OpenStreetMapで検索しています(写真・営業時間・評価なし)。設定でGoogle APIキーを追加すると詳細が表示されます。',
+ 'places.osmActive': 'OpenStreetMapで検索中(写真・評価・営業時間なし)。設定でGoogle APIキーを追加してください。',
+ 'places.categoryCreateError': 'カテゴリの作成に失敗しました',
+ 'places.nameRequired': '名前を入力してください',
+ 'places.saveError': '保存に失敗しました',
+// Place Inspector
+ 'inspector.opened': '営業中',
+ 'inspector.closed': '営業時間外',
+ 'inspector.openingHours': '営業時間',
+ 'inspector.showHours': '営業時間を表示',
+ 'inspector.files': 'ファイル',
+ 'inspector.remove': '削除',
+ 'inspector.filesCount': '{count}件のファイル',
+ 'inspector.removeFromDay': 'この日から削除',
+ 'inspector.addToDay': '日に追加',
+ 'inspector.confirmedRes': '確定済み予約',
+ 'inspector.pendingRes': '保留中の予約',
+ 'inspector.google': 'Googleマップで開く',
+ 'inspector.website': 'Webサイトを開く',
+ 'inspector.addRes': '予約',
+ 'inspector.editRes': '予約を編集',
+ 'inspector.participants': '参加者',
+ 'inspector.trackStats': '統計を記録',
+
+// Reservations
+ 'reservations.title': '予約',
+ 'reservations.empty': '予約はまだありません',
+ 'reservations.emptyHint': '航空券、ホテルなどの予約を追加しましょう',
+ 'reservations.add': '予約を追加',
+ 'reservations.addManual': '手動予約',
+ 'reservations.placeHint': 'ヒント:予約は場所から直接作成すると、日別計画に紐づけやすくなります。',
+ 'reservations.confirmed': '確定',
+ 'reservations.pending': '保留',
+ 'reservations.summary': '確定 {confirmed}件、保留 {pending}件',
+ 'reservations.fromPlan': '計画から',
+ 'reservations.showFiles': 'ファイルを表示',
+ 'reservations.editTitle': '予約を編集',
+ 'reservations.status': 'ステータス',
+ 'reservations.datetime': '日時',
+ 'reservations.startTime': '開始時刻',
+ 'reservations.endTime': '終了時刻',
+ 'reservations.date': '日付',
+ 'reservations.time': '時間',
+ 'reservations.timeAlt': '時間(代替、例:19:30)',
+ 'reservations.notes': 'メモ',
+ 'reservations.notesPlaceholder': '追加のメモ...',
+ 'reservations.meta.airline': '航空会社',
+ 'reservations.meta.flightNumber': '便名',
+ 'reservations.meta.from': '出発地',
+ 'reservations.meta.to': '到着地',
+ 'reservations.needsReview': '要確認',
+ 'reservations.needsReviewHint': '空港を自動で特定できませんでした。場所を確認してください。',
+ 'reservations.searchLocation': '駅・港・住所を検索…',
+ 'airport.searchPlaceholder': '空港コードまたは都市名(例:FRA)',
+ 'map.connections': '接続',
+ 'map.showConnections': '予約ルートを表示',
+ 'map.hideConnections': '予約ルートを非表示',
+ 'reservations.meta.trainNumber': '列車番号',
+ 'reservations.meta.platform': 'ホーム',
+ 'reservations.meta.seat': '座席',
+ 'reservations.meta.checkIn': 'チェックイン',
+ 'reservations.meta.checkOut': 'チェックアウト',
+ 'reservations.meta.linkAccommodation': '宿泊先',
+ 'reservations.meta.checkInUntil': 'チェックイン期限',
+ 'reservations.meta.pickAccommodation': '宿泊先にリンク',
+ 'reservations.meta.noAccommodation': 'なし',
+ 'reservations.meta.hotelPlace': '宿泊先',
+ 'reservations.meta.pickHotel': '宿泊先を選択',
+ 'reservations.meta.fromDay': '開始',
+ 'reservations.meta.toDay': '終了',
+ 'reservations.meta.selectDay': '日を選択',
+ 'reservations.type.flight': '航空便',
+ 'reservations.type.hotel': '宿泊',
+ 'reservations.type.restaurant': 'レストラン',
+ 'reservations.type.train': '列車',
+ 'reservations.type.car': 'レンタカー',
+ 'reservations.type.cruise': 'クルーズ',
+ 'reservations.type.event': 'イベント',
+ 'reservations.type.tour': 'ツアー',
+ 'reservations.type.other': 'その他',
+ 'reservations.confirm.delete': '予約「{name}」を削除しますか?',
+ 'reservations.confirm.deleteTitle': '予約を削除しますか?',
+ 'reservations.confirm.deleteBody': '「{name}」は完全に削除されます。',
+ 'reservations.toast.updated': '予約を更新しました',
+ 'reservations.toast.removed': '予約を削除しました',
+ 'reservations.toast.fileUploaded': 'ファイルをアップロードしました',
+ 'reservations.toast.uploadError': 'アップロードに失敗しました',
+ 'reservations.newTitle': '新しい予約',
+ 'reservations.bookingType': '予約タイプ',
+ 'reservations.titleLabel': 'タイトル',
+ 'reservations.titlePlaceholder': '例:Lufthansa LH123、Hotel Adlon',
+ 'reservations.locationAddress': '場所/住所',
+ 'reservations.locationPlaceholder': '住所、空港、ホテル...',
+ 'reservations.confirmationCode': '予約コード',
+ 'reservations.confirmationPlaceholder': '例:ABC12345',
+ 'reservations.day': '日',
+ 'reservations.noDay': '日なし',
+ 'reservations.place': '場所',
+ 'reservations.noPlace': '場所なし',
+ 'reservations.pendingSave': '保存されます…',
+ 'reservations.uploading': 'アップロード中...',
+ 'reservations.attachFile': 'ファイルを添付',
+ 'reservations.linkExisting': '既存ファイルをリンク',
+ 'reservations.toast.saveError': '保存に失敗しました',
+ 'reservations.toast.updateError': '更新に失敗しました',
+ 'reservations.toast.deleteError': '削除に失敗しました',
+ 'reservations.confirm.remove': '「{name}」の予約を削除しますか?',
+ 'reservations.linkAssignment': '日への割り当てにリンク',
+ 'reservations.pickAssignment': '計画から割り当てを選択...',
+ 'reservations.noAssignment': 'リンクなし(単独)',
+ 'reservations.price': '価格',
+ 'reservations.budgetCategory': '予算カテゴリ',
+ 'reservations.budgetCategoryPlaceholder': '例:交通、宿泊',
+ 'reservations.budgetCategoryAuto': '自動(予約タイプから)',
+ 'reservations.budgetHint': '保存時に予算エントリが自動で作成されます。',
+ 'reservations.departureDate': '出発',
+ 'reservations.arrivalDate': '到着',
+ 'reservations.departureTime': '出発時刻',
+ 'reservations.arrivalTime': '到着時刻',
+ 'reservations.pickupDate': '受取',
+ 'reservations.returnDate': '返却',
+ 'reservations.pickupTime': '受取時刻',
+ 'reservations.returnTime': '返却時刻',
+ 'reservations.endDate': '終了日',
+ 'reservations.meta.departureTimezone': '出発TZ',
+ 'reservations.meta.arrivalTimezone': '到着TZ',
+ 'reservations.span.departure': '出発',
+ 'reservations.span.arrival': '到着',
+ 'reservations.span.inTransit': '移動中',
+ 'reservations.span.pickup': '受取',
+ 'reservations.span.return': '返却',
+ 'reservations.span.active': '有効',
+ 'reservations.span.start': '開始',
+ 'reservations.span.end': '終了',
+ 'reservations.span.ongoing': '進行中',
+ 'reservations.validation.endBeforeStart': '終了日時は開始日時より後である必要があります',
+ 'reservations.addBooking': '予約を追加',
+
+ // Budget
+ 'budget.title': '予算',
+ 'budget.exportCsv': 'CSVを書き出し',
+ 'budget.emptyTitle': '予算はまだありません',
+ 'budget.emptyText': 'カテゴリと項目を作成して旅行予算を計画しましょう',
+ 'budget.emptyPlaceholder': 'カテゴリ名を入力...',
+ 'budget.createCategory': 'カテゴリを作成',
+ 'budget.category': 'カテゴリ',
+ 'budget.categoryName': 'カテゴリ名',
+ 'budget.table.name': '名前',
+ 'budget.table.total': '合計',
+ 'budget.table.persons': '人数',
+ 'budget.table.days': '日数',
+ 'budget.table.perPerson': '1人あたり',
+ 'budget.table.perDay': '1日あたり',
+ 'budget.table.perPersonDay': '人/日',
+ 'budget.table.note': 'メモ',
+ 'budget.table.date': '日付',
+ 'budget.newEntry': '新しい項目',
+ 'budget.defaultEntry': '新しい項目',
+ 'budget.defaultCategory': '新しいカテゴリ',
+ 'budget.total': '合計',
+ 'budget.totalBudget': '総予算',
+ 'budget.byCategory': 'カテゴリ別',
+ 'budget.editTooltip': 'クリックして編集',
+ 'budget.linkedToReservation': '予約に連携中 — 名前はそちらで編集してください',
+ 'budget.confirm.deleteCategory': '{count}件の項目があるカテゴリ「{name}」を削除しますか?',
+ 'budget.deleteCategory': 'カテゴリを削除',
+ 'budget.perPerson': '1人あたり',
+ 'budget.paid': '支払済み',
+ 'budget.open': '未精算',
+ 'budget.noMembers': 'メンバー未割り当て',
+ 'budget.settlement': '精算',
+ 'budget.settlementInfo': '予算項目のメンバーアイコンをクリックして緑にすると、支払い済みを示します。精算では、誰が誰にいくら支払うべきかを表示します。',
+ 'budget.netBalances': '差引残高',
+
+// Files
+ 'files.title': 'ファイル',
+ 'files.pageTitle': 'ファイル・ドキュメント',
+ 'files.subtitle': '{trip} のファイル {count} 件',
+ 'files.download': 'ダウンロード',
+ 'files.openError': 'ファイルを開けませんでした',
+ 'files.downloadPdf': 'PDFをダウンロード',
+ 'files.count': '{count}件のファイル',
+ 'files.countSingular': '1件のファイル',
+ 'files.uploaded': '{count}件アップロード',
+ 'files.uploadError': 'アップロードに失敗しました',
+ 'files.dropzone': 'ここにファイルをドロップ',
+ 'files.dropzoneHint': 'またはクリックして参照',
+ 'files.allowedTypes': '画像、PDF、DOC、DOCX、XLS、XLSX、TXT、CSV · 最大50MB',
+ 'files.uploading': 'アップロード中...',
+ 'files.filterAll': 'すべて',
+ 'files.filterPdf': 'PDF',
+ 'files.filterImages': '画像',
+ 'files.filterDocs': 'ドキュメント',
+ 'files.filterCollab': 'Collabメモ',
+ 'files.sourceCollab': 'Collabメモより',
+ 'files.empty': 'ファイルはまだありません',
+ 'files.emptyHint': 'ファイルをアップロードして旅行に添付しましょう',
+ 'files.openTab': '新しいタブで開く',
+ 'files.confirm.delete': 'このファイルを削除しますか?',
+ 'files.toast.deleted': 'ファイルを削除しました',
+ 'files.toast.deleteError': 'ファイルの削除に失敗しました',
+ 'files.sourcePlan': '日別計画',
+ 'files.sourceBooking': '予約',
+ 'files.sourceTransport': '移動',
+ 'files.attach': '添付',
+ 'files.pasteHint': 'クリップボードから画像を貼り付けることもできます(Ctrl+V)',
+ 'files.trash': 'ゴミ箱',
+ 'files.trashEmpty': 'ゴミ箱は空です',
+ 'files.emptyTrash': 'ゴミ箱を空にする',
+ 'files.restore': '復元',
+ 'files.star': 'スター',
+ 'files.unstar': 'スター解除',
+ 'files.assign': '割り当て',
+ 'files.assignTitle': 'ファイルを割り当て',
+ 'files.assignPlace': '場所',
+ 'files.assignBooking': '予約',
+ 'files.assignTransport': '移動',
+ 'files.unassigned': '未割り当て',
+ 'files.unlink': 'リンクを解除',
+ 'files.toast.trashed': 'ゴミ箱に移動しました',
+ 'files.toast.restored': 'ファイルを復元しました',
+ 'files.toast.trashEmptied': 'ゴミ箱を空にしました',
+ 'files.toast.assigned': 'ファイルを割り当てました',
+ 'files.toast.assignError': '割り当てに失敗しました',
+ 'files.toast.restoreError': '復元に失敗しました',
+ 'files.confirm.permanentDelete': 'このファイルを完全に削除しますか?元に戻せません。',
+ 'files.confirm.emptyTrash': 'ゴミ箱内のファイルをすべて完全に削除しますか?元に戻せません。',
+ 'files.noteLabel': 'メモ',
+ 'files.notePlaceholder': 'メモを追加...',
+
+// Packing
+ 'packing.title': '持ち物リスト',
+ 'packing.empty': '持ち物リストは空です',
+ 'packing.import': 'インポート',
+ 'packing.importTitle': '持ち物リストをインポート',
+ 'packing.importHint': '1行につき1項目。形式:カテゴリ, 名前, 重量(g・任意), バッグ(任意), checked/unchecked(任意)',
+ 'packing.importPlaceholder': '衛生用品, 歯ブラシ\n衣類, Tシャツ, 200\n書類, パスポート, , 機内持ち込み\n電子機器, 充電器, 50, スーツケース, checked',
+ 'packing.importCsv': 'CSV/TXTを読み込む',
+ 'packing.importAction': '{count}件をインポート',
+ 'packing.importSuccess': '{count}件インポートしました',
+ 'packing.importError': 'インポートに失敗しました',
+ 'packing.importEmpty': 'インポートする項目がありません',
+ 'packing.progress': '{packed}/{total} 梱包済み({percent}%)',
+ 'packing.clearChecked': 'チェック済み{count}件を削除',
+ 'packing.clearCheckedShort': '{count}件を削除',
+ 'packing.suggestions': 'おすすめ',
+ 'packing.suggestionsTitle': 'おすすめを追加',
+ 'packing.allSuggested': 'おすすめはすべて追加済み',
+ 'packing.allPacked': 'すべて梱包済み!',
+ 'packing.addPlaceholder': '新しい項目を追加...',
+ 'packing.categoryPlaceholder': 'カテゴリ...',
+ 'packing.filterAll': 'すべて',
+ 'packing.filterOpen': '未完了',
+ 'packing.filterDone': '完了',
+ 'packing.emptyTitle': '持ち物リストは空です',
+ 'packing.emptyHint': '項目を追加するか、おすすめを使いましょう',
+ 'packing.emptyFiltered': 'このフィルターに一致する項目はありません',
+ 'packing.menuRename': '名前を変更',
+ 'packing.menuCheckAll': 'すべてチェック',
+ 'packing.menuUncheckAll': 'すべて解除',
+ 'packing.menuDeleteCat': 'カテゴリを削除',
+ 'packing.noMembers': '旅行メンバーがいません',
+ 'packing.addItem': '項目を追加',
+ 'packing.addItemPlaceholder': '項目名...',
+ 'packing.addCategory': 'カテゴリを追加',
+ 'packing.newCategoryPlaceholder': 'カテゴリ名(例:衣類)',
+ 'packing.applyTemplate': 'テンプレートを適用',
+ 'packing.template': 'テンプレート',
+ 'packing.templateApplied': 'テンプレートから{count}件追加しました',
+ 'packing.templateError': 'テンプレートの適用に失敗しました',
+ 'packing.saveAsTemplate': 'テンプレートとして保存',
+ 'packing.templateName': 'テンプレート名',
+ 'packing.templateSaved': '持ち物リストをテンプレートとして保存しました',
+ 'packing.bags': 'バッグ',
+ 'packing.noBag': '未割り当て',
+ 'packing.totalWeight': '総重量',
+ 'packing.bagName': 'バッグ名...',
+ 'packing.addBag': 'バッグを追加',
+ 'packing.changeCategory': 'カテゴリを変更',
+ 'packing.confirm.clearChecked': 'チェック済み{count}件を削除しますか?',
+ 'packing.confirm.deleteCat': '{count}件の項目があるカテゴリ「{name}」を削除しますか?',
+ 'packing.defaultCategory': 'その他',
+ 'packing.toast.saveError': '保存に失敗しました',
+ 'packing.toast.deleteError': '削除に失敗しました',
+ 'packing.toast.renameError': '名前の変更に失敗しました',
+ 'packing.toast.addError': '追加に失敗しました',
+
+// Packing suggestions
+ 'packing.suggestions.items': [
+ { name: 'パスポート', category: '書類' },
+ { name: '身分証明書', category: '書類' },
+ { name: '海外旅行保険', category: '書類' },
+ { name: '航空券', category: '書類' },
+ { name: 'クレジットカード', category: '金融' },
+ { name: '現金', category: '金融' },
+ { name: 'ビザ', category: '書類' },
+ { name: 'Tシャツ', category: '衣類' },
+ { name: 'ズボン', category: '衣類' },
+ { name: '下着', category: '衣類' },
+ { name: '靴下', category: '衣類' },
+ { name: '上着', category: '衣類' },
+ { name: '寝間着', category: '衣類' },
+ { name: '水着', category: '衣類' },
+ { name: 'レインジャケット', category: '衣類' },
+ { name: '歩きやすい靴', category: '衣類' },
+ { name: '歯ブラシ', category: '洗面用具' },
+ { name: '歯磨き粉', category: '洗面用具' },
+ { name: 'シャンプー', category: '洗面用具' },
+ { name: 'デオドラント', category: '洗面用具' },
+ { name: '日焼け止め', category: '洗面用具' },
+ { name: 'カミソリ', category: '洗面用具' },
+ { name: '充電器', category: '電子機器' },
+ { name: 'モバイルバッテリー', category: '電子機器' },
+ { name: 'ヘッドホン', category: '電子機器' },
+ { name: '変換プラグ', category: '電子機器' },
+ { name: 'カメラ', category: '電子機器' },
+ { name: '鎮痛薬', category: '健康' },
+ { name: '絆創膏', category: '健康' },
+ { name: '消毒液', category: '健康' },
+ ],
+
+ // Members / Sharing
+ 'members.shareTrip': '旅行を共有',
+ 'members.inviteUser': 'ユーザーを招待',
+ 'members.selectUser': 'ユーザーを選択…',
+ 'members.invite': '招待',
+ 'members.allHaveAccess': 'すでに全員がアクセスできます。',
+ 'members.access': 'アクセス',
+ 'members.person': '人',
+ 'members.persons': '人',
+ 'members.you': 'あなた',
+ 'members.owner': 'オーナー',
+ 'members.leaveTrip': '旅行を退出',
+ 'members.removeAccess': 'アクセスを削除',
+ 'members.confirmLeave': '旅行を退出しますか?アクセスできなくなります。',
+ 'members.confirmRemove': 'このユーザーのアクセスを削除しますか?',
+ 'members.loadError': 'メンバーの読み込みに失敗しました',
+ 'members.added': '追加しました',
+ 'members.addError': '追加に失敗しました',
+ 'members.removed': 'メンバーを削除しました',
+ 'members.removeError': '削除に失敗しました',
+
+// Categories (Admin)
+ 'categories.title': 'カテゴリ',
+ 'categories.subtitle': '場所のカテゴリを管理',
+ 'categories.new': '新しいカテゴリ',
+ 'categories.empty': 'カテゴリはまだありません',
+ 'categories.namePlaceholder': 'カテゴリ名',
+ 'categories.icon': 'アイコン',
+ 'categories.color': '色',
+ 'categories.customColor': 'カスタムカラーを選択',
+ 'categories.preview': 'プレビュー',
+ 'categories.defaultName': 'カテゴリ',
+ 'categories.update': '更新',
+ 'categories.create': '作成',
+ 'categories.confirm.delete': 'カテゴリを削除しますか?このカテゴリの場所は削除されません。',
+ 'categories.toast.loadError': 'カテゴリの読み込みに失敗しました',
+ 'categories.toast.nameRequired': '名前を入力してください',
+ 'categories.toast.updated': 'カテゴリを更新しました',
+ 'categories.toast.created': 'カテゴリを作成しました',
+ 'categories.toast.saveError': '保存に失敗しました',
+ 'categories.toast.deleted': 'カテゴリを削除しました',
+ 'categories.toast.deleteError': '削除に失敗しました',
+
+// Backup (Admin)
+ 'backup.title': 'データバックアップ',
+ 'backup.subtitle': 'データベースとアップロードされたすべてのファイル',
+ 'backup.refresh': '更新',
+ 'backup.upload': 'バックアップをアップロード',
+ 'backup.uploading': 'アップロード中…',
+ 'backup.create': 'バックアップを作成',
+ 'backup.creating': '作成中…',
+ 'backup.empty': 'バックアップはまだありません',
+ 'backup.createFirst': '最初のバックアップを作成',
+ 'backup.download': 'ダウンロード',
+ 'backup.restore': '復元',
+ 'backup.confirm.restore': 'バックアップ「{name}」を復元しますか?\n\n現在のすべてのデータはバックアップで置き換えられます。',
+ 'backup.confirm.uploadRestore': 'バックアップファイル「{name}」をアップロードして復元しますか?\n\n現在のすべてのデータは上書きされます。',
+ 'backup.confirm.delete': 'バックアップ「{name}」を削除しますか?',
+ 'backup.toast.loadError': 'バックアップの読み込みに失敗しました',
+ 'backup.toast.created': 'バックアップを作成しました',
+ 'backup.toast.createError': 'バックアップの作成に失敗しました',
+ 'backup.toast.restored': 'バックアップを復元しました。ページを再読み込みします…',
+ 'backup.toast.restoreError': '復元に失敗しました',
+ 'backup.toast.uploadError': 'アップロードに失敗しました',
+ 'backup.toast.deleted': 'バックアップを削除しました',
+ 'backup.toast.deleteError': '削除に失敗しました',
+ 'backup.toast.downloadError': 'ダウンロードに失敗しました',
+ 'backup.toast.settingsSaved': '自動バックアップ設定を保存しました',
+ 'backup.toast.settingsError': '設定の保存に失敗しました',
+ 'backup.auto.title': '自動バックアップ',
+ 'backup.auto.subtitle': 'スケジュールに基づいて自動実行',
+ 'backup.auto.enable': '自動バックアップを有効化',
+ 'backup.auto.enableHint': '選択したスケジュールで自動的に作成されます',
+ 'backup.auto.interval': '間隔',
+ 'backup.auto.hour': '実行時刻',
+ 'backup.auto.hourHint': 'サーバーのローカル時刻({format}形式)',
+ 'backup.auto.dayOfWeek': '曜日',
+ 'backup.auto.dayOfMonth': '月の日',
+ 'backup.auto.dayOfMonthHint': 'すべての月に対応するため1~28に制限されています',
+ 'backup.auto.scheduleSummary': 'スケジュール',
+ 'backup.auto.summaryDaily': '毎日 {hour}:00',
+ 'backup.auto.summaryWeekly': '毎週{day} {hour}:00',
+ 'backup.auto.summaryMonthly': '毎月{day}日 {hour}:00',
+ 'backup.auto.envLocked': 'Docker',
+ 'backup.auto.envLockedHint': '自動バックアップはDockerの環境変数で設定されています。変更するにはdocker-compose.ymlを更新し、コンテナを再起動してください。',
+ 'backup.auto.copyEnv': 'Docker環境変数をコピー',
+ 'backup.auto.envCopied': 'Docker環境変数をコピーしました',
+ 'backup.auto.keepLabel': '古いバックアップを削除',
+ 'backup.dow.sunday': '日',
+ 'backup.dow.monday': '月',
+ 'backup.dow.tuesday': '火',
+ 'backup.dow.wednesday': '水',
+ 'backup.dow.thursday': '木',
+ 'backup.dow.friday': '金',
+ 'backup.dow.saturday': '土',
+ 'backup.interval.hourly': '毎時間',
+ 'backup.interval.daily': '毎日',
+ 'backup.interval.weekly': '毎週',
+ 'backup.interval.monthly': '毎月',
+ 'backup.keep.1day': '1日',
+ 'backup.keep.3days': '3日',
+ 'backup.keep.7days': '7日',
+ 'backup.keep.14days': '14日',
+ 'backup.keep.30days': '30日',
+ 'backup.keep.forever': '無期限',
+
+// Photos
+ 'photos.title': '写真',
+ 'photos.subtitle': '{trip} の写真 {count} 枚',
+ 'photos.dropHere': 'ここに写真をドロップ…',
+ 'photos.dropHereActive': 'ここに写真をドロップ',
+ 'photos.captionForAll': 'キャプション(全体)',
+ 'photos.captionPlaceholder': '任意のキャプション…',
+ 'photos.addCaption': 'キャプションを追加…',
+ 'photos.allDays': 'すべての日',
+ 'photos.noPhotos': 'まだ写真はありません',
+ 'photos.uploadHint': '旅行の写真をアップロード',
+ 'photos.clickToSelect': 'またはクリックして選択',
+ 'photos.linkPlace': '場所を紐づけ',
+ 'photos.noPlace': '場所なし',
+ 'photos.uploadN': '{n} 枚の写真をアップロード',
+ 'photos.linkDay': '日を紐づけ',
+ 'photos.noDay': '日付なし',
+ 'photos.dayLabel': '{number}日目',
+ 'photos.photoSelected': '写真を選択しました',
+ 'photos.photosSelected': '写真を選択しました',
+ 'photos.fileTypeHint': 'JPG、PNG、WebP · 最大 10 MB · 最大 30 枚',
+
+// Backup restore modal
+ 'backup.restoreConfirmTitle': 'バックアップを復元しますか?',
+ 'backup.restoreWarning': '現在のすべてのデータ(旅行、場所、ユーザー、アップロード)はバックアップで完全に置き換えられます。この操作は元に戻せません。',
+ 'backup.restoreTip': 'ヒント:復元前に現在の状態をバックアップすることをおすすめします。',
+ 'backup.restoreConfirm': 'はい、復元します',
+
+// PDF
+ 'pdf.travelPlan': '旅行計画',
+ 'pdf.planned': '予定',
+ 'pdf.costLabel': '費用(EUR)',
+ 'pdf.preview': 'PDFプレビュー',
+ 'pdf.saveAsPdf': 'PDFとして保存',
+
+ // Planner
+ 'planner.places': '場所',
+ 'planner.bookings': '予約',
+ 'planner.packingList': '持ち物リスト',
+ 'planner.documents': 'ドキュメント',
+ 'planner.dayPlan': '日別計画',
+ 'planner.reservations': '予約',
+ 'planner.minTwoPlaces': '座標付きの場所が少なくとも2つ必要です',
+ 'planner.noGeoPlaces': '座標付きの場所がありません',
+ 'planner.routeCalculated': 'ルートを計算しました',
+ 'planner.routeCalcFailed': 'ルートを計算できませんでした',
+ 'planner.routeError': 'ルート計算中にエラーが発生しました',
+ 'planner.icsExportFailed': 'ICSの書き出しに失敗しました',
+ 'planner.routeOptimized': 'ルートを最適化しました',
+ 'planner.reservationUpdated': '予約を更新しました',
+ 'planner.reservationAdded': '予約を追加しました',
+ 'planner.confirmDeleteReservation': '予約を削除しますか?',
+ 'planner.reservationDeleted': '予約を削除しました',
+ 'planner.days': '日',
+ 'planner.allPlaces': 'すべての場所',
+ 'planner.totalPlaces': '合計{n}件の場所',
+ 'planner.noDaysPlanned': '計画された日がありません',
+ 'planner.editTrip': '旅行を編集 →',
+ 'planner.placeOne': '1件の場所',
+ 'planner.placeN': '{n}件の場所',
+ 'planner.addNote': 'メモを追加',
+ 'planner.noEntries': 'この日の予定はありません',
+ 'planner.addPlace': '場所/アクティビティを追加',
+ 'planner.addPlaceShort': '+ 場所/アクティビティ',
+ 'planner.resPending': '予約保留 · ',
+ 'planner.resConfirmed': '予約確定 · ',
+ 'planner.notePlaceholder': 'メモ…',
+ 'planner.noteTimePlaceholder': '時刻(任意)',
+ 'planner.noteExamplePlaceholder': '例:中央駅から14:30発のS3、7番桟橋からフェリー、昼食休憩…',
+ 'planner.totalCost': '合計費用',
+ 'planner.searchPlaces': '場所を検索…',
+ 'planner.allCategories': 'すべてのカテゴリ',
+ 'planner.noPlacesFound': '場所が見つかりません',
+ 'planner.addFirstPlace': '最初の場所を追加',
+ 'planner.noReservations': '予約はありません',
+ 'planner.addFirstReservation': '最初の予約を追加',
+ 'planner.new': '新規',
+ 'planner.addToDay': '+ 日',
+ 'planner.calculating': '計算中…',
+ 'planner.route': 'ルート',
+ 'planner.optimize': '最適化',
+ 'planner.openGoogleMaps': 'Googleマップで開く',
+ 'planner.selectDayHint': '左の一覧から日を選択すると、日別計画が表示されます',
+ 'planner.noPlacesForDay': 'この日の場所はまだありません',
+ 'planner.addPlacesLink': '場所を追加 →',
+ 'planner.minTotal': '最短合計',
+ 'planner.noReservation': '予約なし',
+ 'planner.removeFromDay': 'この日から削除',
+ 'planner.addToThisDay': 'この日に追加',
+ 'planner.overview': '概要',
+ 'planner.noDays': '日がありません',
+ 'planner.editTripToAddDays': '旅行を編集して日を追加',
+ 'planner.dayCount': '{n}日間',
+ 'planner.clickToUnlock': 'クリックして解除',
+ 'planner.keepPosition': '最適化中も位置を保持',
+ 'planner.dayDetails': '日詳細',
+ 'planner.dayN': '{n}日目',
+
+// Dashboard Stats
+ 'stats.countries': '国',
+ 'stats.cities': '都市',
+ 'stats.trips': '旅行',
+ 'stats.places': '場所',
+ 'stats.worldProgress': '世界進捗',
+ 'stats.visited': '訪問済み',
+ 'stats.remaining': '未訪問',
+ 'stats.visitedCountries': '訪問国',
+
+// Day Detail Panel
+ 'day.precipProb': '降水確率',
+ 'day.precipitation': '降水量',
+ 'day.wind': '風',
+ 'day.sunrise': '日の出',
+ 'day.sunset': '日の入り',
+ 'day.hourlyForecast': '時間別予報',
+ 'day.climateHint': '過去の平均値 — 実際の予報はこの日付の16日前から表示されます。',
+ 'day.noWeather': '天気データがありません。座標付きの場所を追加してください。',
+ 'day.overview': '1日の概要',
+ 'day.accommodation': '宿泊先',
+ 'day.addAccommodation': '宿泊先を追加',
+ 'day.hotelDayRange': '適用日',
+ 'day.noPlacesForHotel': '先に旅行に場所を追加してください',
+ 'day.allDays': 'すべて',
+ 'day.checkIn': 'チェックイン',
+ 'day.checkInUntil': 'チェックイン期限',
+ 'day.checkOut': 'チェックアウト',
+ 'day.confirmation': '確認',
+ 'day.editAccommodation': '宿泊先を編集',
+ 'day.reservations': '予約',
+
+// Photos / Immich
+ 'memories.title': '写真',
+ 'memories.notConnected': '{provider_name} が接続されていません',
+ 'memories.notConnectedHint': '設定で {provider_name} インスタンスを接続すると、この旅行に写真を追加できます。',
+ 'memories.notConnectedMultipleHint': '設定で次の写真プロバイダーのいずれかを接続してください:{provider_names}',
+ 'memories.noDates': '写真を読み込むには旅行の日付を追加してください。',
+ 'memories.noPhotos': '写真が見つかりません',
+ 'memories.noPhotosHint': '{provider_name} にこの旅行期間の写真がありません。',
+ 'memories.photosFound': '枚の写真',
+ 'memories.fromOthers': '他のユーザーから',
+ 'memories.sharePhotos': '写真を共有',
+ 'memories.sharing': '共有',
+ 'memories.reviewTitle': '写真を確認',
+ 'memories.reviewHint': 'クリックして共有から除外できます。',
+ 'memories.shareCount': '{count}枚の写真を共有',
+ //-------------------------
+ //todo section
+ 'memories.providerUrl': 'サーバーURL',
+ 'memories.providerApiKey': 'APIキー',
+ 'memories.providerUsername': 'ユーザー名',
+ 'memories.providerPassword': 'パスワード',
+ 'memories.providerOTP': 'MFAコード(有効な場合)',
+ 'memories.skipSSLVerification': 'SSL証明書の検証をスキップ',
+ 'memories.immichAutoUpload': 'アップロード時に旅程の写真をImmichにミラー',
+ 'memories.providerUrlHintSynology': 'URLにPhotosアプリのパスを含めてください(例:https://nas:5001/photo)',
+ 'memories.testConnection': '接続をテスト',
+ 'memories.testFirst': '先に接続をテストしてください',
+ 'memories.testShort': 'テスト',
+ 'memories.connected': '接続済み',
+ 'memories.disconnected': '未接続',
+ 'memories.connectionSuccess': '{provider_name} に接続しました',
+ 'memories.connectionError': '{provider_name} に接続できませんでした',
+ 'memories.saved': '{provider_name} の設定を保存しました',
+ 'memories.providerDisconnectedBanner': '{provider_name} との接続が切れています。写真を見るには設定で再接続してください。',
+ 'memories.saveError': '{provider_name} の設定を保存できませんでした',
+ //------------------------
+ 'memories.addPhotos': '写真を追加',
+ 'memories.linkAlbum': 'アルバムをリンク',
+ 'memories.selectAlbum': '{provider_name} のアルバムを選択',
+ 'memories.selectAlbumMultiple': 'アルバムを選択',
+ 'memories.noAlbums': 'アルバムが見つかりません',
+ 'memories.syncAlbum': 'アルバムを同期',
+ 'memories.unlinkAlbum': 'アルバムのリンクを解除',
+ 'memories.photos': '写真',
+ 'memories.selectPhotos': '{provider_name} から写真を選択',
+ 'memories.selectPhotosMultiple': '写真を選択',
+ 'memories.selectHint': '写真をタップして選択してください。',
+ 'memories.selected': '選択済み',
+ 'memories.addSelected': '{count} 枚の写真を追加',
+ 'memories.alreadyAdded': '追加済み',
+ 'memories.private': '非公開',
+ 'memories.stopSharing': '共有を停止',
+ 'memories.oldest': '古い順',
+ 'memories.newest': '新しい順',
+ 'memories.allLocations': 'すべての場所',
+ 'memories.tripDates': '旅行期間',
+ 'memories.allPhotos': 'すべての写真',
+ 'memories.confirmShareTitle': '旅行メンバーと共有しますか?',
+ 'memories.confirmShareHint': '{count} 枚の写真がこの旅行の全メンバーに表示されます。後から個別に非公開にできます。',
+ 'memories.confirmShareButton': '写真を共有',
+ 'memories.error.loadAlbums': 'アルバムの読み込みに失敗しました',
+ 'memories.error.linkAlbum': 'アルバムのリンクに失敗しました',
+ 'memories.error.unlinkAlbum': 'アルバムのリンク解除に失敗しました',
+ 'memories.error.syncAlbum': 'アルバムの同期に失敗しました',
+ 'memories.error.loadPhotos': '写真の読み込みに失敗しました',
+ 'memories.error.addPhotos': '写真の追加に失敗しました',
+ 'memories.error.removePhoto': '写真の削除に失敗しました',
+ 'memories.error.toggleSharing': '共有設定の更新に失敗しました',
+ 'memories.saveRouteNotConfigured': 'このプロバイダーでは保存先が設定されていません',
+ 'memories.testRouteNotConfigured': 'このプロバイダーではテスト用の保存先が設定されていません',
+ 'memories.fillRequiredFields': '必須項目をすべて入力してください',
+
+ // Collab Addon
+ 'collab.tabs.chat': 'チャット',
+ 'collab.tabs.notes': 'ノート',
+ 'collab.tabs.polls': '投票',
+'collab.whatsNext.title': '次にすること',
+ 'collab.whatsNext.today': '今日',
+ 'collab.whatsNext.tomorrow': '明日',
+ 'collab.whatsNext.empty': '予定されたアクティビティはありません',
+ 'collab.whatsNext.until': '〜',
+ 'collab.whatsNext.emptyHint': '時間が設定されたアクティビティがここに表示されます',
+ 'collab.chat.send': '送信',
+ 'collab.chat.placeholder': 'メッセージを入力…',
+ 'collab.chat.empty': '会話を始めましょう',
+ 'collab.chat.emptyHint': 'メッセージは旅行メンバー全員と共有されます',
+ 'collab.chat.emptyDesc': 'アイデアや計画、最新情報を共有しましょう',
+ 'collab.chat.today': '今日',
+ 'collab.chat.yesterday': '昨日',
+ 'collab.chat.deletedMessage': 'メッセージを削除しました',
+ 'collab.chat.reply': '返信',
+ 'collab.chat.loadMore': '以前のメッセージを読み込む',
+ 'collab.chat.justNow': 'たった今',
+ 'collab.chat.minutesAgo': '{n}分前',
+ 'collab.chat.hoursAgo': '{n}時間前',
+ 'collab.notes.title': 'ノート',
+ 'collab.notes.new': '新規ノート',
+ 'collab.notes.empty': 'まだノートがありません',
+ 'collab.notes.emptyHint': 'アイデアや計画を書き留めましょう',
+ 'collab.notes.all': 'すべて',
+ 'collab.notes.titlePlaceholder': 'ノートのタイトル',
+ 'collab.notes.contentPlaceholder': '内容を入力…',
+ 'collab.notes.categoryPlaceholder': 'カテゴリ',
+ 'collab.notes.newCategory': '新しいカテゴリ…',
+ 'collab.notes.category': 'カテゴリ',
+ 'collab.notes.noCategory': 'カテゴリなし',
+ 'collab.notes.color': '色',
+ 'collab.notes.save': '保存',
+ 'collab.notes.cancel': 'キャンセル',
+ 'collab.notes.edit': '編集',
+ 'collab.notes.delete': '削除',
+ 'collab.notes.pin': '固定',
+ 'collab.notes.unpin': '固定を解除',
+ 'collab.notes.daysAgo': '{n}日前',
+ 'collab.notes.categorySettings': 'カテゴリ管理',
+ 'collab.notes.create': '作成',
+ 'collab.notes.website': 'ウェブサイト',
+ 'collab.notes.websitePlaceholder': 'https://...',
+ 'collab.notes.attachFiles': 'ファイルを添付',
+ 'collab.notes.noCategoriesYet': 'カテゴリがまだありません',
+ 'collab.notes.emptyDesc': 'ノートを作成して始めましょう',
+ 'collab.polls.title': '投票',
+ 'collab.polls.new': '新しい投票',
+ 'collab.polls.empty': 'まだ投票がありません',
+ 'collab.polls.emptyHint': '質問してみんなで投票しましょう',
+ 'collab.polls.question': '質問',
+ 'collab.polls.questionPlaceholder': '何をしますか?',
+ 'collab.polls.addOption': '+ 選択肢を追加',
+ 'collab.polls.optionPlaceholder': '選択肢 {n}',
+ 'collab.polls.create': '投票を作成',
+ 'collab.polls.close': '閉じる',
+ 'collab.polls.closed': '終了',
+ 'collab.polls.votes': '{n}票',
+ 'collab.polls.vote': '{n}票',
+ 'collab.polls.multipleChoice': '複数選択',
+ 'collab.polls.multiChoice': '複数選択',
+ 'collab.polls.deadline': '締切',
+ 'collab.polls.option': '選択肢',
+ 'collab.polls.options': '選択肢',
+ 'collab.polls.delete': '削除',
+ 'collab.polls.closedSection': '終了',
+
+ // Permissions
+ 'admin.tabs.permissions': '権限',
+ 'perm.title': '権限設定',
+ 'perm.subtitle': 'アプリ全体の操作権限を管理',
+ 'perm.saved': '権限設定を保存しました',
+ 'perm.resetDefaults': '既定に戻す',
+ 'perm.customized': 'カスタマイズ済み',
+ 'perm.level.admin': '管理者のみ',
+ 'perm.level.tripOwner': '旅行オーナー',
+ 'perm.level.tripMember': '旅行メンバー',
+ 'perm.level.everybody': '全員',
+ 'perm.cat.trip': '旅行管理',
+ 'perm.cat.members': 'メンバー管理',
+ 'perm.cat.files': 'ファイル',
+ 'perm.cat.content': 'コンテンツと予定',
+ 'perm.cat.extras': '予算・持ち物・コラボ',
+ 'perm.action.trip_create': '旅行を作成',
+ 'perm.action.trip_edit': '旅行詳細を編集',
+ 'perm.action.trip_delete': '旅行を削除',
+ 'perm.action.trip_archive': '旅行をアーカイブ/復元',
+ 'perm.action.trip_cover_upload': 'カバー画像をアップロード',
+ 'perm.action.member_manage': 'メンバーを追加/削除',
+ 'perm.action.file_upload': 'ファイルをアップロード',
+ 'perm.action.file_edit': 'ファイル情報を編集',
+ 'perm.action.file_delete': 'ファイルを削除',
+ 'perm.action.place_edit': '場所を追加/編集/削除',
+ 'perm.action.day_edit': '日・メモ・割り当てを編集',
+ 'perm.action.reservation_edit': '予約を管理',
+ 'perm.action.budget_edit': '予算を管理',
+ 'perm.action.packing_edit': '持ち物を管理',
+ 'perm.action.collab_edit': 'コラボ(メモ・投票・チャット)',
+ 'perm.action.share_manage': '共有リンクを管理',
+ 'perm.actionHint.trip_create': '新しい旅行を作成できる人',
+ 'perm.actionHint.trip_edit': '旅行名や日付などを変更できる人',
+ 'perm.actionHint.trip_delete': '旅行を完全に削除できる人',
+ 'perm.actionHint.trip_archive': '旅行をアーカイブできる人',
+ 'perm.actionHint.trip_cover_upload': 'カバー画像を変更できる人',
+ 'perm.actionHint.member_manage': 'メンバーを招待/削除できる人',
+ 'perm.actionHint.file_upload': 'ファイルをアップロードできる人',
+ 'perm.actionHint.file_edit': 'ファイル説明やリンクを編集できる人',
+ 'perm.actionHint.file_delete': 'ファイルをゴミ箱へ移動/完全削除できる人',
+ 'perm.actionHint.place_edit': '場所を追加・編集・削除できる人',
+ 'perm.actionHint.day_edit': '日やメモ、割り当てを編集できる人',
+ 'perm.actionHint.reservation_edit': '予約を作成・編集・削除できる人',
+ 'perm.actionHint.budget_edit': '予算項目を管理できる人',
+ 'perm.actionHint.packing_edit': '持ち物やバッグを管理できる人',
+ 'perm.actionHint.collab_edit': 'メモや投票、メッセージを作成できる人',
+ 'perm.actionHint.share_manage': '公開共有リンクを管理できる人',
+
+ // Undo
+ 'undo.button': '元に戻す',
+ 'undo.tooltip': '元に戻す: {action}',
+ 'undo.assignPlace': '場所を日に割り当て',
+ 'undo.removeAssignment': '日の割り当てを解除',
+ 'undo.reorder': '場所を並び替え',
+ 'undo.optimize': 'ルートを最適化',
+ 'undo.deletePlace': '場所を削除',
+ 'undo.deletePlaces': '場所を削除',
+ 'undo.moveDay': '場所を別の日に移動',
+ 'undo.lock': '場所のロックを切り替え',
+ 'undo.importGpx': 'GPXをインポート',
+ 'undo.importKeyholeMarkup': 'KMZ/KMLをインポート',
+ 'undo.importGoogleList': 'Googleマップをインポート',
+ 'undo.importNaverList': 'Naverマップをインポート',
+ 'undo.addPlace': '場所を追加',
+ 'undo.done': '元に戻しました: {action}',
+
+ // Notifications
+ 'notifications.title': '通知',
+ 'notifications.markAllRead': 'すべて既読',
+ 'notifications.deleteAll': 'すべて削除',
+ 'notifications.showAll': 'すべて表示',
+ 'notifications.empty': '通知はありません',
+ 'notifications.emptyDescription': 'すべて確認済みです!',
+ 'notifications.all': 'すべて',
+ 'notifications.unreadOnly': '未読',
+ 'notifications.markRead': '既読にする',
+ 'notifications.markUnread': '未読にする',
+ 'notifications.delete': '削除',
+ 'notifications.system': 'システム',
+ 'notifications.synologySessionCleared.title': 'Synology Photosが切断されました',
+ 'notifications.synologySessionCleared.text': 'サーバーまたはアカウントが変更されました。設定で接続を再テストしてください。',
+
+ // Notification test keys (dev only)
+ 'notifications.versionAvailable.title': '更新があります',
+ 'notifications.versionAvailable.text': 'TREK {version} が利用可能です。',
+ 'notifications.versionAvailable.button': '詳細を見る',
+ 'notifications.test.title': '{actor} からのテスト通知',
+ 'notifications.test.text': 'これはテスト通知です。',
+ 'notifications.test.booleanTitle': '{actor} が承認を求めています',
+ 'notifications.test.booleanText': 'テスト用の承認通知です。',
+ 'notifications.test.accept': '承認',
+ 'notifications.test.decline': '却下',
+ 'notifications.test.navigateTitle': '確認してください',
+ 'notifications.test.navigateText': 'テスト用の遷移通知です。',
+ 'notifications.test.goThere': '移動',
+ 'notifications.test.adminTitle': '管理者通知',
+ 'notifications.test.adminText': '{actor} が管理者全員に通知を送りました。',
+ 'notifications.test.tripTitle': '{actor} が旅行に投稿しました',
+ 'notifications.test.tripText': '旅行「{trip}」のテスト通知です。',
+
+ // Todo
+ 'todo.subtab.packing': '持ち物リスト',
+ 'todo.subtab.todo': 'ToDo',
+ 'todo.completed': '完了',
+ 'todo.filter.all': 'すべて',
+ 'todo.filter.open': '未完了',
+ 'todo.filter.done': '完了',
+ 'todo.uncategorized': '未分類',
+ 'todo.namePlaceholder': 'タスク名',
+ 'todo.descriptionPlaceholder': '説明(任意)',
+ 'todo.unassigned': '未割り当て',
+ 'todo.noCategory': 'カテゴリなし',
+ 'todo.hasDescription': '説明あり',
+ 'todo.addItem': '新しいタスクを追加...',
+ 'todo.sidebar.sortBy': '並び替え',
+ 'todo.priority': '優先度',
+ 'todo.newCategoryLabel': '新規',
+ 'budget.categoriesLabel': 'カテゴリ',
+ 'todo.newCategory': 'カテゴリ名',
+ 'todo.addCategory': 'カテゴリを追加',
+ 'todo.newItem': '新しいタスク',
+ 'todo.empty': 'タスクはまだありません。追加して始めましょう!',
+ 'todo.filter.my': '自分のタスク',
+ 'todo.filter.overdue': '期限切れ',
+ 'todo.sidebar.tasks': 'タスク',
+ 'todo.sidebar.categories': 'カテゴリ',
+ 'todo.detail.title': 'タスク',
+ 'todo.detail.description': '説明',
+ 'todo.detail.category': 'カテゴリ',
+ 'todo.detail.dueDate': '期限',
+ 'todo.detail.assignedTo': '担当者',
+ 'todo.detail.delete': '削除',
+ 'todo.detail.save': '変更を保存',
+ 'todo.sortByPrio': '優先度',
+ 'todo.detail.priority': '優先度',
+ 'todo.detail.noPriority': 'なし',
+ 'todo.detail.create': 'タスクを作成',
+
+ // Notifications — dev test events
+ 'notif.test.title': '[テスト] 通知',
+ 'notif.test.simple.text': 'これはシンプルなテスト通知です。',
+ 'notif.test.boolean.text': 'このテスト通知を承認しますか?',
+ 'notif.test.navigate.text': '下をクリックしてダッシュボードに移動してください。',
+
+ // Notifications
+ 'notif.trip_invite.title': '旅行への招待',
+ 'notif.trip_invite.text': '{actor}が「{trip}」に招待しました',
+ 'notif.booking_change.title': '予約が更新されました',
+ 'notif.booking_change.text': '{actor}が「{trip}」の予約を更新しました',
+ 'notif.trip_reminder.title': '旅行リマインド',
+ 'notif.trip_reminder.text': '旅行「{trip}」がまもなく始まります!',
+ 'notif.todo_due.title': 'ToDoの期限',
+ 'notif.todo_due.text': '「{trip}」の{todo}は{due}が期限です',
+ 'notif.vacay_invite.title': 'Vacay Fusionへの招待',
+ 'notif.vacay_invite.text': '{actor}から旅行プランの統合に招待されました',
+ 'notif.photos_shared.title': '写真が共有されました',
+ 'notif.photos_shared.text': '{actor}が「{trip}」で{count}枚の写真を共有しました',
+ 'notif.collab_message.title': '新しいメッセージ',
+ 'notif.collab_message.text': '{actor}が「{trip}」でメッセージを送りました',
+ 'notif.packing_tagged.title': '持ち物の割り当て',
+ 'notif.packing_tagged.text': '{actor}が「{trip}」の{category}をあなたに割り当てました',
+ 'notif.version_available.title': '新しいバージョンがあります',
+ 'notif.version_available.text': 'TREK {version}が利用可能です',
+ 'notif.action.view_trip': '旅行を見る',
+ 'notif.action.view_collab': 'メッセージを見る',
+ 'notif.action.view_packing': '持ち物を見る',
+ 'notif.action.view_photos': '写真を見る',
+ 'notif.action.view_vacay': 'Vacayを見る',
+ 'notif.action.view_admin': '管理画面へ',
+ 'notif.action.view': '表示',
+ 'notif.action.accept': '承認',
+ 'notif.action.decline': '拒否',
+ 'notif.generic.title': '通知',
+ 'notif.generic.text': '新しい通知があります',
+ 'notif.dev.unknown_event.title': '[DEV] 不明なイベント',
+ 'notif.dev.unknown_event.text': 'イベントタイプ「{event}」はEVENT_NOTIFICATION_CONFIGに登録されていません',
+
+// Journey addon
+ 'journey.search.placeholder': '日記を検索…',
+ 'journey.search.noResults': '「{query}」に一致する日記はありません',
+ 'journey.title': '日記',
+ 'journey.subtitle': '旅の記録をリアルタイムで残そう',
+ 'journey.new': '新しい日記',
+ 'journey.create': '作成',
+ 'journey.titlePlaceholder': 'どこへ行きますか?',
+ 'journey.empty': '日記はまだありません',
+ 'journey.emptyHint': '次の旅を記録してみましょう',
+ 'journey.deleted': '日記を削除しました',
+ 'journey.createError': '日記を作成できませんでした',
+ 'journey.deleteError': '日記を削除できませんでした',
+ 'journey.deleteConfirmTitle': '削除',
+ 'journey.deleteConfirmMessage': '「{title}」を削除しますか?元に戻せません。',
+ 'journey.deleteConfirmGeneric': '本当に削除しますか?',
+ 'journey.notFound': '日記が見つかりません',
+ 'journey.photos': '写真',
+ 'journey.timelineEmpty': 'まだ立ち寄りがありません',
+ 'journey.timelineEmptyHint': 'チェックインするか、日記を書いて始めましょう',
+ 'journey.status.draft': '下書き',
+ 'journey.status.active': '進行中',
+ 'journey.status.completed': '完了',
+ 'journey.status.upcoming': '予定',
+ 'journey.status.archived': 'アーカイブ',
+ 'journey.checkin.add': 'チェックイン',
+ 'journey.checkin.namePlaceholder': '場所名',
+ 'journey.checkin.notesPlaceholder': 'メモ(任意)',
+ 'journey.checkin.save': '保存',
+ 'journey.checkin.error': 'チェックインを保存できませんでした',
+ 'journey.entry.add': '日記',
+ 'journey.entry.edit': '編集',
+ 'journey.entry.titlePlaceholder': 'タイトル(任意)',
+ 'journey.entry.bodyPlaceholder': '今日は何がありましたか?',
+ 'journey.entry.save': '保存',
+ 'journey.entry.error': '日記を保存できませんでした',
+ 'journey.photo.add': '写真',
+ 'journey.photo.uploadError': 'アップロードに失敗しました',
+ 'journey.share.share': '共有',
+ 'journey.share.public': '公開',
+ 'journey.share.linkCopied': '公開リンクをコピーしました',
+ 'journey.share.disabled': '公開共有は無効です',
+ 'journey.editor.titlePlaceholder': 'この瞬間に名前をつけて…',
+ 'journey.editor.bodyPlaceholder': 'この日のストーリーを書いてみよう…',
+ 'journey.editor.placePlaceholder': '場所(任意)',
+ 'journey.editor.tagsPlaceholder': 'タグ:穴場、最高の食事、また行きたい…',
+ 'journey.visibility.private': '非公開',
+ 'journey.visibility.shared': '共有',
+ 'journey.visibility.public': '公開',
+ 'journey.emptyState.title': 'ここから物語が始まります',
+ 'journey.emptyState.subtitle': '場所にチェックインするか、最初の日記を書いてみましょう',
+
+// Journey Frontpage
+ 'journey.frontpage.subtitle': '旅を、忘れられない物語に',
+ 'journey.frontpage.createJourney': '日記を作成',
+ 'journey.frontpage.activeJourney': '進行中の日記',
+ 'journey.frontpage.allJourneys': 'すべての日記',
+ 'journey.frontpage.journeys': '日記',
+ 'journey.frontpage.createNew': '新しい日記を作成',
+ 'journey.frontpage.createNewSub': '旅を選んで、物語を書き、共有しよう',
+ 'journey.frontpage.live': 'ライブ',
+ 'journey.frontpage.synced': '同期済み',
+ 'journey.frontpage.continueWriting': '続けて書く',
+ 'journey.frontpage.updated': '{time}に更新',
+ 'journey.frontpage.suggestionLabel': '旅行が終了しました',
+ 'journey.frontpage.suggestionText': '{title} を日記にしよう',
+ 'journey.frontpage.dismiss': '閉じる',
+ 'journey.frontpage.journeyName': '日記名',
+ 'journey.frontpage.namePlaceholder': '例:東南アジア 2026',
+ 'journey.frontpage.selectTrips': '旅行を選択',
+ 'journey.frontpage.tripsSelected': '件選択',
+ 'journey.frontpage.trips': '旅行',
+ 'journey.frontpage.placesImported': '場所がインポートされます',
+ 'journey.frontpage.places': '場所',
+
+ // Journey Detail
+ 'journey.detail.backToJourney': '日記に戻る',
+ 'journey.detail.syncedWithTrips': '旅行と同期済み',
+ 'journey.detail.addEntry': 'エントリーを追加',
+ 'journey.detail.newEntry': '新しいエントリー',
+ 'journey.detail.editEntry': 'エントリーを編集',
+ 'journey.detail.noEntries': 'エントリーはまだありません',
+ 'journey.detail.noEntriesHint': '旅行を追加して下書きエントリーを作成しましょう',
+ 'journey.detail.noPhotos': '写真はまだありません',
+ 'journey.detail.noPhotosHint': 'エントリーに写真を追加するか、Immich/Synologyライブラリを表示',
+ 'journey.detail.journeyTab': '日記',
+ 'journey.detail.journeyStats': '統計',
+ 'journey.detail.syncedTrips': '同期中の旅行',
+ 'journey.detail.noTripsLinked': 'リンクされた旅行はありません',
+ 'journey.detail.contributors': '参加者',
+ 'journey.detail.readMore': 'もっと見る',
+ 'journey.detail.prosCons': '良かった点・気になった点',
+ 'journey.detail.photos': '写真',
+ 'journey.detail.day': '{number}日目',
+ 'journey.detail.places': '場所',
+
+// Journey Detail — Stats
+ 'journey.stats.days': '日数',
+ 'journey.stats.cities': '都市',
+ 'journey.stats.entries': 'エントリー',
+ 'journey.stats.photos': '写真',
+ 'journey.stats.places': '場所',
+ 'journey.skeletons.show': '提案を表示',
+ 'journey.skeletons.hide': '提案を非表示',
+
+// Journey Detail — Verdict
+ 'journey.verdict.lovedIt': '最高だった',
+ 'journey.verdict.couldBeBetter': '改善の余地あり',
+
+// Journey Detail — Synced badge
+ 'journey.synced.places': '場所',
+ 'journey.synced.synced': '同期済み',
+
+// Journey Entry Editor
+ 'journey.editor.discardChangesConfirm': '未保存の変更があります。破棄しますか?',
+ 'journey.editor.uploadPhotos': '写真をアップロード',
+ 'journey.editor.uploading': 'アップロード中…',
+ 'journey.editor.fromGallery': 'ギャラリーから',
+ 'journey.editor.allPhotosAdded': 'すべての写真は追加済みです',
+ 'journey.editor.writeStory': 'ストーリーを書く…',
+ 'journey.editor.prosCons': '良かった点・気になった点',
+ 'journey.editor.pros': '良かった点',
+ 'journey.editor.cons': '気になった点',
+ 'journey.editor.proPlaceholder': '良かったこと…',
+ 'journey.editor.conPlaceholder': 'いまいちだったこと…',
+ 'journey.editor.addAnother': '追加',
+ 'journey.editor.date': '日付',
+ 'journey.editor.location': '場所',
+ 'journey.editor.searchLocation': '場所を検索…',
+ 'journey.editor.mood': '気分',
+ 'journey.editor.weather': '天気',
+ 'journey.editor.photoFirst': '1番目',
+ 'journey.editor.makeFirst': '1番目にする',
+ 'journey.editor.searching': '検索中…',
+
+// Journey Entry — Moods
+ 'journey.mood.amazing': '最高',
+ 'journey.mood.good': '良い',
+ 'journey.mood.neutral': '普通',
+ 'journey.mood.rough': '大変',
+
+// Journey Entry — Weather
+ 'journey.weather.sunny': '晴れ',
+ 'journey.weather.partly': '晴れ時々くもり',
+ 'journey.weather.cloudy': 'くもり',
+ 'journey.weather.rainy': '雨',
+ 'journey.weather.stormy': '嵐',
+ 'journey.weather.cold': '雪',
+
+// Journey — Trip Linking
+ 'journey.trips.linkTrip': '旅行をリンク',
+ 'journey.trips.searchTrip': '旅行を検索',
+ 'journey.trips.searchPlaceholder': '旅行名または目的地…',
+ 'journey.trips.noTripsAvailable': '利用できる旅行がありません',
+ 'journey.trips.link': 'リンク',
+ 'journey.trips.tripLinked': '旅行をリンクしました',
+ 'journey.trips.linkFailed': 'リンクに失敗しました',
+ 'journey.trips.addTrip': '旅行を追加',
+ 'journey.trips.unlinkTrip': 'リンク解除',
+ 'journey.trips.unlinkMessage': '「{title}」のリンクを解除しますか?この旅行から同期されたエントリーと写真はすべて完全に削除されます。元に戻せません。',
+ 'journey.trips.unlink': '解除',
+ 'journey.trips.tripUnlinked': 'リンクを解除しました',
+ 'journey.trips.unlinkFailed': '解除に失敗しました',
+ 'journey.trips.noTripsLinkedSettings': 'リンクされた旅行はありません',
+
+// Journey — Contributors
+ 'journey.contributors.invite': '参加者を招待',
+ 'journey.contributors.searchUser': 'ユーザーを検索',
+ 'journey.contributors.searchPlaceholder': 'ユーザー名またはメール…',
+ 'journey.contributors.noUsers': 'ユーザーが見つかりません',
+ 'journey.contributors.role': '役割',
+ 'journey.contributors.added': '参加者を追加しました',
+ 'journey.contributors.addFailed': '追加に失敗しました',
+ 'journey.contributors.remove': '参加者を削除',
+ 'journey.contributors.removeConfirm': '{username}をこの日記から削除しますか?',
+ 'journey.contributors.removed': '参加者を削除しました',
+ 'journey.contributors.removeFailed': '削除に失敗しました',
+
+// Journey — Share
+ 'journey.share.publicShare': '公開共有',
+ 'journey.share.createLink': '共有リンクを作成',
+ 'journey.share.linkCreated': '共有リンクを作成しました',
+ 'journey.share.createFailed': 'リンク作成に失敗しました',
+ 'journey.share.copy': 'コピー',
+ 'journey.share.copied': 'コピーしました!',
+ 'journey.share.timeline': 'タイムライン',
+ 'journey.share.gallery': 'ギャラリー',
+ 'journey.share.map': 'マップ',
+ 'journey.share.removeLink': '共有リンクを削除',
+ 'journey.share.linkDeleted': '共有リンクを削除しました',
+ 'journey.share.deleteFailed': '削除に失敗しました',
+ 'journey.share.updateFailed': '更新に失敗しました',
+
+// Journey — Invite
+ 'journey.invite.role': '役割',
+ 'journey.invite.viewer': '閲覧者',
+ 'journey.invite.editor': '編集者',
+ 'journey.invite.invite': '招待',
+ 'journey.invite.inviting': '招待中…',
+
+// Journey — Settings Dialog
+ 'journey.settings.title': '日記設定',
+ 'journey.settings.coverImage': 'カバー画像',
+ 'journey.settings.changeCover': 'カバーを変更',
+ 'journey.settings.addCover': 'カバー画像を追加',
+ 'journey.settings.name': '名前',
+ 'journey.settings.subtitle': 'サブタイトル',
+ 'journey.settings.subtitlePlaceholder': '例:タイ・ベトナム・カンボジア',
+ 'journey.settings.endJourney': '日記をアーカイブ',
+ 'journey.settings.reopenJourney': '日記を復元',
+ 'journey.settings.archived': '日記をアーカイブしました',
+ 'journey.settings.reopened': '日記を復元しました',
+ 'journey.settings.endDescription': 'Liveバッジを非表示にします。いつでも再開できます。',
+ 'journey.settings.delete': '削除',
+ 'journey.settings.deleteJourney': '日記を削除',
+ 'journey.settings.deleteMessage': '「{title}」を削除しますか?すべてのエントリーと写真が失われます。',
+ 'journey.settings.saved': '設定を保存しました',
+ 'journey.settings.saveFailed': '保存に失敗しました',
+ 'journey.settings.coverUpdated': 'カバーを更新しました',
+ 'journey.settings.coverFailed': 'アップロードに失敗しました',
+ 'journey.settings.failedToDelete': '削除に失敗しました',
+ 'journey.entries.deleteTitle': 'エントリーを削除',
+ 'journey.photosUploaded': '{count}枚の写真をアップロード',
+ 'journey.photosAdded': '{count}枚の写真を追加',
+
+// Journey — Public Page
+ 'journey.public.notFound': '見つかりません',
+ 'journey.public.notFoundMessage': 'この日記は存在しないか、リンクの有効期限が切れています。',
+ 'journey.public.readOnly': '閲覧のみ · 公開日記',
+ 'journey.public.tagline': '旅の記録&探索キット',
+ 'journey.public.sharedVia': '共有元',
+ 'journey.public.madeWith': '作成:',
+
+// Journey — PDF Export
+ 'journey.pdf.journeyBook': '日記ブック',
+ 'journey.pdf.madeWith': 'Made with TREK',
+ 'journey.pdf.day': '日目',
+ 'journey.pdf.theEnd': 'おわり',
+ 'journey.pdf.saveAsPdf': 'PDFとして保存',
+ 'journey.pdf.pages': 'ページ',
+ 'journey.picker.tripPeriod': '旅行期間',
+ 'journey.picker.dateRange': '日付範囲',
+ 'journey.picker.allPhotos': 'すべての写真',
+ 'journey.picker.albums': 'アルバム',
+ 'journey.picker.selected': '選択中',
+ 'journey.picker.addTo': '追加先',
+ 'journey.picker.newGallery': '新しいギャラリー',
+ 'journey.picker.selectAll': 'すべて選択',
+ 'journey.picker.deselectAll': '選択解除',
+ 'journey.picker.noAlbums': 'アルバムがありません',
+ 'journey.picker.selectDate': '日付を選択',
+ 'journey.picker.search': '検索',
+
+// Dashboard Mobile
+ 'dashboard.greeting.morning': 'おはようございます、',
+ 'dashboard.greeting.afternoon': 'こんにちは、',
+ 'dashboard.greeting.evening': 'こんばんは、',
+ 'dashboard.mobile.liveNow': 'ライブ中',
+ 'dashboard.mobile.tripProgress': '旅行の進行状況',
+ 'dashboard.mobile.daysLeft': '残り{count}日',
+ 'dashboard.mobile.places': '場所',
+ 'dashboard.mobile.buddies': '仲間',
+ 'dashboard.mobile.newTrip': '新しい旅行',
+ 'dashboard.mobile.currency': '通貨',
+ 'dashboard.mobile.timezone': 'タイムゾーン',
+ 'dashboard.mobile.upcomingTrips': '今後の旅行',
+ 'dashboard.mobile.yourTrips': 'あなたの旅行',
+ 'dashboard.mobile.trips': '旅行',
+ 'dashboard.mobile.starts': '開始',
+ 'dashboard.mobile.duration': '期間',
+ 'dashboard.mobile.day': '日',
+ 'dashboard.mobile.days': '日',
+ 'dashboard.mobile.ongoing': '進行中',
+ 'dashboard.mobile.startsToday': '今日開始',
+ 'dashboard.mobile.tomorrow': '明日',
+ 'dashboard.mobile.inDays': '{count}日後',
+ 'dashboard.mobile.inMonths': '{count}か月後',
+ 'dashboard.mobile.completed': '完了',
+ 'dashboard.mobile.currencyConverter': '通貨換算',
+
+ // BottomNav & Profile
+ 'nav.profile': 'プロフィール',
+ 'nav.bottomSettings': '設定',
+ 'nav.bottomAdmin': '管理者設定',
+ 'nav.bottomLogout': 'ログアウト',
+ 'nav.bottomAdminBadge': '管理者',
+
+// DayPlan Mobile
+ 'dayplan.mobile.addPlace': '場所を追加',
+ 'dayplan.mobile.searchPlaces': '場所を検索…',
+ 'dayplan.mobile.allAssigned': 'すべて割り当て済み',
+ 'dayplan.mobile.noMatch': '一致なし',
+ 'dayplan.mobile.createNew': '新しい場所を作成',
+
+'admin.addons.catalog.journey.name': '日記',
+ 'admin.addons.catalog.journey.description': 'チェックイン、写真、日ごとのストーリーで旅を記録',
+
+// OAuth scope groups
+ 'oauth.scope.group.trips': '旅行',
+ 'oauth.scope.group.places': '場所',
+ 'oauth.scope.group.atlas': '地図',
+ 'oauth.scope.group.packing': '持ち物',
+ 'oauth.scope.group.todos': 'ToDo',
+ 'oauth.scope.group.budget': '予算',
+ 'oauth.scope.group.reservations': '予約',
+ 'oauth.scope.group.collab': 'コラボ',
+ 'oauth.scope.group.notifications': '通知',
+ 'oauth.scope.group.vacay': '休暇',
+ 'oauth.scope.group.geo': '地図',
+ 'oauth.scope.group.weather': '天気',
+ 'oauth.scope.group.journey': '日記',
+
+// OAuth scope labels & descriptions
+ 'oauth.scope.trips:read.label': '旅行・旅程を表示',
+ 'oauth.scope.trips:read.description': '旅行、日程、メモ、メンバーを閲覧',
+ 'oauth.scope.trips:write.label': '旅行・旅程を編集',
+ 'oauth.scope.trips:write.description': '旅行や日程、メモの作成・更新、メンバー管理',
+ 'oauth.scope.trips:delete.label': '旅行を削除',
+ 'oauth.scope.trips:delete.description': '旅行全体を完全に削除(元に戻せません)',
+ 'oauth.scope.trips:share.label': '共有リンクを管理',
+ 'oauth.scope.trips:share.description': '旅行の公開共有リンクを作成・更新・無効化',
+ 'oauth.scope.places:read.label': '場所・地図データを表示',
+ 'oauth.scope.places:read.description': '場所、日への割り当て、タグ、カテゴリを閲覧',
+ 'oauth.scope.places:write.label': '場所を管理',
+ 'oauth.scope.places:write.description': '場所、割り当て、タグの作成・更新・削除',
+ 'oauth.scope.atlas:read.label': '地図を表示',
+ 'oauth.scope.atlas:read.description': '訪問した国・地域、バケットリストを閲覧',
+ 'oauth.scope.atlas:write.label': '地図を管理',
+ 'oauth.scope.atlas:write.description': '訪問済みの国・地域を管理、バケットリスト編集',
+ 'oauth.scope.packing:read.label': '持ち物リストを表示',
+ 'oauth.scope.packing:read.description': '持ち物、バッグ、担当者を閲覧',
+ 'oauth.scope.packing:write.label': '持ち物リストを管理',
+ 'oauth.scope.packing:write.description': '持ち物やバッグの追加・編集・削除・並び替え',
+ 'oauth.scope.todos:read.label': 'ToDoリストを表示',
+ 'oauth.scope.todos:read.description': '旅行のToDoと担当者を閲覧',
+ 'oauth.scope.todos:write.label': 'ToDoリストを管理',
+ 'oauth.scope.todos:write.description': 'ToDoの作成・編集・完了・削除・並び替え',
+ 'oauth.scope.budget:read.label': '予算を表示',
+ 'oauth.scope.budget:read.description': '予算項目や内訳を閲覧',
+ 'oauth.scope.budget:write.label': '予算を管理',
+ 'oauth.scope.budget:write.description': '予算項目の作成・編集・削除',
+ 'oauth.scope.reservations:read.label': '予約を表示',
+ 'oauth.scope.reservations:read.description': '予約や宿泊情報を閲覧',
+ 'oauth.scope.reservations:write.label': '予約を管理',
+ 'oauth.scope.reservations:write.description': '予約の作成・編集・削除・並び替え',
+ 'oauth.scope.collab:read.label': 'コラボを表示',
+ 'oauth.scope.collab:read.description': '共同メモ、投票、メッセージを閲覧',
+ 'oauth.scope.collab:write.label': 'コラボを管理',
+ 'oauth.scope.collab:write.description': '共同メモ、投票、メッセージを管理',
+ 'oauth.scope.notifications:read.label': '通知を表示',
+ 'oauth.scope.notifications:read.description': 'アプリ内通知と未読数を閲覧',
+ 'oauth.scope.notifications:write.label': '通知を管理',
+ 'oauth.scope.notifications:write.description': '通知を既読にする・対応する',
+ 'oauth.scope.vacay:read.label': '休暇プランを表示',
+ 'oauth.scope.vacay:read.description': '休暇プランのデータや統計を閲覧',
+ 'oauth.scope.vacay:write.label': '休暇プランを管理',
+ 'oauth.scope.vacay:write.description': '休暇エントリーや予定を管理',
+ 'oauth.scope.geo:read.label': '地図・ジオコーディング',
+ 'oauth.scope.geo:read.description': '場所検索、地図URL解析、逆ジオコーディング',
+ 'oauth.scope.weather:read.label': '天気予報',
+ 'oauth.scope.weather:read.description': '旅行先・日程の天気予報を取得',
+ 'oauth.scope.journey:read.label': '日記を表示',
+ 'oauth.scope.journey:read.description': '日記、エントリー、参加者を閲覧',
+ 'oauth.scope.journey:write.label': '日記を管理',
+ 'oauth.scope.journey:write.description': '日記やエントリーの作成・編集・削除',
+ 'oauth.scope.journey:share.label': '日記共有を管理',
+ 'oauth.scope.journey:share.description': '公開共有リンクの作成・更新・無効化',
+
+// System notices — 3.0.0 upgrade
+ 'system_notice.v3_photos.title': '写真の場所が3.0で変更されました',
+ 'system_notice.v3_photos.body': '旅行プランナー内の写真は削除されましたが、写真データは安全です。TREKがImmichやSynologyのライブラリを変更することはありません。\n\n写真は現在日記アドオンにあります。日記は任意機能です。未有効の場合は、管理画面 → アドオンで有効にしてください。',
+ 'system_notice.v3_journey.title': '日記登場 — 旅の日記',
+ 'system_notice.v3_journey.body': 'タイムライン、写真ギャラリー、インタラクティブな地図で旅を物語に。',
+ 'system_notice.v3_journey.cta_label': '日記を開く',
+ 'system_notice.v3_journey.highlight_timeline': '日ごとのタイムラインとギャラリー',
+ 'system_notice.v3_journey.highlight_photos': 'ImmichやSynologyからインポート',
+ 'system_notice.v3_journey.highlight_share': 'ログイン不要で公開共有',
+ 'system_notice.v3_journey.highlight_export': 'PDFフォトブックとして書き出し',
+ 'system_notice.v3_features.title': '3.0のその他の注目点',
+ 'system_notice.v3_features.body': '今回のリリースで知っておきたいポイント。',
+ 'system_notice.v3_features.highlight_dashboard': 'モバイル重視のダッシュボード刷新',
+ 'system_notice.v3_features.highlight_offline': 'PWAとして完全オフライン対応',
+ 'system_notice.v3_features.highlight_search': 'リアルタイム場所検索',
+ 'system_notice.v3_features.highlight_import': 'KMZ/KMLから場所をインポート',
+
+// System notices — MCP OAuth 2.1 upgrade
+ 'system_notice.v3_mcp.title': 'MCP:OAuth 2.1に更新',
+ 'system_notice.v3_mcp.body': 'MCP連携が全面的に刷新されました。OAuth 2.1が推奨認証方式です。従来の静的トークン(trek_…)は非推奨となり、将来削除されます。',
+ 'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1推奨(mcp-remote)',
+ 'system_notice.v3_mcp.highlight_scopes': '24の詳細な権限スコープ',
+ 'system_notice.v3_mcp.highlight_deprecated': '静的trek_トークンは非推奨',
+ 'system_notice.v3_mcp.highlight_tools': 'ツールとプロンプトを拡張',
+
+// System notices — personal thank you
+ 'system_notice.v3_thankyou.title': '開発者より一言',
+ 'system_notice.v3_thankyou.body': '少しだけお時間をください。\n\nTREKは、自分の旅のために作った小さな個人プロジェクトでした。それが今では4,000人以上に使ってもらえるとは思ってもいませんでした。スターも、Issueも、機能要望も、すべて目を通しています。\n\nTREKはこれからもオープンソース、自分でホストでき、あなたのものです。トラッキングなし、サブスクなし。旅が好きな人が作ったツールです。\n\nhttps://github.com/jubnlにも感謝を。3.0の多くはあなたのおかげです。\n\nバグ報告、翻訳、共有、利用してくれたすべての方へ—本当にありがとうございます。\n\nこれからも一緒に旅を。\n\n— Maurice',
+
+ // System notices — 3.0.14
+ 'system_notice.v3014_whitespace_collision.title': '対応が必要:ユーザーアカウントの競合',
+ 'system_notice.v3014_whitespace_collision.body': '3.0.14 へのアップグレードにより、保存されているアカウントの先頭または末尾の空白が原因で、ユーザー名またはメールアドレスの競合が1件以上検出されました。影響を受けたアカウントは自動的にリネームされています。対象となるアカウントを特定するには、サーバーログで **[migration] WHITESPACE COLLISION** で始まる行を確認してください。',
+// System notices — onboarding
+
+ 'system_notice.welcome_v1.title': 'TREKへようこそ',
+ 'system_notice.welcome_v1.body': 'オールインワンの旅行プランナー。旅程作成、共有、整理をオンライン・オフラインで。',
+ 'system_notice.welcome_v1.cta_label': '旅行を計画',
+ 'system_notice.welcome_v1.hero_alt': 'TREKのUIが重なった風景写真',
+ 'system_notice.welcome_v1.highlight_plan': '日ごとの旅程作成',
+ 'system_notice.welcome_v1.highlight_share': '仲間と共同編集',
+ 'system_notice.welcome_v1.highlight_offline': 'モバイルでオフライン対応',
+ 'system_notice.dev_test_modal.title': '[Dev] テスト通知',
+ 'system_notice.dev_test_modal.body': 'これは開発用テスト通知です。',
+ 'system_notice.pager.prev': '前へ',
+ 'system_notice.pager.next': '次へ',
+ 'system_notice.pager.counter': '{current} / {total}',
+ 'system_notice.pager.goto': '通知{n}へ',
+ 'system_notice.pager.position': '{total}件中{current}件目',
+ 'transport.addTransport': '移動手段を追加',
+ 'transport.modalTitle.create': '移動手段を追加',
+ 'transport.modalTitle.edit': '移動手段を編集',
+ 'transport.title': '移動手段',
+ 'transport.addManual': '手動で追加',
+
+ // Added to match EN keys
+ 'journey.editor.uploadingProgress': 'アップロード中 {done}/{total}…',
+ 'journey.editor.uploadFailed': '写真のアップロードに失敗しました',
+ 'journey.editor.uploadPartialFailed': '{total}枚中{failed}枚の写真がアップロードに失敗しました — もう一度保存して再試行してください',
+ 'journey.photosUploadFailed': '一部の写真をアップロードできませんでした',
+ 'settings.oauth.modal.machineClient': 'マシンクライアント(ブラウザログインなし)',
+ 'settings.oauth.modal.machineClientHint': 'client_credentials グラントを使用します — リダイレクト URI は不要です。トークンは client_id + client_secret を介して直接発行され、選択したスコープ内であなたとして動作します。',
+ 'settings.oauth.modal.machineClientUsage': 'トークンを取得するには、grant_type=client_credentials、client_id、client_secret を指定して POST /oauth/token を呼び出します。ブラウザもリフレッシュトークンも不要です。',
+ 'settings.oauth.badge.machine': 'マシン',
+}
+
+export default ja
diff --git a/client/src/i18n/translations/ko.ts b/client/src/i18n/translations/ko.ts
new file mode 100644
index 00000000..82a8dfc0
--- /dev/null
+++ b/client/src/i18n/translations/ko.ts
@@ -0,0 +1,2429 @@
+const ko: Record = {
+ // Common
+ 'common.save': '저장',
+ 'common.showMore': '더 보기',
+ 'common.showLess': '접기',
+ 'common.cancel': '취소',
+ 'common.clear': '지우기',
+ 'common.delete': '삭제',
+ 'common.edit': '편집',
+ 'common.add': '추가',
+ 'common.loading': '로딩 중...',
+ 'common.import': '가져오기',
+ 'common.select': '선택',
+ 'common.selectAll': '전체 선택',
+ 'common.deselectAll': '전체 해제',
+ 'common.error': '오류',
+ 'common.unknownError': '알 수 없는 오류',
+ 'common.tooManyAttempts': '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.',
+ 'common.back': '뒤로',
+ 'common.all': '전체',
+ 'common.close': '닫기',
+ 'common.open': '열기',
+ 'common.upload': '업로드',
+ 'common.search': '검색',
+ 'common.confirm': '확인',
+ 'common.ok': '확인',
+ 'common.yes': '예',
+ 'common.no': '아니오',
+ 'common.or': '또는',
+ 'common.none': '없음',
+ 'common.date': '날짜',
+ 'common.rename': '이름 변경',
+ 'common.discardChanges': '변경 사항 취소',
+ 'common.discard': '취소',
+ 'common.name': '이름',
+ 'common.email': '이메일',
+ 'common.password': '비밀번호',
+ 'common.saving': '저장 중...',
+ 'common.justNow': '방금 전',
+ 'common.hoursAgo': '{count}시간 전',
+ 'common.daysAgo': '{count}일 전',
+ 'common.saved': '저장됨',
+ 'trips.memberRemoved': '{username} 제거됨',
+ 'trips.memberRemoveError': '제거 실패',
+ 'trips.memberAdded': '{username} 추가됨',
+ 'trips.memberAddError': '추가 실패',
+ 'trips.reminder': '리마인더',
+ 'trips.reminderNone': '없음',
+ 'trips.reminderDay': '일',
+ 'trips.reminderDays': '일',
+ 'trips.reminderCustom': '직접 설정',
+ 'trips.reminderDaysBefore': '일 전 출발',
+ 'trips.reminderDisabledHint': '여행 리마인더가 비활성화되어 있습니다. 관리자 > 설정 > 알림에서 활성화하세요.',
+ 'common.update': '업데이트',
+ 'common.change': '변경',
+ 'common.uploading': '업로드 중…',
+ 'common.backToPlanning': '계획으로 돌아가기',
+ 'common.reset': '초기화',
+ 'common.expand': '펼치기',
+ 'common.collapse': '접기',
+
+ // Navbar
+ 'nav.trip': '여행',
+ 'nav.share': '공유',
+ 'nav.settings': '설정',
+ 'nav.admin': '관리자',
+ 'nav.logout': '로그아웃',
+ 'nav.lightMode': '라이트 모드',
+ 'nav.darkMode': '다크 모드',
+ 'nav.autoMode': '자동 모드',
+ 'nav.administrator': '관리자',
+
+ // Dashboard
+ 'dashboard.title': '내 여행',
+ 'dashboard.subtitle.loading': '여행 불러오는 중...',
+ 'dashboard.subtitle.trips': '{count}개 여행 ({archived}개 보관됨)',
+ 'dashboard.subtitle.empty': '첫 번째 여행을 시작하세요',
+ 'dashboard.subtitle.activeOne': '활성 여행 {count}개',
+ 'dashboard.subtitle.activeMany': '활성 여행 {count}개',
+ 'dashboard.subtitle.archivedSuffix': ' · {count}개 보관됨',
+ 'dashboard.newTrip': '새 여행',
+ 'dashboard.gridView': '격자 보기',
+ 'dashboard.listView': '목록 보기',
+ 'dashboard.currency': '통화',
+ 'dashboard.timezone': '시간대',
+ 'dashboard.localTime': '현지',
+ 'dashboard.timezoneCustomTitle': '사용자 지정 시간대',
+ 'dashboard.timezoneCustomLabelPlaceholder': '레이블 (선택)',
+ 'dashboard.timezoneCustomTzPlaceholder': '예: America/New_York',
+ 'dashboard.timezoneCustomAdd': '추가',
+ 'dashboard.timezoneCustomErrorEmpty': '시간대 식별자를 입력하세요',
+ 'dashboard.timezoneCustomErrorInvalid': '잘못된 시간대입니다. Europe/Berlin 같은 형식을 사용하세요',
+ 'dashboard.timezoneCustomErrorDuplicate': '이미 추가됨',
+ 'dashboard.emptyTitle': '아직 여행이 없습니다',
+ 'dashboard.emptyText': '첫 번째 여행을 만들고 계획을 시작하세요!',
+ 'dashboard.emptyButton': '첫 번째 여행 만들기',
+ 'dashboard.nextTrip': '다음 여행',
+ 'dashboard.shared': '공유됨',
+ 'dashboard.sharedBy': '{name}이(가) 공유',
+ 'dashboard.days': '일',
+ 'dashboard.places': '장소',
+ 'dashboard.members': '동행자',
+ 'dashboard.archive': '보관',
+ 'dashboard.copyTrip': '복사',
+ 'dashboard.copySuffix': '사본',
+ 'dashboard.restore': '복원',
+ 'dashboard.archived': '보관됨',
+ 'dashboard.status.ongoing': '진행 중',
+ 'dashboard.status.today': '오늘',
+ 'dashboard.status.tomorrow': '내일',
+ 'dashboard.status.past': '지난 여행',
+ 'dashboard.status.daysLeft': '{count}일 남음',
+ 'dashboard.toast.loadError': '여행 불러오기 실패',
+ 'dashboard.toast.created': '여행이 생성되었습니다!',
+ 'dashboard.toast.createError': '여행 생성 실패',
+ 'dashboard.toast.updated': '여행이 업데이트되었습니다!',
+ 'dashboard.toast.updateError': '여행 업데이트 실패',
+ 'dashboard.toast.deleted': '여행이 삭제되었습니다',
+ 'dashboard.toast.deleteError': '여행 삭제 실패',
+ 'dashboard.toast.archived': '여행이 보관되었습니다',
+ 'dashboard.toast.archiveError': '여행 보관 실패',
+ 'dashboard.toast.restored': '여행이 복원되었습니다',
+ 'dashboard.toast.restoreError': '여행 복원 실패',
+ 'dashboard.toast.copied': '여행이 복사되었습니다!',
+ 'dashboard.toast.copyError': '여행 복사 실패',
+ 'dashboard.confirm.delete': '여행 "{title}"을(를) 삭제할까요? 모든 장소와 계획이 영구 삭제됩니다.',
+ 'dashboard.confirm.copy.title': '이 여행을 복사할까요?',
+ 'dashboard.confirm.copy.willCopy': '복사될 항목',
+ 'dashboard.confirm.copy.will1': '일정, 장소 및 일별 배정',
+ 'dashboard.confirm.copy.will2': '숙박 및 예약',
+ 'dashboard.confirm.copy.will3': '예산 항목 및 카테고리 순서',
+ 'dashboard.confirm.copy.will4': '짐 목록 (체크 해제 상태)',
+ 'dashboard.confirm.copy.will5': '할 일 (미배정 및 미완료)',
+ 'dashboard.confirm.copy.will6': '일별 메모',
+ 'dashboard.confirm.copy.wontCopy': '복사되지 않는 항목',
+ 'dashboard.confirm.copy.wont1': '공동 작업자 및 멤버 배정',
+ 'dashboard.confirm.copy.wont2': '공동 작업 메모, 투표 및 메시지',
+ 'dashboard.confirm.copy.wont3': '파일 및 사진',
+ 'dashboard.confirm.copy.wont4': '공유 토큰',
+ 'dashboard.confirm.copy.confirm': '여행 복사',
+ 'dashboard.editTrip': '여행 편집',
+ 'dashboard.createTrip': '새 여행 만들기',
+ 'dashboard.tripTitle': '제목',
+ 'dashboard.tripTitlePlaceholder': '예: 일본에서의 여름',
+ 'dashboard.tripDescription': '설명',
+ 'dashboard.tripDescriptionPlaceholder': '이 여행에 대해 설명해 주세요',
+ 'dashboard.startDate': '시작일',
+ 'dashboard.endDate': '종료일',
+ 'dashboard.dayCount': '일수',
+ 'dashboard.dayCountHint': '여행 날짜가 설정되지 않은 경우 계획할 일수입니다.',
+ 'dashboard.noDateHint': '날짜 미설정 — 기본 7일이 생성됩니다. 언제든지 변경할 수 있습니다.',
+ 'dashboard.coverImage': '커버 이미지',
+ 'dashboard.addCoverImage': '커버 이미지 추가 (또는 끌어다 놓기)',
+ 'dashboard.addMembers': '동행자',
+ 'dashboard.addMember': '멤버 추가',
+ 'dashboard.coverSaved': '커버 이미지가 저장되었습니다',
+ 'dashboard.coverUploadError': '업로드 실패',
+ 'dashboard.coverRemoveError': '삭제 실패',
+ 'dashboard.titleRequired': '제목을 입력하세요',
+ 'dashboard.endDateError': '종료일은 시작일 이후여야 합니다',
+
+ // Settings
+ 'settings.title': '설정',
+ 'settings.subtitle': '개인 설정을 구성하세요',
+ 'settings.tabs.display': '화면',
+ 'settings.tabs.map': '지도',
+ 'settings.tabs.notifications': '알림',
+ 'settings.tabs.integrations': '통합',
+ 'settings.tabs.account': '계정',
+ 'settings.tabs.offline': '오프라인',
+ 'settings.tabs.about': '정보',
+ 'settings.map': '지도',
+ 'settings.mapTemplate': '지도 템플릿',
+ 'settings.mapTemplatePlaceholder.select': '템플릿 선택...',
+ 'settings.mapDefaultHint': '비워두면 OpenStreetMap (기본값) 사용',
+ 'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
+ 'settings.mapHint': '지도 타일 URL 템플릿',
+ 'settings.mapProvider': '지도 공급자',
+ 'settings.mapProviderHint': '여행 플래너 및 Journey 지도에 영향을 줍니다. Atlas는 항상 Leaflet을 사용합니다.',
+ 'settings.mapLeafletSubtitle': '클래식 2D, 모든 래스터 타일',
+ 'settings.mapMapboxSubtitle': '벡터 타일, 3D 건물 및 지형',
+ 'settings.mapExperimental': '실험적',
+ 'settings.mapMapboxToken': 'Mapbox 액세스 토큰',
+ 'settings.mapMapboxTokenHint': '공개 토큰 (pk.*) 출처',
+ 'settings.mapMapboxTokenLink': 'mapbox.com → 액세스 토큰',
+ 'settings.mapStyle': '지도 스타일',
+ 'settings.mapStylePlaceholder': 'Mapbox 스타일 선택',
+ 'settings.mapStyleHint': '프리셋 또는 mapbox://styles/USER/ID URL 직접 입력',
+ 'settings.map3dBuildings': '3D 건물 및 지형',
+ 'settings.map3dHint': '기울기 + 실제 3D 건물 돌출 — 위성 포함 모든 스타일에서 작동합니다.',
+ 'settings.mapHighQuality': '고품질 모드',
+ 'settings.mapHighQualityHint': '안티앨리어싱 + 구형 투영으로 선명한 경계와 현실적인 지구 뷰를 제공합니다.',
+ 'settings.mapHighQualityWarning': '저사양 기기에서 성능에 영향을 줄 수 있습니다.',
+ 'settings.mapTipLabel': '팁:',
+ 'settings.mapTip': '우클릭 후 드래그하여 지도를 회전/기울이세요. 가운데 클릭으로 장소를 추가할 수 있습니다 (우클릭은 회전 전용).',
+ 'settings.latitude': '위도',
+ 'settings.longitude': '경도',
+ 'settings.saveMap': '지도 저장',
+ 'settings.apiKeys': 'API 키',
+ 'settings.mapsKey': 'Google Maps API 키',
+ 'settings.mapsKeyHint': '장소 검색용. Places API (New) 필요. console.cloud.google.com에서 발급',
+ 'settings.weatherKey': 'OpenWeatherMap API 키',
+ 'settings.weatherKeyHint': '날씨 데이터용. openweathermap.org/api에서 무료 발급',
+ 'settings.keyPlaceholder': '키 입력...',
+ 'settings.configured': '설정됨',
+ 'settings.saveKeys': '키 저장',
+ 'settings.display': '화면',
+ 'settings.colorMode': '색상 모드',
+ 'settings.light': '라이트',
+ 'settings.dark': '다크',
+ 'settings.auto': '자동',
+ 'settings.language': '언어',
+ 'settings.temperature': '온도 단위',
+ 'settings.timeFormat': '시간 형식',
+ 'settings.routeCalculation': '경로 계산',
+ 'settings.bookingLabels': '예약 경로 레이블',
+ 'settings.bookingLabelsHint': '지도에 역 / 공항 이름을 표시합니다. 끄면 아이콘만 표시됩니다.',
+ 'settings.blurBookingCodes': '예약 코드 흐리게',
+ 'settings.notifications': '알림',
+ 'settings.notifyTripInvite': '여행 초대',
+ 'settings.notifyBookingChange': '예약 변경',
+ 'settings.notifyTripReminder': '여행 리마인더',
+ 'settings.notifyTodoDue': '할 일 마감 임박',
+ 'settings.notifyVacayInvite': 'Vacay 퓨전 초대',
+ 'settings.notifyPhotosShared': '공유된 사진 (Immich)',
+ 'settings.notifyCollabMessage': '채팅 메시지 (Collab)',
+ 'settings.notifyPackingTagged': '짐 목록: 배정',
+ 'settings.notifyWebhook': '웹훅 알림',
+ 'settings.notifyVersionAvailable': '새 버전 사용 가능',
+ 'settings.notificationPreferences.email': '이메일',
+ 'settings.notificationPreferences.webhook': '웹훅',
+ 'settings.notificationPreferences.inapp': '앱 내',
+ 'settings.notificationPreferences.ntfy': 'Ntfy',
+ 'settings.notificationPreferences.noChannels': '알림 채널이 설정되지 않았습니다. 관리자에게 이메일 또는 웹훅 알림 설정을 요청하세요.',
+ 'settings.webhookUrl.label': '웹훅 URL',
+ 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
+ 'settings.webhookUrl.hint': 'Discord, Slack 또는 사용자 지정 웹훅 URL을 입력하여 알림을 받으세요.',
+ 'settings.webhookUrl.saved': '웹훅 URL이 저장되었습니다',
+ 'settings.webhookUrl.test': '테스트',
+ 'settings.webhookUrl.testSuccess': '테스트 웹훅이 성공적으로 전송되었습니다',
+ 'settings.webhookUrl.testFailed': '테스트 웹훅 실패',
+ 'settings.ntfyUrl.topicLabel': 'Ntfy 토픽',
+ 'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
+ 'settings.ntfyUrl.serverLabel': 'Ntfy 서버 URL (선택)',
+ 'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
+ 'settings.ntfyUrl.hint': 'ntfy 토픽을 입력하여 푸시 알림을 받으세요. 서버를 비워두면 관리자가 설정한 기본값을 사용합니다.',
+ 'settings.ntfyUrl.tokenLabel': '액세스 토큰 (선택)',
+ 'settings.ntfyUrl.tokenHint': '비밀번호로 보호된 토픽에 필요합니다.',
+ 'settings.ntfyUrl.saved': 'Ntfy 설정이 저장되었습니다',
+ 'settings.ntfyUrl.test': '테스트',
+ 'settings.ntfyUrl.testSuccess': '테스트 ntfy 알림이 성공적으로 전송되었습니다',
+ 'settings.ntfyUrl.testFailed': '테스트 ntfy 알림 실패',
+ 'settings.ntfyUrl.tokenCleared': '액세스 토큰이 삭제되었습니다',
+ 'admin.notifications.title': '알림',
+ 'admin.notifications.hint': '알림 채널을 하나 선택하세요. 한 번에 하나만 활성화할 수 있습니다.',
+ 'admin.notifications.none': '비활성화',
+ 'admin.notifications.email': '이메일 (SMTP)',
+ 'admin.notifications.webhook': '웹훅',
+ 'admin.notifications.ntfy': 'Ntfy',
+ 'admin.ntfy.hint': '사용자가 자신의 ntfy 토픽을 설정하여 푸시 알림을 받을 수 있습니다. 아래에 기본 서버를 설정하면 사용자 설정에 미리 채워집니다.',
+ 'admin.notifications.save': '알림 설정 저장',
+ 'admin.notifications.saved': '알림 설정이 저장되었습니다',
+ 'admin.notifications.testWebhook': '테스트 웹훅 전송',
+ 'admin.notifications.testWebhookSuccess': '테스트 웹훅이 성공적으로 전송되었습니다',
+ 'admin.notifications.testWebhookFailed': '테스트 웹훅 실패',
+ 'admin.notifications.testNtfy': '테스트 ntfy 전송',
+ 'admin.notifications.testNtfySuccess': '테스트 ntfy가 성공적으로 전송되었습니다',
+ 'admin.notifications.testNtfyFailed': '테스트 ntfy 실패',
+ 'admin.notifications.emailPanel.title': '이메일 (SMTP)',
+ 'admin.notifications.webhookPanel.title': '웹훅',
+ 'admin.notifications.inappPanel.title': '앱 내',
+ 'admin.notifications.inappPanel.hint': '앱 내 알림은 항상 활성화되어 있으며 전역으로 비활성화할 수 없습니다.',
+ 'admin.notifications.adminWebhookPanel.title': '관리자 웹훅',
+ 'admin.notifications.adminWebhookPanel.hint': '이 웹훅은 관리자 알림 전용입니다 (예: 버전 알림). 사용자별 웹훅과 별개이며 설정 시 항상 실행됩니다.',
+ 'admin.notifications.adminWebhookPanel.saved': '관리자 웹훅 URL이 저장되었습니다',
+ 'admin.notifications.adminWebhookPanel.testSuccess': '테스트 웹훅이 성공적으로 전송되었습니다',
+ 'admin.notifications.adminWebhookPanel.testFailed': '테스트 웹훅 실패',
+ 'admin.notifications.adminWebhookPanel.alwaysOnHint': 'URL이 설정되면 관리자 웹훅은 항상 실행됩니다',
+ 'admin.notifications.adminNtfyPanel.title': '관리자 Ntfy',
+ 'admin.notifications.adminNtfyPanel.hint': '이 ntfy 토픽은 관리자 알림 전용입니다 (예: 버전 알림). 사용자별 토픽과 별개이며 설정 시 항상 실행됩니다.',
+ 'admin.notifications.adminNtfyPanel.serverLabel': 'Ntfy 서버 URL',
+ 'admin.notifications.adminNtfyPanel.serverHint': '사용자 ntfy 알림의 기본 서버로도 사용됩니다. 비워두면 ntfy.sh가 기본값입니다. 사용자는 자신의 설정에서 변경할 수 있습니다.',
+ 'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
+ 'admin.notifications.adminNtfyPanel.topicLabel': '관리자 토픽',
+ 'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
+ 'admin.notifications.adminNtfyPanel.tokenLabel': '액세스 토큰 (선택)',
+ 'admin.notifications.adminNtfyPanel.tokenCleared': '관리자 액세스 토큰이 삭제되었습니다',
+ 'admin.notifications.adminNtfyPanel.saved': '관리자 ntfy 설정이 저장되었습니다',
+ 'admin.notifications.adminNtfyPanel.test': '테스트 ntfy 전송',
+ 'admin.notifications.adminNtfyPanel.testSuccess': '테스트 ntfy가 성공적으로 전송되었습니다',
+ 'admin.notifications.adminNtfyPanel.testFailed': '테스트 ntfy 실패',
+ 'admin.notifications.adminNtfyPanel.alwaysOnHint': '토픽이 설정되면 관리자 ntfy는 항상 실행됩니다',
+ 'admin.notifications.adminNotificationsHint': '관리자 전용 알림 (예: 버전 알림)을 전달할 채널을 설정하세요.',
+ 'admin.notifications.tripReminders.title': '여행 리마인더',
+ 'admin.notifications.tripReminders.hint': '여행 시작 전에 리마인더 알림을 전송합니다 (여행에 리마인더 일수가 설정되어 있어야 합니다).',
+ 'admin.notifications.tripReminders.enabled': '여행 리마인더 활성화됨',
+ 'admin.notifications.tripReminders.disabled': '여행 리마인더 비활성화됨',
+ 'admin.smtp.title': '이메일 및 알림',
+ 'admin.smtp.hint': '이메일 알림 전송을 위한 SMTP 설정입니다.',
+ 'admin.smtp.testButton': '테스트 이메일 전송',
+ 'admin.webhook.hint': '사용자가 알림용 웹훅 URL을 설정할 수 있습니다 (Discord, Slack 등).',
+ 'admin.smtp.testSuccess': '테스트 이메일이 성공적으로 전송되었습니다',
+ 'admin.smtp.testFailed': '테스트 이메일 실패',
+ 'settings.notificationsDisabled': '알림이 설정되지 않았습니다. 관리자에게 이메일 또는 웹훅 알림 활성화를 요청하세요.',
+ 'settings.notificationsActive': '활성 채널',
+ 'settings.notificationsManagedByAdmin': '알림 이벤트는 관리자가 설정합니다.',
+ 'dayplan.icsTooltip': '캘린더 내보내기 (ICS)',
+ 'share.linkTitle': '공개 링크',
+ 'share.linkHint': '로그인 없이 이 여행을 볼 수 있는 링크를 만드세요. 읽기 전용 — 편집 불가.',
+ 'share.createLink': '링크 만들기',
+ 'share.deleteLink': '링크 삭제',
+ 'share.createError': '링크 생성 실패',
+ 'common.copy': '복사',
+ 'common.copied': '복사됨',
+ 'share.permMap': '지도 및 계획',
+ 'share.permBookings': '예약',
+ 'share.permPacking': '짐 목록',
+ 'shared.expired': '링크가 만료되었거나 유효하지 않습니다',
+ 'shared.expiredHint': '이 공유 여행 링크는 더 이상 유효하지 않습니다.',
+ 'shared.readOnly': '읽기 전용 공유 보기',
+ 'shared.tabPlan': '계획',
+ 'shared.tabBookings': '예약',
+ 'shared.tabPacking': '짐 목록',
+ 'shared.tabBudget': '예산',
+ 'shared.tabChat': '채팅',
+ 'shared.days': '일',
+ 'shared.places': '장소',
+ 'shared.other': '기타',
+ 'shared.totalBudget': '총 예산',
+ 'shared.messages': '메시지',
+ 'shared.sharedVia': '공유 경로',
+ 'shared.confirmed': '확정됨',
+ 'shared.pending': '대기 중',
+ 'share.permBudget': '예산',
+ 'share.permCollab': '채팅',
+ 'settings.on': '켜기',
+ 'settings.off': '끄기',
+ 'settings.mcp.title': 'MCP 설정',
+ 'settings.mcp.endpoint': 'MCP 엔드포인트',
+ 'settings.mcp.clientConfig': '클라이언트 설정',
+ 'settings.mcp.clientConfigHint': '을 아래 목록의 API 토큰으로 교체하세요. npx 경로는 시스템에 따라 조정해야 할 수 있습니다 (예: Windows의 경우 C:\\PROGRA~1\\nodejs\\npx.cmd).',
+ 'settings.mcp.clientConfigHintOAuth': '와 을 위에서 생성한 OAuth 2.1 클라이언트 자격 증명으로 교체하세요. mcp-remote가 처음 연결 시 브라우저를 열어 인증을 완료합니다. npx 경로는 시스템에 따라 조정해야 할 수 있습니다 (예: Windows의 경우 C:\\PROGRA~1\\nodejs\\npx.cmd).',
+ 'settings.mcp.copy': '복사',
+ 'settings.mcp.copied': '복사됨!',
+ 'settings.mcp.apiTokens': 'API 토큰',
+ 'settings.mcp.createToken': '새 토큰 만들기',
+ 'settings.mcp.noTokens': '토큰이 없습니다. MCP 클라이언트 연결을 위해 토큰을 만드세요.',
+ 'settings.mcp.tokenCreatedAt': '생성일',
+ 'settings.mcp.tokenUsedAt': '사용일',
+ 'settings.mcp.deleteTokenTitle': '토큰 삭제',
+ 'settings.mcp.deleteTokenMessage': '이 토큰은 즉시 무효화됩니다. 이 토큰을 사용하는 MCP 클라이언트는 접근 권한을 잃게 됩니다.',
+ 'settings.mcp.modal.createTitle': 'API 토큰 만들기',
+ 'settings.mcp.modal.tokenName': '토큰 이름',
+ 'settings.mcp.modal.tokenNamePlaceholder': '예: Claude Desktop, 업무용 노트북',
+ 'settings.mcp.modal.creating': '생성 중…',
+ 'settings.mcp.modal.create': '토큰 만들기',
+ 'settings.mcp.modal.createdTitle': '토큰이 생성되었습니다',
+ 'settings.mcp.modal.createdWarning': '이 토큰은 한 번만 표시됩니다. 지금 복사하여 저장하세요 — 다시 복구할 수 없습니다.',
+ 'settings.mcp.modal.done': '완료',
+ 'settings.mcp.toast.created': '토큰이 생성되었습니다',
+ 'settings.mcp.toast.createError': '토큰 생성 실패',
+ 'settings.mcp.toast.deleted': '토큰이 삭제되었습니다',
+ 'settings.mcp.toast.deleteError': '토큰 삭제 실패',
+ 'settings.mcp.apiTokensDeprecated': 'API 토큰은 더 이상 사용되지 않으며 향후 버전에서 제거될 예정입니다. 대신 OAuth 2.1 클라이언트를 사용하세요.',
+ 'settings.oauth.clients': 'OAuth 2.1 클라이언트',
+ 'settings.oauth.clientsHint': 'OAuth 2.1 클라이언트를 등록하여 타사 MCP 앱 (Claude Web, Cursor 등)이 정적 토큰 없이 연결할 수 있도록 하세요.',
+ 'settings.oauth.createClient': '새 클라이언트',
+ 'settings.oauth.noClients': '등록된 OAuth 클라이언트가 없습니다.',
+ 'settings.oauth.clientId': '클라이언트 ID',
+ 'settings.oauth.clientSecret': '클라이언트 시크릿',
+ 'settings.oauth.deleteClient': '클라이언트 삭제',
+ 'settings.oauth.deleteClientMessage': '이 클라이언트와 모든 활성 세션이 영구 삭제됩니다. 이 클라이언트를 사용하는 앱은 즉시 접근 권한을 잃게 됩니다.',
+ 'settings.oauth.rotateSecret': '시크릿 교체',
+ 'settings.oauth.rotateSecretMessage': '새 클라이언트 시크릿이 생성되고 기존 세션은 즉시 무효화됩니다. 이 대화창을 닫기 전에 앱을 업데이트하세요.',
+ 'settings.oauth.rotateSecretConfirm': '교체',
+ 'settings.oauth.rotateSecretConfirming': '교체 중…',
+ 'settings.oauth.rotateSecretDoneTitle': '새 시크릿이 생성되었습니다',
+ 'settings.oauth.rotateSecretDoneWarning': '이 시크릿은 한 번만 표시됩니다. 지금 복사하여 앱을 업데이트하세요 — 이전 세션은 모두 무효화되었습니다.',
+ 'settings.oauth.activeSessions': '활성 OAuth 세션',
+ 'settings.oauth.sessionScopes': '권한 범위',
+ 'settings.oauth.sessionExpires': '만료',
+ 'settings.oauth.revoke': '취소',
+ 'settings.oauth.revokeSession': '세션 취소',
+ 'settings.oauth.revokeSessionMessage': '이 OAuth 세션의 접근 권한이 즉시 취소됩니다.',
+ 'settings.oauth.modal.createTitle': 'OAuth 클라이언트 등록',
+ 'settings.oauth.modal.presets': '빠른 프리셋',
+ 'settings.oauth.modal.clientName': '앱 이름',
+ 'settings.oauth.modal.clientNamePlaceholder': '예: Claude Web, My MCP App',
+ 'settings.oauth.modal.redirectUris': '리디렉션 URI',
+ 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth',
+ 'settings.oauth.modal.redirectUrisHint': '한 줄에 URI 하나. HTTPS 필수 (localhost 예외). 정확히 일치해야 합니다.',
+ 'settings.oauth.modal.scopes': '허용 권한 범위',
+ 'settings.oauth.modal.scopesHint': 'list_trips 및 get_trip_summary는 항상 사용 가능합니다 — 권한 범위 불필요. AI가 다른 도구를 사용하는 데 필요한 여행 ID를 찾을 수 있습니다.',
+ 'settings.oauth.modal.selectAll': '전체 선택',
+ 'settings.oauth.modal.deselectAll': '전체 해제',
+ 'settings.oauth.modal.creating': '등록 중…',
+ 'settings.oauth.modal.create': '클라이언트 등록',
+ 'settings.oauth.modal.createdTitle': '클라이언트가 등록되었습니다',
+ 'settings.oauth.modal.createdWarning': '클라이언트 시크릿은 한 번만 표시됩니다. 지금 복사하세요 — 다시 복구할 수 없습니다.',
+ 'settings.oauth.toast.createError': 'OAuth 클라이언트 등록 실패',
+ 'settings.oauth.toast.deleted': 'OAuth 클라이언트가 삭제되었습니다',
+ 'settings.oauth.toast.deleteError': 'OAuth 클라이언트 삭제 실패',
+ 'settings.oauth.toast.revoked': '세션이 취소되었습니다',
+ 'settings.oauth.toast.revokeError': '세션 취소 실패',
+ 'settings.oauth.toast.rotateError': '클라이언트 시크릿 교체 실패',
+ 'settings.account': '계정',
+ 'settings.about': '정보',
+ 'settings.about.reportBug': '버그 신고',
+ 'settings.about.reportBugHint': '문제를 발견하셨나요? 알려주세요',
+ 'settings.about.featureRequest': '기능 요청',
+ 'settings.about.featureRequestHint': '새로운 기능을 제안하세요',
+ 'settings.about.wikiHint': '문서 및 가이드',
+ 'settings.about.supporters.badge': '월간 후원자',
+ 'settings.about.supporters.title': 'TREK의 여행 동반자',
+ 'settings.about.supporters.subtitle': '다음 여정을 계획하는 동안, 이분들이 TREK의 미래를 함께 만들어가고 있습니다. 월간 후원금은 개발과 실제 작업 시간에 직접 사용되어 TREK이 오픈 소스로 유지될 수 있게 합니다.',
+ 'settings.about.supporters.since': '{date}부터 후원자',
+ 'settings.about.supporters.tierEmpty': '첫 번째 후원자가 되어보세요',
+ 'settings.about.supporter.tier.noReturnTicket': '편도 티켓',
+ 'settings.about.supporter.tier.lostLuggageVip': '분실 수하물 VIP',
+ 'settings.about.supporter.tier.businessClassDreamer': '비즈니스 클래스 꿈꾸기',
+ 'settings.about.supporter.tier.budgetTraveller': '알뜰 여행자',
+ 'settings.about.supporter.tier.hostelBunkmate': '호스텔 룸메이트',
+ 'settings.about.description': 'TREK은 첫 아이디어부터 마지막 추억까지 여행을 체계적으로 관리하는 자체 호스팅 여행 플래너입니다. 일별 계획, 예산, 짐 목록, 사진 등 모든 것이 하나의 서버에 담겨 있습니다.',
+ 'settings.about.madeWith': '으로 만들어졌습니다',
+ 'settings.about.madeBy': 'Maurice와 성장하는 오픈 소스 커뮤니티가 함께',
+ 'settings.username': '사용자 이름',
+ 'settings.email': '이메일',
+ 'settings.role': '역할',
+ 'settings.roleAdmin': '관리자',
+ 'settings.oidcLinked': '연결됨',
+ 'settings.changePassword': '비밀번호 변경',
+ 'settings.currentPassword': '현재 비밀번호',
+ 'settings.currentPasswordRequired': '현재 비밀번호를 입력하세요',
+ 'settings.newPassword': '새 비밀번호',
+ 'settings.confirmPassword': '새 비밀번호 확인',
+ 'settings.updatePassword': '비밀번호 업데이트',
+ 'settings.passwordRequired': '현재 비밀번호와 새 비밀번호를 입력하세요',
+ 'settings.passwordTooShort': '비밀번호는 최소 8자 이상이어야 합니다',
+ 'settings.passwordMismatch': '비밀번호가 일치하지 않습니다',
+ 'settings.passwordWeak': '비밀번호는 대문자, 소문자, 숫자, 특수문자를 포함해야 합니다',
+ 'settings.passwordChanged': '비밀번호가 성공적으로 변경되었습니다',
+ 'settings.mustChangePassword': '계속하기 전에 비밀번호를 변경해야 합니다. 아래에서 새 비밀번호를 설정하세요.',
+ 'settings.deleteAccount': '계정 삭제',
+ 'settings.deleteAccountTitle': '계정을 삭제할까요?',
+ 'settings.deleteAccountWarning': '계정과 모든 여행, 장소, 파일이 영구 삭제됩니다. 이 작업은 취소할 수 없습니다.',
+ 'settings.deleteAccountConfirm': '영구 삭제',
+ 'settings.deleteBlockedTitle': '삭제 불가',
+ 'settings.deleteBlockedMessage': '유일한 관리자입니다. 계정을 삭제하기 전에 다른 사용자를 관리자로 승격하세요.',
+ 'settings.roleUser': '사용자',
+ 'settings.saveProfile': '프로필 저장',
+ 'settings.toast.mapSaved': '지도 설정이 저장되었습니다',
+ 'settings.toast.keysSaved': 'API 키가 저장되었습니다',
+ 'settings.toast.displaySaved': '화면 설정이 저장되었습니다',
+ 'settings.toast.profileSaved': '프로필이 저장되었습니다',
+ 'settings.uploadAvatar': '프로필 사진 업로드',
+ 'settings.removeAvatar': '프로필 사진 삭제',
+ 'settings.avatarUploaded': '프로필 사진이 업데이트되었습니다',
+ 'settings.avatarRemoved': '프로필 사진이 삭제되었습니다',
+ 'settings.avatarError': '업로드 실패',
+ 'settings.mfa.title': '2단계 인증 (2FA)',
+ 'settings.mfa.description': '이메일 및 비밀번호로 로그인할 때 두 번째 단계를 추가합니다. 인증 앱 (Google Authenticator, Authy 등)을 사용하세요.',
+ 'settings.mfa.requiredByPolicy': '관리자가 2단계 인증을 요구합니다. 앱을 계속 사용하려면 아래에서 인증 앱을 설정하세요.',
+ 'settings.mfa.backupTitle': '백업 코드',
+ 'settings.mfa.backupDescription': '인증 앱에 접근할 수 없을 때 이 일회용 백업 코드를 사용하세요.',
+ 'settings.mfa.backupWarning': '지금 이 코드를 저장하세요. 각 코드는 한 번만 사용할 수 있습니다.',
+ 'settings.mfa.backupCopy': '코드 복사',
+ 'settings.mfa.backupDownload': 'TXT 다운로드',
+ 'settings.mfa.backupPrint': '인쇄 / PDF',
+ 'settings.mfa.backupCopied': '백업 코드가 복사되었습니다',
+ 'settings.mfa.enabled': '계정에 2FA가 활성화되어 있습니다.',
+ 'settings.mfa.disabled': '2FA가 활성화되지 않았습니다.',
+ 'settings.mfa.setup': '인증 앱 설정',
+ 'settings.mfa.scanQr': '앱으로 이 QR 코드를 스캔하거나 시크릿을 수동으로 입력하세요.',
+ 'settings.mfa.secretLabel': '시크릿 키 (수동 입력)',
+ 'settings.mfa.codePlaceholder': '6자리 코드',
+ 'settings.mfa.enable': '2FA 활성화',
+ 'settings.mfa.cancelSetup': '취소',
+ 'settings.mfa.disableTitle': '2FA 비활성화',
+ 'settings.mfa.disableHint': '계정 비밀번호와 인증 앱의 현재 코드를 입력하세요.',
+ 'settings.mfa.disable': '2FA 비활성화',
+ 'settings.mfa.toastEnabled': '2단계 인증이 활성화되었습니다',
+ 'settings.mfa.toastDisabled': '2단계 인증이 비활성화되었습니다',
+ 'settings.mfa.demoBlocked': '데모 모드에서는 사용할 수 없습니다',
+
+ // Login
+ 'login.error': '로그인 실패. 자격 증명을 확인하세요.',
+ 'login.tagline': '나의 여행.\n나의 계획.',
+ 'login.description': '인터랙티브 지도, 예산, 실시간 동기화로 함께 여행을 계획하세요.',
+ 'login.features.maps': '인터랙티브 지도',
+ 'login.features.mapsDesc': 'Google Places, 경로 및 클러스터링',
+ 'login.features.realtime': '실시간 동기화',
+ 'login.features.realtimeDesc': 'WebSocket으로 함께 계획',
+ 'login.features.budget': '예산 추적',
+ 'login.features.budgetDesc': '카테고리, 차트 및 1인당 비용',
+ 'login.features.collab': '협업',
+ 'login.features.collabDesc': '공유 여행으로 다중 사용자 지원',
+ 'login.features.packing': '짐 목록',
+ 'login.features.packingDesc': '카테고리, 진행 상황 및 제안',
+ 'login.features.bookings': '예약',
+ 'login.features.bookingsDesc': '항공, 호텔, 레스토랑 등',
+ 'login.features.files': '문서',
+ 'login.features.filesDesc': '문서 업로드 및 관리',
+ 'login.features.routes': '스마트 경로',
+ 'login.features.routesDesc': '자동 최적화 및 Google Maps 내보내기',
+ 'login.selfHosted': '자체 호스팅 · 오픈 소스 · 내 데이터는 내 것',
+ 'login.title': '로그인',
+ 'login.subtitle': '다시 오신 것을 환영합니다',
+ 'login.signingIn': '로그인 중…',
+ 'login.signIn': '로그인',
+ 'login.createAdmin': '관리자 계정 만들기',
+ 'login.createAdminHint': 'TREK의 첫 번째 관리자 계정을 설정하세요.',
+ 'login.setNewPassword': '새 비밀번호 설정',
+ 'login.setNewPasswordHint': '계속하기 전에 비밀번호를 변경해야 합니다.',
+ 'login.createAccount': '계정 만들기',
+ 'login.createAccountHint': '새 계정을 등록하세요.',
+ 'login.creating': '생성 중…',
+ 'login.noAccount': '계정이 없으신가요?',
+ 'login.hasAccount': '이미 계정이 있으신가요?',
+ 'login.register': '회원가입',
+ 'login.emailPlaceholder': 'your@email.com',
+ 'login.username': '사용자 이름',
+ 'login.oidc.registrationDisabled': '회원가입이 비활성화되어 있습니다. 관리자에게 문의하세요.',
+ 'login.oidc.noEmail': '공급자로부터 이메일을 받지 못했습니다.',
+ 'login.oidc.tokenFailed': '인증 실패.',
+ 'login.oidc.invalidState': '유효하지 않은 세션입니다. 다시 시도하세요.',
+ 'login.demoFailed': '데모 로그인 실패',
+ 'login.oidcSignIn': '{name}으로 로그인',
+ 'login.oidcOnly': '비밀번호 인증이 비활성화되었습니다. SSO 공급자로 로그인하세요.',
+ 'login.oidcLoggedOut': '로그아웃되었습니다. SSO 공급자로 다시 로그인하세요.',
+ 'login.demoHint': '데모 체험 — 회원가입 불필요',
+ 'login.mfaTitle': '2단계 인증',
+ 'login.mfaSubtitle': '인증 앱의 6자리 코드를 입력하세요.',
+ 'login.mfaCodeLabel': '인증 코드',
+ 'login.mfaCodeRequired': '인증 앱의 코드를 입력하세요.',
+ 'login.mfaHint': 'Google Authenticator, Authy 또는 다른 TOTP 앱을 열어주세요.',
+ 'login.mfaBack': '← 로그인으로 돌아가기',
+ 'login.mfaVerify': '인증',
+ 'login.invalidInviteLink': '유효하지 않거나 만료된 초대 링크입니다',
+ 'login.oidcFailed': 'OIDC 로그인 실패',
+ 'login.usernameRequired': '사용자 이름을 입력하세요',
+ 'login.passwordMinLength': '비밀번호는 최소 8자 이상이어야 합니다',
+ 'login.forgotPassword': '비밀번호를 잊으셨나요?',
+ 'login.forgotPasswordTitle': '비밀번호 재설정',
+ 'login.forgotPasswordBody': '가입 시 사용한 이메일 주소를 입력하세요. 계정이 존재하면 재설정 링크를 보내드립니다.',
+ 'login.forgotPasswordSubmit': '재설정 링크 전송',
+ 'login.forgotPasswordSentTitle': '이메일을 확인하세요',
+ 'login.forgotPasswordSentBody': '해당 이메일로 계정이 있다면 재설정 링크가 전송 중입니다. 링크는 60분 후 만료됩니다.',
+ 'login.forgotPasswordSmtpHintOff': '알림: 관리자가 SMTP를 설정하지 않아 재설정 링크가 이메일 대신 서버 콘솔에 기록됩니다.',
+ 'login.backToLogin': '로그인으로 돌아가기',
+ 'login.newPassword': '새 비밀번호',
+ 'login.confirmPassword': '새 비밀번호 확인',
+ 'login.passwordsDontMatch': '비밀번호가 일치하지 않습니다',
+ 'login.mfaCode': '2FA 코드',
+ 'login.resetPasswordTitle': '새 비밀번호 설정',
+ 'login.resetPasswordBody': '이전에 사용하지 않은 강력한 비밀번호를 선택하세요. 최소 8자.',
+ 'login.resetPasswordMfaBody': '재설정을 완료하려면 2FA 코드 또는 백업 코드를 입력하세요.',
+ 'login.resetPasswordSubmit': '비밀번호 재설정',
+ 'login.resetPasswordVerify': '인증 및 재설정',
+ 'login.resetPasswordSuccessTitle': '비밀번호가 업데이트되었습니다',
+ 'login.resetPasswordSuccessBody': '새 비밀번호로 로그인할 수 있습니다.',
+ 'login.resetPasswordInvalidLink': '유효하지 않은 재설정 링크',
+ 'login.resetPasswordInvalidLinkBody': '이 링크가 없거나 손상되었습니다. 새 링크를 요청하세요.',
+ 'login.resetPasswordFailed': '재설정 실패. 링크가 만료되었을 수 있습니다.',
+
+ // Register
+ 'register.passwordMismatch': '비밀번호가 일치하지 않습니다',
+ 'register.passwordTooShort': '비밀번호는 최소 8자 이상이어야 합니다',
+ 'register.failed': '회원가입 실패',
+ 'register.getStarted': '시작하기',
+ 'register.subtitle': '계정을 만들고 꿈의 여행을 계획하세요.',
+ 'register.feature1': '무제한 여행 계획',
+ 'register.feature2': '인터랙티브 지도 보기',
+ 'register.feature3': '장소 및 카테고리 관리',
+ 'register.feature4': '예약 추적',
+ 'register.feature5': '짐 목록 만들기',
+ 'register.feature6': '사진 및 파일 저장',
+ 'register.createAccount': '계정 만들기',
+ 'register.startPlanning': '여행 계획 시작',
+ 'register.minChars': '최소 6자',
+ 'register.confirmPassword': '비밀번호 확인',
+ 'register.repeatPassword': '비밀번호 재입력',
+ 'register.registering': '가입 중...',
+ 'register.register': '회원가입',
+ 'register.hasAccount': '이미 계정이 있으신가요?',
+ 'register.signIn': '로그인',
+
+ // Admin
+ 'admin.title': '관리자',
+ 'admin.subtitle': '사용자 관리 및 시스템 설정',
+ 'admin.tabs.users': '사용자',
+ 'admin.tabs.categories': '카테고리',
+ 'admin.tabs.backup': '백업',
+ 'admin.tabs.notifications': '알림',
+ 'admin.tabs.audit': '감사',
+ 'admin.stats.users': '사용자',
+ 'admin.stats.trips': '여행',
+ 'admin.stats.places': '장소',
+ 'admin.stats.photos': '사진',
+ 'admin.stats.files': '파일',
+ 'admin.table.user': '사용자',
+ 'admin.table.email': '이메일',
+ 'admin.table.role': '역할',
+ 'admin.table.created': '생성일',
+ 'admin.table.lastLogin': '마지막 로그인',
+ 'admin.table.actions': '작업',
+ 'admin.you': '(나)',
+ 'admin.editUser': '사용자 편집',
+ 'admin.newPassword': '새 비밀번호',
+ 'admin.newPasswordHint': '비워두면 현재 비밀번호 유지',
+ 'admin.deleteUser': '사용자 "{name}"을(를) 삭제할까요? 모든 여행이 영구 삭제됩니다.',
+ 'admin.deleteUserTitle': '사용자 삭제',
+ 'admin.newPasswordPlaceholder': '새 비밀번호 입력…',
+ 'admin.toast.loadError': '관리자 데이터 불러오기 실패',
+ 'admin.toast.userUpdated': '사용자가 업데이트되었습니다',
+ 'admin.toast.updateError': '업데이트 실패',
+ 'admin.toast.userDeleted': '사용자가 삭제되었습니다',
+ 'admin.toast.deleteError': '삭제 실패',
+ 'admin.toast.cannotDeleteSelf': '자신의 계정은 삭제할 수 없습니다',
+ 'admin.toast.userCreated': '사용자가 생성되었습니다',
+ 'admin.toast.createError': '사용자 생성 실패',
+ 'admin.toast.fieldsRequired': '사용자 이름, 이메일, 비밀번호가 필요합니다',
+ 'admin.createUser': '사용자 만들기',
+ 'admin.invite.title': '초대 링크',
+ 'admin.invite.subtitle': '일회용 회원가입 링크 만들기',
+ 'admin.invite.create': '링크 만들기',
+ 'admin.invite.createAndCopy': '만들기 및 복사',
+ 'admin.invite.empty': '아직 초대 링크가 없습니다',
+ 'admin.invite.maxUses': '최대 사용 횟수',
+ 'admin.invite.expiry': '만료 기간',
+ 'admin.invite.uses': '사용됨',
+ 'admin.invite.expiresAt': '만료',
+ 'admin.invite.createdBy': '작성자',
+ 'admin.invite.active': '활성',
+ 'admin.invite.expired': '만료됨',
+ 'admin.invite.usedUp': '사용 완료',
+ 'admin.invite.copied': '초대 링크가 클립보드에 복사되었습니다',
+ 'admin.invite.copyLink': '링크 복사',
+ 'admin.invite.deleted': '초대 링크가 삭제되었습니다',
+ 'admin.invite.createError': '초대 링크 생성 실패',
+ 'admin.invite.deleteError': '초대 링크 삭제 실패',
+ 'admin.tabs.settings': '설정',
+ 'admin.allowRegistration': '회원가입 허용',
+ 'admin.allowRegistrationHint': '새 사용자가 직접 회원가입할 수 있습니다',
+ 'admin.authMethods': '인증 방법',
+ 'admin.passwordLogin': '비밀번호 로그인',
+ 'admin.passwordLoginHint': '사용자가 이메일과 비밀번호로 로그인할 수 있습니다',
+ 'admin.passwordRegistration': '비밀번호 회원가입',
+ 'admin.passwordRegistrationHint': '새 사용자가 이메일과 비밀번호로 가입할 수 있습니다',
+ 'admin.oidcLogin': 'SSO 로그인',
+ 'admin.oidcLoginHint': '사용자가 SSO로 로그인할 수 있습니다',
+ 'admin.oidcRegistration': 'SSO 자동 프로비저닝',
+ 'admin.oidcRegistrationHint': '새 SSO 사용자의 계정을 자동으로 생성합니다',
+ 'admin.envOverrideHint': '비밀번호 로그인 설정은 OIDC_ONLY 환경 변수로 제어되며 여기서 변경할 수 없습니다.',
+ 'admin.lockoutWarning': '최소 하나의 로그인 방법이 활성화되어 있어야 합니다',
+ 'admin.requireMfa': '2단계 인증 (2FA) 요구',
+ 'admin.requireMfaHint': '2FA가 없는 사용자는 앱을 사용하기 전에 설정에서 설정을 완료해야 합니다.',
+ 'admin.apiKeys': 'API 키',
+ 'admin.apiKeysHint': '선택 사항. 사진 및 날씨 등 확장된 장소 데이터를 활성화합니다.',
+ 'admin.mapsKey': 'Google Maps API 키',
+ 'admin.mapsKeyHint': '장소 검색에 필요합니다. console.cloud.google.com에서 발급',
+ 'admin.mapsKeyHintLong': 'API 키 없이는 장소 검색에 OpenStreetMap이 사용됩니다. Google API 키가 있으면 사진, 평점, 영업 시간도 불러올 수 있습니다. console.cloud.google.com에서 발급하세요.',
+ 'admin.recommended': '권장',
+ 'admin.weatherKey': 'OpenWeatherMap API 키',
+ 'admin.weatherKeyHint': '날씨 데이터용. openweathermap.org에서 무료 발급',
+ 'admin.validateKey': '테스트',
+ 'admin.keyValid': '연결됨',
+ 'admin.keyInvalid': '유효하지 않음',
+ 'admin.keySaved': 'API 키가 저장되었습니다',
+ 'admin.oidcTitle': 'Single Sign-On (OIDC)',
+ 'admin.oidcSubtitle': 'Google, Apple, Authentik 또는 Keycloak 등 외부 공급자를 통한 로그인을 허용합니다.',
+ 'admin.oidcDisplayName': '표시 이름',
+ 'admin.oidcIssuer': '발급자 URL',
+ 'admin.oidcIssuerHint': '공급자의 OpenID Connect 발급자 URL. 예: https://accounts.google.com',
+ 'admin.oidcSaved': 'OIDC 설정이 저장되었습니다',
+ 'admin.oidcOnlyMode': '비밀번호 인증 비활성화',
+ 'admin.oidcOnlyModeHint': '활성화하면 SSO 로그인만 허용됩니다. 비밀번호 기반 로그인 및 회원가입이 차단됩니다.',
+
+ // File Types
+ 'admin.fileTypes': '허용된 파일 형식',
+ 'admin.fileTypesHint': '사용자가 업로드할 수 있는 파일 형식을 설정합니다.',
+ 'admin.fileTypesFormat': '쉼표로 구분된 확장자 (예: jpg,png,pdf,doc). 모든 형식을 허용하려면 *를 사용하세요.',
+ 'admin.fileTypesSaved': '파일 형식 설정이 저장되었습니다',
+
+ 'admin.placesPhotos.title': '장소 사진',
+ 'admin.placesPhotos.subtitle': 'Google Places API에서 사진을 가져옵니다. API 할당량 절약을 위해 비활성화할 수 있습니다. Wikimedia 사진은 영향을 받지 않습니다.',
+ 'admin.placesAutocomplete.title': '장소 자동완성',
+ 'admin.placesAutocomplete.subtitle': '검색 제안에 Google Places API를 사용합니다. API 할당량 절약을 위해 비활성화할 수 있습니다.',
+ 'admin.placesDetails.title': '장소 상세 정보',
+ 'admin.placesDetails.subtitle': 'Google Places API에서 상세 장소 정보 (영업 시간, 평점, 웹사이트)를 가져옵니다. API 할당량 절약을 위해 비활성화할 수 있습니다.',
+ // Packing Templates & Bag Tracking
+ 'admin.bagTracking.title': '가방 추적',
+ 'admin.bagTracking.subtitle': '짐 항목에 무게 및 가방 배정을 활성화합니다',
+ 'admin.collab.chat.title': '채팅',
+ 'admin.collab.chat.subtitle': '여행 협업을 위한 실시간 메시지',
+ 'admin.collab.notes.title': '메모',
+ 'admin.collab.notes.subtitle': '공유 메모 및 문서',
+ 'admin.collab.polls.title': '투표',
+ 'admin.collab.polls.subtitle': '그룹 투표',
+ 'admin.collab.whatsnext.title': '다음 할 일',
+ 'admin.collab.whatsnext.subtitle': '활동 제안 및 다음 단계',
+ 'admin.tabs.config': '개인 설정',
+ 'admin.tabs.defaults': '기본값',
+ 'admin.defaultSettings.title': '기본 사용자 설정',
+ 'admin.defaultSettings.description': '인스턴스 전체 기본값을 설정합니다. 설정을 변경하지 않은 사용자에게 이 값이 표시됩니다. 사용자의 변경 사항이 항상 우선합니다.',
+ 'admin.defaultSettings.saved': '기본값이 저장되었습니다',
+ 'admin.defaultSettings.reset': '기본 내장값으로 초기화',
+ 'admin.defaultSettings.resetToBuiltIn': '초기화',
+ 'admin.tabs.templates': '짐 목록 템플릿',
+ 'admin.packingTemplates.title': '짐 목록 템플릿',
+ 'admin.packingTemplates.subtitle': '여행을 위한 재사용 가능한 짐 목록을 만드세요',
+ 'admin.packingTemplates.create': '새 템플릿',
+ 'admin.packingTemplates.namePlaceholder': '템플릿 이름 (예: 해변 휴가)',
+ 'admin.packingTemplates.empty': '아직 템플릿이 없습니다',
+ 'admin.packingTemplates.items': '항목',
+ 'admin.packingTemplates.categories': '카테고리',
+ 'admin.packingTemplates.itemName': '항목 이름',
+ 'admin.packingTemplates.itemCategory': '카테고리',
+ 'admin.packingTemplates.categoryName': '카테고리 이름 (예: 의류)',
+ 'admin.packingTemplates.addCategory': '카테고리 추가',
+ 'admin.packingTemplates.created': '템플릿이 생성되었습니다',
+ 'admin.packingTemplates.deleted': '템플릿이 삭제되었습니다',
+ 'admin.packingTemplates.loadError': '템플릿 불러오기 실패',
+ 'admin.packingTemplates.createError': '템플릿 생성 실패',
+ 'admin.packingTemplates.deleteError': '템플릿 삭제 실패',
+ 'admin.packingTemplates.saveError': '저장 실패',
+
+ // Addons
+ 'admin.tabs.addons': '애드온',
+ 'admin.addons.title': '애드온',
+ 'admin.addons.subtitle': '기능을 활성화 또는 비활성화하여 TREK 경험을 맞춤 설정하세요.',
+ 'admin.addons.catalog.packing.name': '목록',
+ 'admin.addons.catalog.packing.description': '여행을 위한 짐 목록 및 할 일 작업',
+ 'admin.addons.catalog.budget.name': '예산',
+ 'admin.addons.catalog.budget.description': '지출 추적 및 여행 예산 계획',
+ 'admin.addons.catalog.documents.name': '문서',
+ 'admin.addons.catalog.documents.description': '여행 서류 저장 및 관리',
+ 'admin.addons.catalog.vacay.name': 'Vacay',
+ 'admin.addons.catalog.vacay.description': '캘린더 보기가 있는 개인 휴가 플래너',
+ 'admin.addons.catalog.atlas.name': 'Atlas',
+ 'admin.addons.catalog.atlas.description': '방문한 나라와 여행 통계가 있는 세계 지도',
+ 'admin.addons.catalog.collab.name': 'Collab',
+ 'admin.addons.catalog.collab.description': '여행 계획을 위한 실시간 메모, 투표, 채팅',
+ 'admin.addons.catalog.memories.name': '사진 (Immich)',
+ 'admin.addons.catalog.memories.description': 'Immich 인스턴스를 통해 여행 사진 공유',
+ 'admin.addons.catalog.mcp.name': 'MCP',
+ 'admin.addons.catalog.mcp.description': 'AI 어시스턴트 통합을 위한 모델 컨텍스트 프로토콜',
+ 'admin.addons.subtitleBefore': '기능을 활성화 또는 비활성화하여 ',
+ 'admin.addons.subtitleAfter': ' 경험을 맞춤 설정하세요.',
+ 'admin.addons.enabled': '활성화됨',
+ 'admin.addons.disabled': '비활성화됨',
+ 'admin.addons.type.trip': '여행',
+ 'admin.addons.type.global': '전역',
+ 'admin.addons.type.integration': '통합',
+ 'admin.addons.tripHint': '각 여행 내 탭으로 사용 가능',
+ 'admin.addons.globalHint': '메인 내비게이션의 독립 섹션으로 사용 가능',
+ 'admin.addons.integrationHint': '전용 페이지가 없는 백엔드 서비스 및 API 통합',
+ 'admin.addons.toast.updated': '애드온이 업데이트되었습니다',
+ 'admin.addons.toast.error': '애드온 업데이트 실패',
+ 'admin.addons.noAddons': '사용 가능한 애드온이 없습니다',
+ // Weather info
+ 'admin.weather.title': '날씨 데이터',
+ 'admin.weather.badge': '2026년 3월 24일부터',
+ 'admin.weather.description': 'TREK은 날씨 데이터 소스로 Open-Meteo를 사용합니다. Open-Meteo는 무료 오픈 소스 날씨 서비스로 API 키가 필요 없습니다.',
+ 'admin.weather.forecast': '16일 예보',
+ 'admin.weather.forecastDesc': '이전: 5일 (OpenWeatherMap)',
+ 'admin.weather.climate': '과거 기후 데이터',
+ 'admin.weather.climateDesc': '16일 예보 이후 날짜의 85년 평균값',
+ 'admin.weather.requests': '하루 10,000 요청',
+ 'admin.weather.requestsDesc': '무료, API 키 불필요',
+ 'admin.weather.locationHint': '날씨는 각 날의 좌표가 있는 첫 번째 장소를 기준으로 합니다. 날에 배정된 장소가 없으면 장소 목록의 임의 장소가 참조로 사용됩니다.',
+
+ // GitHub
+ 'admin.tabs.mcpTokens': 'MCP 접근',
+ 'admin.mcpTokens.title': 'MCP 접근',
+ 'admin.mcpTokens.subtitle': '모든 사용자의 OAuth 세션 및 API 토큰을 관리합니다',
+ 'admin.mcpTokens.sectionTitle': 'API 토큰',
+ 'admin.mcpTokens.owner': '소유자',
+ 'admin.mcpTokens.tokenName': '토큰 이름',
+ 'admin.mcpTokens.created': '생성일',
+ 'admin.mcpTokens.lastUsed': '마지막 사용',
+ 'admin.mcpTokens.never': '없음',
+ 'admin.mcpTokens.empty': '생성된 MCP 토큰이 없습니다',
+ 'admin.mcpTokens.deleteTitle': '토큰 삭제',
+ 'admin.mcpTokens.deleteMessage': '토큰이 즉시 취소됩니다. 사용자는 이 토큰을 통한 MCP 접근 권한을 잃게 됩니다.',
+ 'admin.mcpTokens.deleteSuccess': '토큰이 삭제되었습니다',
+ 'admin.mcpTokens.deleteError': '토큰 삭제 실패',
+ 'admin.mcpTokens.loadError': '토큰 불러오기 실패',
+ 'admin.oauthSessions.sectionTitle': 'OAuth 세션',
+ 'admin.oauthSessions.clientName': '클라이언트',
+ 'admin.oauthSessions.owner': '소유자',
+ 'admin.oauthSessions.scopes': '권한 범위',
+ 'admin.oauthSessions.created': '생성일',
+ 'admin.oauthSessions.empty': '활성 OAuth 세션이 없습니다',
+ 'admin.oauthSessions.revokeTitle': '세션 취소',
+ 'admin.oauthSessions.revokeMessage': 'OAuth 세션이 즉시 취소됩니다. 클라이언트는 MCP 접근 권한을 잃게 됩니다.',
+ 'admin.oauthSessions.revokeSuccess': '세션이 취소되었습니다',
+ 'admin.oauthSessions.revokeError': '세션 취소 실패',
+ 'admin.oauthSessions.loadError': 'OAuth 세션 불러오기 실패',
+ 'admin.tabs.github': 'GitHub',
+
+ 'admin.audit.subtitle': '보안 및 관리 이벤트 (백업, 사용자, MFA, 설정).',
+ 'admin.audit.empty': '아직 감사 항목이 없습니다.',
+ 'admin.audit.refresh': '새로 고침',
+ 'admin.audit.loadMore': '더 불러오기',
+ 'admin.audit.showing': '{count}개 불러옴 · 총 {total}개',
+ 'admin.audit.col.time': '시간',
+ 'admin.audit.col.user': '사용자',
+ 'admin.audit.col.action': '작업',
+ 'admin.audit.col.resource': '리소스',
+ 'admin.audit.col.ip': 'IP',
+ 'admin.audit.col.details': '상세',
+ 'admin.github.title': '릴리스 히스토리',
+ 'admin.github.subtitle': '{repo}의 최신 업데이트',
+ 'admin.github.latest': '최신',
+ 'admin.github.prerelease': '사전 릴리스',
+ 'admin.github.showDetails': '상세 보기',
+ 'admin.github.hideDetails': '상세 숨기기',
+ 'admin.github.loadMore': '더 불러오기',
+ 'admin.github.loading': '불러오는 중...',
+ 'admin.github.error': '릴리스 불러오기 실패',
+ 'admin.github.by': '작성자',
+ 'admin.github.support': 'TREK 개발 지속에 도움이 됩니다',
+
+ 'admin.update.available': '업데이트 사용 가능',
+ 'admin.update.text': 'TREK {version}이(가) 사용 가능합니다. 현재 {current}을(를) 실행 중입니다.',
+ 'admin.update.button': 'GitHub에서 보기',
+ 'admin.update.install': '업데이트 설치',
+ 'admin.update.confirmTitle': '업데이트를 설치할까요?',
+ 'admin.update.confirmText': 'TREK이 {current}에서 {version}으로 업데이트됩니다. 서버가 이후 자동으로 재시작됩니다.',
+ 'admin.update.dataInfo': '모든 데이터 (여행, 사용자, API 키, 업로드, Vacay, Atlas, 예산)가 보존됩니다.',
+ 'admin.update.warning': '재시작 중에 앱이 잠시 사용할 수 없게 됩니다.',
+ 'admin.update.confirm': '지금 업데이트',
+ 'admin.update.installing': '업데이트 중…',
+ 'admin.update.success': '업데이트 완료! 서버가 재시작 중입니다…',
+ 'admin.update.failed': '업데이트 실패',
+ 'admin.update.backupHint': '업데이트 전에 백업을 생성하는 것을 권장합니다.',
+ 'admin.update.backupLink': '백업으로 이동',
+ 'admin.update.howTo': '업데이트 방법',
+ 'admin.update.dockerText': 'TREK 인스턴스가 Docker에서 실행 중입니다. {version}으로 업데이트하려면 서버에서 다음 명령을 실행하세요:',
+ 'admin.update.reloadHint': '잠시 후 페이지를 새로 고침하세요.',
+
+ // Vacay addon
+ 'vacay.subtitle': '휴가 일수를 계획하고 관리하세요',
+ 'vacay.settings': '설정',
+ 'vacay.year': '연도',
+ 'vacay.addYear': '다음 연도 추가',
+ 'vacay.addPrevYear': '이전 연도 추가',
+ 'vacay.removeYear': '연도 제거',
+ 'vacay.removeYearConfirm': '{year}을(를) 제거할까요?',
+ 'vacay.removeYearHint': '이 연도의 모든 휴가 항목 및 회사 공휴일이 영구 삭제됩니다.',
+ 'vacay.remove': '제거',
+ 'vacay.persons': '인원',
+ 'vacay.noPersons': '추가된 인원이 없습니다',
+ 'vacay.addPerson': '인원 추가',
+ 'vacay.editPerson': '인원 편집',
+ 'vacay.removePerson': '인원 제거',
+ 'vacay.removePersonConfirm': '{name}을(를) 제거할까요?',
+ 'vacay.removePersonHint': '이 인원의 모든 휴가 항목이 영구 삭제됩니다.',
+ 'vacay.personName': '이름',
+ 'vacay.personNamePlaceholder': '이름 입력',
+ 'vacay.color': '색상',
+ 'vacay.add': '추가',
+ 'vacay.legend': '범례',
+ 'vacay.publicHoliday': '공휴일',
+ 'vacay.companyHoliday': '회사 휴일',
+ 'vacay.weekend': '주말',
+ 'vacay.modeVacation': '휴가',
+ 'vacay.modeCompany': '회사 휴일',
+ 'vacay.entitlement': '휴가 일수',
+ 'vacay.entitlementDays': '일',
+ 'vacay.used': '사용',
+ 'vacay.remaining': '남음',
+ 'vacay.carriedOver': '{year}에서 이월',
+ 'vacay.blockWeekends': '주말 차단',
+ 'vacay.blockWeekendsHint': '주말에 휴가 항목 추가를 방지합니다',
+ 'vacay.weekendDays': '주말 요일',
+ 'vacay.mon': '월',
+ 'vacay.tue': '화',
+ 'vacay.wed': '수',
+ 'vacay.thu': '목',
+ 'vacay.fri': '금',
+ 'vacay.sat': '토',
+ 'vacay.sun': '일',
+ 'vacay.publicHolidays': '공휴일',
+ 'vacay.publicHolidaysHint': '캘린더에 공휴일을 표시합니다',
+ 'vacay.selectCountry': '국가 선택',
+ 'vacay.selectRegion': '지역 선택 (선택)',
+ 'vacay.addCalendar': '캘린더 추가',
+ 'vacay.calendarLabel': '레이블 (선택)',
+ 'vacay.calendarColor': '색상',
+ 'vacay.noCalendars': '아직 공휴일 캘린더가 없습니다',
+ 'vacay.companyHolidays': '회사 휴일',
+ 'vacay.companyHolidaysHint': '회사 전체 휴일 표시를 허용합니다',
+ 'vacay.companyHolidaysNoDeduct': '회사 휴일은 휴가 일수에서 차감되지 않습니다.',
+ 'vacay.weekStart': '주 시작 요일',
+ 'vacay.weekStartHint': '캘린더 주가 월요일 또는 일요일에 시작할지 선택하세요',
+ 'vacay.carryOver': '이월',
+ 'vacay.carryOverHint': '남은 휴가 일수를 다음 연도로 자동 이월합니다',
+ 'vacay.sharing': '공유',
+ 'vacay.sharingHint': '다른 TREK 사용자와 휴가 계획을 공유합니다',
+ 'vacay.owner': '소유자',
+ 'vacay.shareEmailPlaceholder': 'TREK 사용자 이메일',
+ 'vacay.shareSuccess': '계획이 성공적으로 공유되었습니다',
+ 'vacay.shareError': '계획 공유 실패',
+ 'vacay.dissolve': '퓨전 해제',
+ 'vacay.dissolveHint': '캘린더를 다시 분리합니다. 항목은 유지됩니다.',
+ 'vacay.dissolveAction': '해제',
+ 'vacay.dissolved': '캘린더가 분리되었습니다',
+ 'vacay.fusedWith': '퓨전됨',
+ 'vacay.you': '나',
+ 'vacay.noData': '데이터 없음',
+ 'vacay.changeColor': '색상 변경',
+ 'vacay.inviteUser': '사용자 초대',
+ 'vacay.inviteHint': '다른 TREK 사용자를 초대하여 통합 휴가 캘린더를 공유하세요.',
+ 'vacay.selectUser': '사용자 선택',
+ 'vacay.sendInvite': '초대 전송',
+ 'vacay.inviteSent': '초대가 전송되었습니다',
+ 'vacay.inviteError': '초대 전송 실패',
+ 'vacay.pending': '대기 중',
+ 'vacay.noUsersAvailable': '사용 가능한 사용자가 없습니다',
+ 'vacay.accept': '수락',
+ 'vacay.decline': '거절',
+ 'vacay.acceptFusion': '수락 및 퓨전',
+ 'vacay.inviteTitle': '퓨전 요청',
+ 'vacay.inviteWantsToFuse': '귀하와 휴가 캘린더를 공유하고 싶어 합니다.',
+ 'vacay.fuseInfo1': '두 사람 모두 하나의 공유 캘린더에서 모든 휴가 항목을 볼 수 있습니다.',
+ 'vacay.fuseInfo2': '양측 모두 서로의 항목을 만들고 편집할 수 있습니다.',
+ 'vacay.fuseInfo3': '양측 모두 항목을 삭제하고 휴가 일수를 변경할 수 있습니다.',
+ 'vacay.fuseInfo4': '공휴일 및 회사 휴일 등의 설정이 공유됩니다.',
+ 'vacay.fuseInfo5': '어느 쪽이든 언제든지 퓨전을 해제할 수 있습니다. 항목은 보존됩니다.',
+ 'nav.myTrips': '내 여행',
+
+ // Atlas addon
+ 'atlas.subtitle': '전 세계의 나의 여행 발자취',
+ 'atlas.countries': '국가',
+ 'atlas.trips': '여행',
+ 'atlas.places': '장소',
+ 'atlas.unmark': '제거',
+ 'atlas.confirmMark': '이 나라를 방문한 곳으로 표시할까요?',
+ 'atlas.confirmUnmark': '방문 목록에서 이 나라를 제거할까요?',
+ 'atlas.confirmUnmarkRegion': '방문 목록에서 이 지역을 제거할까요?',
+ 'atlas.markVisited': '방문으로 표시',
+ 'atlas.markVisitedHint': '방문 목록에 이 나라를 추가합니다',
+ 'atlas.markRegionVisitedHint': '방문 목록에 이 지역을 추가합니다',
+ 'atlas.addToBucket': '버킷 리스트에 추가',
+ 'atlas.addPoi': '장소 추가',
+ 'atlas.searchCountry': '국가 검색...',
+ 'atlas.bucketNamePlaceholder': '이름 (국가, 도시, 장소...)',
+ 'atlas.month': '월',
+ 'atlas.year': '연도',
+ 'atlas.addToBucketHint': '방문하고 싶은 장소로 저장',
+ 'atlas.bucketWhen': '방문 예정 시기는 언제인가요?',
+ 'atlas.statsTab': '통계',
+ 'atlas.bucketTab': '버킷 리스트',
+ 'atlas.addBucket': '버킷 리스트에 추가',
+ 'atlas.bucketNotesPlaceholder': '메모 (선택)',
+ 'atlas.bucketEmpty': '버킷 리스트가 비어 있습니다',
+ 'atlas.bucketEmptyHint': '꿈꾸는 방문지를 추가하세요',
+ 'atlas.days': '일',
+ 'atlas.visitedCountries': '방문한 나라',
+ 'atlas.cities': '도시',
+ 'atlas.noData': '아직 여행 데이터가 없습니다',
+ 'atlas.noDataHint': '여행을 만들고 장소를 추가하여 세계 지도를 확인하세요',
+ 'atlas.lastTrip': '마지막 여행',
+ 'atlas.nextTrip': '다음 여행',
+ 'atlas.daysLeft': '일 남음',
+ 'atlas.streak': '연속',
+ 'atlas.years': '년',
+ 'atlas.yearInRow': '연 연속',
+ 'atlas.yearsInRow': '년 연속',
+ 'atlas.tripIn': '에서 여행',
+ 'atlas.tripsIn': '에서 여행',
+ 'atlas.since': '부터',
+ 'atlas.europe': '유럽',
+ 'atlas.asia': '아시아',
+ 'atlas.northAmerica': '북미',
+ 'atlas.southAmerica': '남미',
+ 'atlas.africa': '아프리카',
+ 'atlas.oceania': '오세아니아',
+ 'atlas.other': '기타',
+ 'atlas.firstVisit': '첫 번째 여행',
+ 'atlas.lastVisitLabel': '마지막 여행',
+ 'atlas.tripSingular': '여행',
+ 'atlas.tripPlural': '여행',
+ 'atlas.placeVisited': '방문한 장소',
+ 'atlas.placesVisited': '방문한 장소',
+
+ // Trip Planner
+ 'trip.tabs.plan': '계획',
+ 'trip.tabs.transports': '교통',
+ 'trip.tabs.reservations': '예약',
+ 'trip.tabs.reservationsShort': '예약',
+ 'trip.tabs.packing': '짐 목록',
+ 'trip.tabs.packingShort': '짐',
+ 'trip.tabs.lists': '목록',
+ 'trip.tabs.listsShort': '목록',
+ 'trip.tabs.budget': '예산',
+ 'trip.tabs.files': '파일',
+ 'trip.loading': '여행 불러오는 중...',
+ 'trip.loadingPhotos': '장소 사진 불러오는 중...',
+ 'trip.mobilePlan': '계획',
+ 'trip.mobilePlaces': '장소',
+ 'trip.toast.placeUpdated': '장소가 업데이트되었습니다',
+ 'trip.toast.placeAdded': '장소가 추가되었습니다',
+ 'trip.toast.placeDeleted': '장소가 삭제되었습니다',
+ 'trip.toast.selectDay': '먼저 날을 선택하세요',
+ 'trip.toast.assignedToDay': '장소가 날에 배정되었습니다',
+ 'trip.toast.reorderError': '순서 변경 실패',
+ 'trip.toast.reservationUpdated': '예약이 업데이트되었습니다',
+ 'trip.toast.reservationAdded': '예약이 추가되었습니다',
+ 'trip.toast.deleted': '삭제됨',
+ 'trip.confirm.deletePlace': '이 장소를 삭제할까요?',
+ 'trip.confirm.deletePlaces': '{count}개 장소를 삭제할까요?',
+ 'trip.toast.placesDeleted': '{count}개 장소가 삭제되었습니다',
+
+ // Day Plan Sidebar
+ 'dayplan.emptyDay': '이 날에 계획된 장소가 없습니다',
+ 'dayplan.cannotReorderTransport': '고정된 시간이 있는 예약은 순서를 변경할 수 없습니다',
+ 'dayplan.confirmRemoveTimeTitle': '시간을 제거할까요?',
+ 'dayplan.confirmRemoveTimeBody': '이 장소에 고정된 시간 ({time})이 있습니다. 이동하면 시간이 제거되고 자유 정렬이 허용됩니다.',
+ 'dayplan.confirmRemoveTimeAction': '시간 제거 및 이동',
+ 'dayplan.cannotDropOnTimed': '시간이 고정된 항목 사이에 배치할 수 없습니다',
+ 'dayplan.cannotBreakChronology': '이 작업은 시간 고정 항목과 예약의 시간 순서를 깨뜨립니다',
+ 'dayplan.addNote': '메모 추가',
+ 'dayplan.expandAll': '모든 날 펼치기',
+ 'dayplan.collapseAll': '모든 날 접기',
+ 'dayplan.editNote': '메모 편집',
+ 'dayplan.noteAdd': '메모 추가',
+ 'dayplan.noteEdit': '메모 편집',
+ 'dayplan.noteTitle': '메모',
+ 'dayplan.noteSubtitle': '일별 메모',
+ 'dayplan.totalCost': '총 비용',
+ 'dayplan.days': '일',
+ 'dayplan.dayN': '{n}일차',
+ 'dayplan.calculating': '계산 중...',
+ 'dayplan.route': '경로',
+ 'dayplan.optimize': '최적화',
+ 'dayplan.optimized': '경로가 최적화되었습니다',
+ 'dayplan.routeError': '경로 계산 실패',
+ 'dayplan.toast.needTwoPlaces': '경로 최적화에는 최소 두 개의 장소가 필요합니다',
+ 'dayplan.toast.routeOptimized': '경로가 최적화되었습니다',
+ 'dayplan.toast.noGeoPlaces': '경로 계산을 위한 좌표가 있는 장소가 없습니다',
+ 'dayplan.confirmed': '확정됨',
+ 'dayplan.pendingRes': '대기 중',
+ 'dayplan.pdf': 'PDF',
+ 'dayplan.pdfTooltip': '일별 계획을 PDF로 내보내기',
+ 'dayplan.pdfError': 'PDF 내보내기 실패',
+
+ // Places Sidebar
+ 'places.addPlace': '장소/활동 추가',
+ 'places.importFile': '파일 가져오기',
+ 'places.sidebarDrop': '끌어다 가져오기',
+ 'places.importFileHint': 'Google My Maps, Google Earth 또는 GPS 추적기 등의 .gpx, .kml, .kmz 파일을 가져옵니다.',
+ 'places.importFileDropHere': '파일을 선택하거나 여기에 끌어다 놓으세요',
+ 'places.importFileDropActive': '파일을 놓으세요',
+ 'places.importFileUnsupported': '지원하지 않는 파일 형식입니다. .gpx, .kml 또는 .kmz를 사용하세요.',
+ 'places.importFileTooLarge': '파일이 너무 큽니다. 최대 업로드 크기는 {maxMb} MB입니다.',
+ 'places.importFileError': '가져오기 실패',
+ 'places.importAllSkipped': '모든 장소가 이미 여행에 포함되어 있습니다.',
+ 'places.gpxImported': 'GPX에서 {count}개 장소를 가져왔습니다',
+ 'places.gpxImportTypes': '무엇을 가져올까요?',
+ 'places.gpxImportWaypoints': '웨이포인트',
+ 'places.gpxImportRoutes': '경로',
+ 'places.gpxImportTracks': '트랙 (경로 형상 포함)',
+ 'places.gpxImportNoneSelected': '가져올 유형을 하나 이상 선택하세요.',
+ 'places.kmlImportTypes': '무엇을 가져올까요?',
+ 'places.kmlImportPoints': '포인트 (Placemarks)',
+ 'places.kmlImportPaths': '경로 (LineStrings)',
+ 'places.kmlImportNoneSelected': '가져올 유형을 하나 이상 선택하세요.',
+ 'places.selectionCount': '{count}개 선택됨',
+ 'places.deleteSelected': '선택 항목 삭제',
+ 'places.kmlKmzImported': 'KMZ/KML에서 {count}개 장소를 가져왔습니다',
+ 'places.urlResolved': 'URL에서 장소를 가져왔습니다',
+ 'places.importList': '목록 가져오기',
+ 'places.kmlKmzSummaryValues': '총 Placemarks: {total} · 가져옴: {created} · 건너뜀: {skipped}',
+ 'places.importGoogleList': 'Google 목록',
+ 'places.importNaverList': '네이버 목록',
+ 'places.googleListHint': '공유된 Google Maps 목록 링크를 붙여넣어 모든 장소를 가져옵니다.',
+ 'places.googleListImported': '"{list}"에서 {count}개 장소를 가져왔습니다',
+ 'places.googleListError': 'Google Maps 목록 가져오기 실패',
+ 'places.naverListHint': '공유된 네이버 지도 목록 링크를 붙여넣어 모든 장소를 가져옵니다.',
+ 'places.naverListImported': '"{list}"에서 {count}개 장소를 가져왔습니다',
+ 'places.naverListError': '네이버 지도 목록 가져오기 실패',
+ 'places.viewDetails': '상세 보기',
+ 'places.assignToDay': '어느 날에 추가할까요?',
+ 'places.all': '전체',
+ 'places.unplanned': '미계획',
+ 'places.filterTracks': '트랙',
+ 'places.search': '장소 검색...',
+ 'places.allCategories': '모든 카테고리',
+ 'places.categoriesSelected': '카테고리',
+ 'places.clearFilter': '필터 지우기',
+ 'places.count': '장소 {count}개',
+ 'places.countSingular': '장소 1개',
+ 'places.allPlanned': '모든 장소가 계획되었습니다',
+ 'places.noneFound': '장소를 찾을 수 없습니다',
+ 'places.editPlace': '장소 편집',
+ 'places.formName': '이름',
+ 'places.formNamePlaceholder': '예: 에펠탑',
+ 'places.formDescription': '설명',
+ 'places.formDescriptionPlaceholder': '간단한 설명...',
+ 'places.formAddress': '주소',
+ 'places.formAddressPlaceholder': '도로, 도시, 국가',
+ 'places.formLat': '위도 (예: 48.8566)',
+ 'places.formLng': '경도 (예: 2.3522)',
+ 'places.formCategory': '카테고리',
+ 'places.noCategory': '카테고리 없음',
+ 'places.categoryNamePlaceholder': '카테고리 이름',
+ 'places.formTime': '시간',
+ 'places.startTime': '시작',
+ 'places.endTime': '종료',
+ 'places.endTimeBeforeStart': '종료 시간이 시작 시간보다 앞입니다',
+ 'places.timeCollision': '시간 겹침:',
+ 'places.formWebsite': '웹사이트',
+ 'places.formNotes': '메모',
+ 'places.formNotesPlaceholder': '개인 메모...',
+ 'places.formReservation': '예약',
+ 'places.reservationNotesPlaceholder': '예약 메모, 확인 번호...',
+ 'places.mapsSearchPlaceholder': '장소 검색...',
+ 'places.mapsSearchError': '장소 검색 실패.',
+ 'places.loadingDetails': '장소 상세 정보 불러오는 중…',
+ 'places.osmHint': 'OpenStreetMap 검색 사용 중 (사진, 영업 시간, 평점 없음). 전체 정보를 위해 설정에서 Google API 키를 추가하세요.',
+ 'places.osmActive': 'OpenStreetMap으로 검색 중 (사진, 평점, 영업 시간 없음). 향상된 데이터를 위해 설정에서 Google API 키를 추가하세요.',
+ 'places.categoryCreateError': '카테고리 생성 실패',
+ 'places.nameRequired': '이름을 입력하세요',
+ 'places.saveError': '저장 실패',
+ // Place Inspector
+ 'inspector.opened': '영업 중',
+ 'inspector.closed': '영업 종료',
+ 'inspector.openingHours': '영업 시간',
+ 'inspector.showHours': '영업 시간 보기',
+ 'inspector.files': '파일',
+ 'inspector.filesCount': '{count}개 파일',
+ 'inspector.remove': '제거',
+ 'inspector.removeFromDay': '날에서 제거',
+ 'inspector.addToDay': '날에 추가',
+ 'inspector.confirmedRes': '확정된 예약',
+ 'inspector.pendingRes': '대기 중인 예약',
+ 'inspector.google': 'Google Maps에서 열기',
+ 'inspector.website': '웹사이트 열기',
+ 'inspector.addRes': '예약',
+ 'inspector.editRes': '예약 편집',
+ 'inspector.participants': '참가자',
+ 'inspector.trackStats': '트랙 통계',
+
+ // Reservations
+ 'reservations.title': '예약',
+ 'reservations.empty': '아직 예약이 없습니다',
+ 'reservations.emptyHint': '항공, 호텔 등의 예약을 추가하세요',
+ 'reservations.add': '예약 추가',
+ 'reservations.addManual': '직접 예약',
+ 'reservations.placeHint': '팁: 예약은 일별 계획과 연결하기 위해 장소에서 직접 만드는 것이 가장 좋습니다.',
+ 'reservations.confirmed': '확정됨',
+ 'reservations.pending': '대기 중',
+ 'reservations.summary': '{confirmed}개 확정, {pending}개 대기 중',
+ 'reservations.fromPlan': '계획에서',
+ 'reservations.showFiles': '파일 보기',
+ 'reservations.editTitle': '예약 편집',
+ 'reservations.status': '상태',
+ 'reservations.datetime': '날짜 및 시간',
+ 'reservations.startTime': '시작 시간',
+ 'reservations.endTime': '종료 시간',
+ 'reservations.date': '날짜',
+ 'reservations.time': '시간',
+ 'reservations.timeAlt': '시간 (대안, 예: 19:30)',
+ 'reservations.notes': '메모',
+ 'reservations.notesPlaceholder': '추가 메모...',
+ 'reservations.meta.airline': '항공사',
+ 'reservations.meta.flightNumber': '항공편 번호',
+ 'reservations.meta.from': '출발',
+ 'reservations.meta.to': '도착',
+ 'reservations.needsReview': '검토 필요',
+ 'reservations.needsReviewHint': '공항이 자동으로 매칭되지 않았습니다 — 위치를 확인해 주세요.',
+ 'reservations.searchLocation': '역, 항구, 주소 검색…',
+ 'airport.searchPlaceholder': '공항 코드 또는 도시 (예: ICN)',
+ 'map.connections': '연결',
+ 'map.showConnections': '예약 경로 표시',
+ 'map.hideConnections': '예약 경로 숨기기',
+ 'reservations.meta.trainNumber': '열차 번호',
+ 'reservations.meta.platform': '플랫폼',
+ 'reservations.meta.seat': '좌석',
+ 'reservations.meta.checkIn': '체크인',
+ 'reservations.meta.checkInUntil': '체크인 마감',
+ 'reservations.meta.checkOut': '체크아웃',
+ 'reservations.meta.linkAccommodation': '숙박',
+ 'reservations.meta.pickAccommodation': '숙박 연결',
+ 'reservations.meta.noAccommodation': '없음',
+ 'reservations.meta.hotelPlace': '숙박',
+ 'reservations.meta.pickHotel': '숙박 선택',
+ 'reservations.meta.fromDay': '부터',
+ 'reservations.meta.toDay': '까지',
+ 'reservations.meta.selectDay': '날 선택',
+ 'reservations.type.flight': '항공',
+ 'reservations.type.hotel': '숙박',
+ 'reservations.type.restaurant': '레스토랑',
+ 'reservations.type.train': '기차',
+ 'reservations.type.car': '차량',
+ 'reservations.type.cruise': '크루즈',
+ 'reservations.type.event': '이벤트',
+ 'reservations.type.tour': '투어',
+ 'reservations.type.other': '기타',
+ 'reservations.confirm.delete': '예약 "{name}"을(를) 삭제할까요?',
+ 'reservations.confirm.deleteTitle': '예약을 삭제할까요?',
+ 'reservations.confirm.deleteBody': '"{name}"이(가) 영구 삭제됩니다.',
+ 'reservations.toast.updated': '예약이 업데이트되었습니다',
+ 'reservations.toast.removed': '예약이 삭제되었습니다',
+ 'reservations.toast.fileUploaded': '파일이 업로드되었습니다',
+ 'reservations.toast.uploadError': '업로드 실패',
+ 'reservations.newTitle': '새 예약',
+ 'reservations.bookingType': '예약 유형',
+ 'reservations.titleLabel': '제목',
+ 'reservations.titlePlaceholder': '예: 대한항공 KE123, 호텔 신라, ...',
+ 'reservations.locationAddress': '위치 / 주소',
+ 'reservations.locationPlaceholder': '주소, 공항, 호텔...',
+ 'reservations.confirmationCode': '예약 코드',
+ 'reservations.confirmationPlaceholder': '예: ABC12345',
+ 'reservations.day': '날',
+ 'reservations.noDay': '날 없음',
+ 'reservations.place': '장소',
+ 'reservations.noPlace': '장소 없음',
+ 'reservations.pendingSave': '저장될 예정…',
+ 'reservations.uploading': '업로드 중...',
+ 'reservations.attachFile': '파일 첨부',
+ 'reservations.linkExisting': '기존 파일 연결',
+ 'reservations.toast.saveError': '저장 실패',
+ 'reservations.toast.updateError': '업데이트 실패',
+ 'reservations.toast.deleteError': '삭제 실패',
+ 'reservations.confirm.remove': '"{name}"의 예약을 제거할까요?',
+ 'reservations.linkAssignment': '날 배정에 연결',
+ 'reservations.pickAssignment': '계획에서 배정을 선택하세요...',
+ 'reservations.noAssignment': '연결 없음 (독립)',
+ 'reservations.price': '가격',
+ 'reservations.budgetCategory': '예산 카테고리',
+ 'reservations.budgetCategoryPlaceholder': '예: 교통, 숙박',
+ 'reservations.budgetCategoryAuto': '자동 (예약 유형에서)',
+ 'reservations.budgetHint': '저장 시 예산 항목이 자동으로 생성됩니다.',
+ 'reservations.departureDate': '출발',
+ 'reservations.arrivalDate': '도착',
+ 'reservations.departureTime': '출발 시간',
+ 'reservations.arrivalTime': '도착 시간',
+ 'reservations.pickupDate': '픽업',
+ 'reservations.returnDate': '반납',
+ 'reservations.pickupTime': '픽업 시간',
+ 'reservations.returnTime': '반납 시간',
+ 'reservations.endDate': '종료 날짜',
+ 'reservations.meta.departureTimezone': '출발 시간대',
+ 'reservations.meta.arrivalTimezone': '도착 시간대',
+ 'reservations.span.departure': '출발',
+ 'reservations.span.arrival': '도착',
+ 'reservations.span.inTransit': '이동 중',
+ 'reservations.span.pickup': '픽업',
+ 'reservations.span.return': '반납',
+ 'reservations.span.active': '활성',
+ 'reservations.span.start': '시작',
+ 'reservations.span.end': '종료',
+ 'reservations.span.ongoing': '진행 중',
+ 'reservations.validation.endBeforeStart': '종료 날짜/시간은 시작 날짜/시간 이후여야 합니다',
+ 'reservations.addBooking': '예약 추가',
+
+ // Budget
+ 'budget.title': '예산',
+ 'budget.exportCsv': 'CSV 내보내기',
+ 'budget.emptyTitle': '아직 예산이 없습니다',
+ 'budget.emptyText': '카테고리와 항목을 만들어 여행 예산을 계획하세요',
+ 'budget.emptyPlaceholder': '카테고리 이름을 입력하세요...',
+ 'budget.createCategory': '카테고리 만들기',
+ 'budget.category': '카테고리',
+ 'budget.categoryName': '카테고리 이름',
+ 'budget.table.name': '이름',
+ 'budget.table.total': '합계',
+ 'budget.table.persons': '인원',
+ 'budget.table.days': '일수',
+ 'budget.table.perPerson': '1인당',
+ 'budget.table.perDay': '일당',
+ 'budget.table.perPersonDay': '1인/일',
+ 'budget.table.note': '메모',
+ 'budget.table.date': '날짜',
+ 'budget.newEntry': '새 항목',
+ 'budget.defaultEntry': '새 항목',
+ 'budget.defaultCategory': '새 카테고리',
+ 'budget.total': '합계',
+ 'budget.totalBudget': '총 예산',
+ 'budget.byCategory': '카테고리별',
+ 'budget.editTooltip': '클릭하여 편집',
+ 'budget.linkedToReservation': '예약에 연결됨 — 이름은 예약에서 편집하세요',
+ 'budget.confirm.deleteCategory': '카테고리 "{name}"을(를) {count}개 항목과 함께 삭제할까요?',
+ 'budget.deleteCategory': '카테고리 삭제',
+ 'budget.perPerson': '1인당',
+ 'budget.paid': '지불됨',
+ 'budget.open': '미결',
+ 'budget.noMembers': '배정된 멤버가 없습니다',
+ 'budget.settlement': '정산',
+ 'budget.settlementInfo': '예산 항목의 멤버 아바타를 클릭하면 녹색으로 표시됩니다 — 해당 멤버가 지불했음을 의미합니다. 그러면 정산에서 누가 누구에게 얼마를 지불해야 하는지 보여줍니다.',
+ 'budget.netBalances': '순 잔액',
+
+ // Files
+ 'files.title': '파일',
+ 'files.pageTitle': '파일 및 서류',
+ 'files.subtitle': '{trip}의 파일 {count}개',
+ 'files.download': '다운로드',
+ 'files.openError': '파일을 열 수 없습니다',
+ 'files.downloadPdf': 'PDF 다운로드',
+ 'files.count': '파일 {count}개',
+ 'files.countSingular': '파일 1개',
+ 'files.uploaded': '{count}개 업로드됨',
+ 'files.uploadError': '업로드 실패',
+ 'files.dropzone': '여기에 파일을 놓으세요',
+ 'files.dropzoneHint': '또는 클릭하여 탐색',
+ 'files.allowedTypes': '이미지, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · 최대 50 MB',
+ 'files.uploading': '업로드 중...',
+ 'files.filterAll': '전체',
+ 'files.filterPdf': 'PDF',
+ 'files.filterImages': '이미지',
+ 'files.filterDocs': '문서',
+ 'files.filterCollab': 'Collab 메모',
+ 'files.sourceCollab': 'Collab 메모에서',
+ 'files.empty': '아직 파일이 없습니다',
+ 'files.emptyHint': '파일을 업로드하여 여행에 첨부하세요',
+ 'files.openTab': '새 탭에서 열기',
+ 'files.confirm.delete': '이 파일을 삭제할까요?',
+ 'files.toast.deleted': '파일이 삭제되었습니다',
+ 'files.toast.deleteError': '파일 삭제 실패',
+ 'files.sourcePlan': '일별 계획',
+ 'files.sourceBooking': '예약',
+ 'files.sourceTransport': '교통',
+ 'files.attach': '첨부',
+ 'files.pasteHint': '클립보드에서 이미지를 붙여넣을 수도 있습니다 (Ctrl+V)',
+ 'files.trash': '휴지통',
+ 'files.trashEmpty': '휴지통이 비어 있습니다',
+ 'files.emptyTrash': '휴지통 비우기',
+ 'files.restore': '복원',
+ 'files.star': '즐겨찾기',
+ 'files.unstar': '즐겨찾기 해제',
+ 'files.assign': '배정',
+ 'files.assignTitle': '파일 배정',
+ 'files.assignPlace': '장소',
+ 'files.assignBooking': '예약',
+ 'files.assignTransport': '교통',
+ 'files.unassigned': '미배정',
+ 'files.unlink': '연결 해제',
+ 'files.toast.trashed': '휴지통으로 이동됨',
+ 'files.toast.restored': '파일이 복원되었습니다',
+ 'files.toast.trashEmptied': '휴지통이 비워졌습니다',
+ 'files.toast.assigned': '파일이 배정되었습니다',
+ 'files.toast.assignError': '배정 실패',
+ 'files.toast.restoreError': '복원 실패',
+ 'files.confirm.permanentDelete': '이 파일을 영구 삭제할까요? 이 작업은 취소할 수 없습니다.',
+ 'files.confirm.emptyTrash': '휴지통의 모든 파일을 영구 삭제할까요? 이 작업은 취소할 수 없습니다.',
+ 'files.noteLabel': '메모',
+ 'files.notePlaceholder': '메모 추가...',
+
+ // Packing
+ 'packing.title': '짐 목록',
+ 'packing.empty': '짐 목록이 비어 있습니다',
+ 'packing.import': '가져오기',
+ 'packing.importTitle': '짐 목록 가져오기',
+ 'packing.importHint': '한 줄에 하나의 항목. 형식: 카테고리, 이름, 무게(g, 선택), 가방(선택), checked/unchecked(선택)',
+ 'packing.importPlaceholder': '위생, 칫솔\n의류, 티셔츠, 200\n서류, 여권, , 기내 수하물\n전자기기, 충전기, 50, 캐리어, checked',
+ 'packing.importCsv': 'CSV/TXT 불러오기',
+ 'packing.importAction': '{count}개 가져오기',
+ 'packing.importSuccess': '{count}개 항목을 가져왔습니다',
+ 'packing.importError': '가져오기 실패',
+ 'packing.importEmpty': '가져올 항목이 없습니다',
+ 'packing.progress': '{total}개 중 {packed}개 완료 ({percent}%)',
+ 'packing.clearChecked': '체크된 {count}개 제거',
+ 'packing.clearCheckedShort': '{count}개 제거',
+ 'packing.suggestions': '제안',
+ 'packing.suggestionsTitle': '제안 추가',
+ 'packing.allSuggested': '모든 제안이 추가되었습니다',
+ 'packing.allPacked': '모두 완료!',
+ 'packing.addPlaceholder': '새 항목 추가...',
+ 'packing.categoryPlaceholder': '카테고리...',
+ 'packing.filterAll': '전체',
+ 'packing.filterOpen': '미완료',
+ 'packing.filterDone': '완료',
+ 'packing.emptyTitle': '짐 목록이 비어 있습니다',
+ 'packing.emptyHint': '항목을 추가하거나 제안을 사용하세요',
+ 'packing.emptyFiltered': '이 필터와 일치하는 항목이 없습니다',
+ 'packing.menuRename': '이름 변경',
+ 'packing.menuCheckAll': '전체 체크',
+ 'packing.menuUncheckAll': '전체 체크 해제',
+ 'packing.menuDeleteCat': '카테고리 삭제',
+ 'packing.noMembers': '여행 멤버가 없습니다',
+ 'packing.addItem': '항목 추가',
+ 'packing.addItemPlaceholder': '항목 이름...',
+ 'packing.addCategory': '카테고리 추가',
+ 'packing.newCategoryPlaceholder': '카테고리 이름 (예: 의류)',
+ 'packing.applyTemplate': '템플릿 적용',
+ 'packing.template': '템플릿',
+ 'packing.templateApplied': '템플릿에서 {count}개 항목이 추가되었습니다',
+ 'packing.templateError': '템플릿 적용 실패',
+ 'packing.saveAsTemplate': '템플릿으로 저장',
+ 'packing.templateName': '템플릿 이름',
+ 'packing.templateSaved': '짐 목록이 템플릿으로 저장되었습니다',
+ 'packing.bags': '가방',
+ 'packing.noBag': '미배정',
+ 'packing.totalWeight': '총 무게',
+ 'packing.bagName': '가방 이름...',
+ 'packing.addBag': '가방 추가',
+ 'packing.changeCategory': '카테고리 변경',
+ 'packing.confirm.clearChecked': '체크된 {count}개 항목을 제거할까요?',
+ 'packing.confirm.deleteCat': '카테고리 "{name}"을(를) {count}개 항목과 함께 삭제할까요?',
+ 'packing.defaultCategory': '기타',
+ 'packing.toast.saveError': '저장 실패',
+ 'packing.toast.deleteError': '삭제 실패',
+ 'packing.toast.renameError': '이름 변경 실패',
+ 'packing.toast.addError': '추가 실패',
+
+ // Packing suggestions
+ 'packing.suggestions.items': [
+ { name: '여권', category: '서류' },
+ { name: '신분증', category: '서류' },
+ { name: '여행자 보험', category: '서류' },
+ { name: '항공권', category: '서류' },
+ { name: '신용카드', category: '금융' },
+ { name: '현금', category: '금융' },
+ { name: '비자', category: '서류' },
+ { name: '티셔츠', category: '의류' },
+ { name: '바지', category: '의류' },
+ { name: '속옷', category: '의류' },
+ { name: '양말', category: '의류' },
+ { name: '재킷', category: '의류' },
+ { name: '잠옷', category: '의류' },
+ { name: '수영복', category: '의류' },
+ { name: '우비', category: '의류' },
+ { name: '편한 신발', category: '의류' },
+ { name: '칫솔', category: '세면도구' },
+ { name: '치약', category: '세면도구' },
+ { name: '샴푸', category: '세면도구' },
+ { name: '데오도란트', category: '세면도구' },
+ { name: '자외선 차단제', category: '세면도구' },
+ { name: '면도기', category: '세면도구' },
+ { name: '충전기', category: '전자기기' },
+ { name: '보조 배터리', category: '전자기기' },
+ { name: '헤드폰', category: '전자기기' },
+ { name: '여행용 어댑터', category: '전자기기' },
+ { name: '카메라', category: '전자기기' },
+ { name: '진통제', category: '건강' },
+ { name: '반창고', category: '건강' },
+ { name: '소독제', category: '건강' },
+ ],
+
+ // Members / Sharing
+ 'members.shareTrip': '여행 공유',
+ 'members.inviteUser': '사용자 초대',
+ 'members.selectUser': '사용자 선택…',
+ 'members.invite': '초대',
+ 'members.allHaveAccess': '모든 사용자가 이미 접근 권한을 가지고 있습니다.',
+ 'members.access': '접근',
+ 'members.person': '명',
+ 'members.persons': '명',
+ 'members.you': '나',
+ 'members.owner': '소유자',
+ 'members.leaveTrip': '여행 떠나기',
+ 'members.removeAccess': '접근 권한 제거',
+ 'members.confirmLeave': '여행을 떠날까요? 접근 권한을 잃게 됩니다.',
+ 'members.confirmRemove': '이 사용자의 접근 권한을 제거할까요?',
+ 'members.loadError': '멤버 불러오기 실패',
+ 'members.added': '추가됨',
+ 'members.addError': '추가 실패',
+ 'members.removed': '멤버가 제거되었습니다',
+ 'members.removeError': '제거 실패',
+
+ // Categories (Admin)
+ 'categories.title': '카테고리',
+ 'categories.subtitle': '장소 카테고리 관리',
+ 'categories.new': '새 카테고리',
+ 'categories.empty': '아직 카테고리가 없습니다',
+ 'categories.namePlaceholder': '카테고리 이름',
+ 'categories.icon': '아이콘',
+ 'categories.color': '색상',
+ 'categories.customColor': '사용자 지정 색상 선택',
+ 'categories.preview': '미리보기',
+ 'categories.defaultName': '카테고리',
+ 'categories.update': '업데이트',
+ 'categories.create': '생성',
+ 'categories.confirm.delete': '카테고리를 삭제할까요? 이 카테고리의 장소는 삭제되지 않습니다.',
+ 'categories.toast.loadError': '카테고리 불러오기 실패',
+ 'categories.toast.nameRequired': '이름을 입력하세요',
+ 'categories.toast.updated': '카테고리가 업데이트되었습니다',
+ 'categories.toast.created': '카테고리가 생성되었습니다',
+ 'categories.toast.saveError': '저장 실패',
+ 'categories.toast.deleted': '카테고리가 삭제되었습니다',
+ 'categories.toast.deleteError': '삭제 실패',
+
+ // Backup (Admin)
+ 'backup.title': '데이터 백업',
+ 'backup.subtitle': '데이터베이스 및 모든 업로드된 파일',
+ 'backup.refresh': '새로 고침',
+ 'backup.upload': '백업 업로드',
+ 'backup.uploading': '업로드 중…',
+ 'backup.create': '백업 만들기',
+ 'backup.creating': '생성 중…',
+ 'backup.empty': '아직 백업이 없습니다',
+ 'backup.createFirst': '첫 번째 백업 만들기',
+ 'backup.download': '다운로드',
+ 'backup.restore': '복원',
+ 'backup.confirm.restore': '백업 "{name}"을(를) 복원할까요?\n\n현재 모든 데이터가 백업으로 교체됩니다.',
+ 'backup.confirm.uploadRestore': '백업 파일 "{name}"을(를) 업로드하고 복원할까요?\n\n현재 모든 데이터가 덮어쓰여집니다.',
+ 'backup.confirm.delete': '백업 "{name}"을(를) 삭제할까요?',
+ 'backup.toast.loadError': '백업 불러오기 실패',
+ 'backup.toast.created': '백업이 성공적으로 생성되었습니다',
+ 'backup.toast.createError': '백업 생성 실패',
+ 'backup.toast.restored': '백업이 복원되었습니다. 페이지가 새로 고침됩니다…',
+ 'backup.toast.restoreError': '복원 실패',
+ 'backup.toast.uploadError': '업로드 실패',
+ 'backup.toast.deleted': '백업이 삭제되었습니다',
+ 'backup.toast.deleteError': '삭제 실패',
+ 'backup.toast.downloadError': '다운로드 실패',
+ 'backup.toast.settingsSaved': '자동 백업 설정이 저장되었습니다',
+ 'backup.toast.settingsError': '설정 저장 실패',
+ 'backup.auto.title': '자동 백업',
+ 'backup.auto.subtitle': '일정에 따른 자동 백업',
+ 'backup.auto.enable': '자동 백업 활성화',
+ 'backup.auto.enableHint': '선택한 일정에 따라 자동으로 백업이 생성됩니다',
+ 'backup.auto.interval': '간격',
+ 'backup.auto.hour': '실행 시간',
+ 'backup.auto.hourHint': '서버 현지 시간 ({format} 형식)',
+ 'backup.auto.dayOfWeek': '요일',
+ 'backup.auto.dayOfMonth': '매월 몇 일',
+ 'backup.auto.dayOfMonthHint': '모든 달과의 호환성을 위해 1-28로 제한',
+ 'backup.auto.scheduleSummary': '일정',
+ 'backup.auto.summaryDaily': '매일 {hour}:00',
+ 'backup.auto.summaryWeekly': '매주 {day} {hour}:00',
+ 'backup.auto.summaryMonthly': '매월 {day}일 {hour}:00',
+ 'backup.auto.envLocked': 'Docker',
+ 'backup.auto.envLockedHint': '자동 백업이 Docker 환경 변수로 설정되어 있습니다. 설정을 변경하려면 docker-compose.yml을 업데이트하고 컨테이너를 재시작하세요.',
+ 'backup.auto.copyEnv': 'Docker 환경 변수 복사',
+ 'backup.auto.envCopied': 'Docker 환경 변수가 클립보드에 복사되었습니다',
+ 'backup.auto.keepLabel': '이후 오래된 백업 삭제',
+ 'backup.dow.sunday': '일',
+ 'backup.dow.monday': '월',
+ 'backup.dow.tuesday': '화',
+ 'backup.dow.wednesday': '수',
+ 'backup.dow.thursday': '목',
+ 'backup.dow.friday': '금',
+ 'backup.dow.saturday': '토',
+ 'backup.interval.hourly': '매시간',
+ 'backup.interval.daily': '매일',
+ 'backup.interval.weekly': '매주',
+ 'backup.interval.monthly': '매월',
+ 'backup.keep.1day': '1일',
+ 'backup.keep.3days': '3일',
+ 'backup.keep.7days': '7일',
+ 'backup.keep.14days': '14일',
+ 'backup.keep.30days': '30일',
+ 'backup.keep.forever': '영구 보관',
+
+ // Photos
+ 'photos.title': '사진',
+ 'photos.subtitle': '{trip}의 사진 {count}장',
+ 'photos.dropHere': '여기에 사진을 놓으세요...',
+ 'photos.dropHereActive': '여기에 사진을 놓으세요',
+ 'photos.captionForAll': '캡션 (전체)',
+ 'photos.captionPlaceholder': '선택적 캡션...',
+ 'photos.addCaption': '캡션 추가...',
+ 'photos.allDays': '모든 날',
+ 'photos.noPhotos': '아직 사진이 없습니다',
+ 'photos.uploadHint': '여행 사진을 업로드하세요',
+ 'photos.clickToSelect': '또는 클릭하여 선택',
+ 'photos.linkPlace': '장소 연결',
+ 'photos.noPlace': '장소 없음',
+ 'photos.uploadN': '{n}장 사진 업로드',
+ 'photos.linkDay': '날 연결',
+ 'photos.noDay': '날 없음',
+ 'photos.dayLabel': '{number}일차',
+ 'photos.photoSelected': '사진 선택됨',
+ 'photos.photosSelected': '사진 선택됨',
+ 'photos.fileTypeHint': 'JPG, PNG, WebP · 최대 10 MB · 최대 30장',
+
+ // Backup restore modal
+ 'backup.restoreConfirmTitle': '백업을 복원할까요?',
+ 'backup.restoreWarning': '현재 모든 데이터 (여행, 장소, 사용자, 업로드)가 백업으로 영구 교체됩니다. 이 작업은 취소할 수 없습니다.',
+ 'backup.restoreTip': '팁: 복원 전에 현재 상태의 백업을 만드세요.',
+ 'backup.restoreConfirm': '예, 복원',
+
+ // PDF
+ 'pdf.travelPlan': '여행 계획',
+ 'pdf.planned': '계획됨',
+ 'pdf.costLabel': '비용 (원)',
+ 'pdf.preview': 'PDF 미리보기',
+ 'pdf.saveAsPdf': 'PDF로 저장',
+
+ // Planner
+ 'planner.places': '장소',
+ 'planner.bookings': '예약',
+ 'planner.packingList': '짐 목록',
+ 'planner.documents': '문서',
+ 'planner.dayPlan': '일별 계획',
+ 'planner.reservations': '예약',
+ 'planner.minTwoPlaces': '경로 계산에 좌표가 있는 장소가 최소 2개 필요합니다',
+ 'planner.noGeoPlaces': '좌표가 있는 장소가 없습니다',
+ 'planner.routeCalculated': '경로가 계산되었습니다',
+ 'planner.routeCalcFailed': '경로를 계산할 수 없습니다',
+ 'planner.routeError': '경로 계산 중 오류',
+ 'planner.icsExportFailed': 'ICS 내보내기 실패',
+ 'planner.routeOptimized': '경로가 최적화되었습니다',
+ 'planner.reservationUpdated': '예약이 업데이트되었습니다',
+ 'planner.reservationAdded': '예약이 추가되었습니다',
+ 'planner.confirmDeleteReservation': '예약을 삭제할까요?',
+ 'planner.reservationDeleted': '예약이 삭제되었습니다',
+ 'planner.days': '일',
+ 'planner.allPlaces': '모든 장소',
+ 'planner.totalPlaces': '총 {n}개 장소',
+ 'planner.noDaysPlanned': '아직 계획된 날이 없습니다',
+ 'planner.editTrip': '여행 편집 →',
+ 'planner.placeOne': '장소 1개',
+ 'planner.placeN': '장소 {n}개',
+ 'planner.addNote': '메모 추가',
+ 'planner.noEntries': '이 날에 항목이 없습니다',
+ 'planner.addPlace': '장소/활동 추가',
+ 'planner.addPlaceShort': '+ 장소/활동 추가',
+ 'planner.resPending': '예약 대기 중 · ',
+ 'planner.resConfirmed': '예약 확정 · ',
+ 'planner.notePlaceholder': '메모…',
+ 'planner.noteTimePlaceholder': '시간 (선택)',
+ 'planner.noteExamplePlaceholder': '예: 14:30 중앙역에서 S3, 7번 부두에서 페리, 점심 휴식…',
+ 'planner.totalCost': '총 비용',
+ 'planner.searchPlaces': '장소 검색…',
+ 'planner.allCategories': '모든 카테고리',
+ 'planner.noPlacesFound': '장소를 찾을 수 없습니다',
+ 'planner.addFirstPlace': '첫 번째 장소 추가',
+ 'planner.noReservations': '예약 없음',
+ 'planner.addFirstReservation': '첫 번째 예약 추가',
+ 'planner.new': '새로 만들기',
+ 'planner.addToDay': '+ 날에 추가',
+ 'planner.calculating': '계산 중…',
+ 'planner.route': '경로',
+ 'planner.optimize': '최적화',
+ 'planner.openGoogleMaps': 'Google Maps에서 열기',
+ 'planner.selectDayHint': '왼쪽 목록에서 날을 선택하여 일별 계획을 보세요',
+ 'planner.noPlacesForDay': '이 날에 아직 장소가 없습니다',
+ 'planner.addPlacesLink': '장소 추가 →',
+ 'planner.minTotal': '분 합계',
+ 'planner.noReservation': '예약 없음',
+ 'planner.removeFromDay': '날에서 제거',
+ 'planner.addToThisDay': '날에 추가',
+ 'planner.overview': '개요',
+ 'planner.noDays': '아직 날이 없습니다',
+ 'planner.editTripToAddDays': '여행을 편집하여 날을 추가하세요',
+ 'planner.dayCount': '{n}일',
+ 'planner.clickToUnlock': '클릭하여 잠금 해제',
+ 'planner.keepPosition': '경로 최적화 중 위치 유지',
+ 'planner.dayDetails': '일별 상세',
+ 'planner.dayN': '{n}일차',
+
+ // Dashboard Stats
+ 'stats.countries': '국가',
+ 'stats.cities': '도시',
+ 'stats.trips': '여행',
+ 'stats.places': '장소',
+ 'stats.worldProgress': '세계 진행도',
+ 'stats.visited': '방문함',
+ 'stats.remaining': '남음',
+ 'stats.visitedCountries': '방문한 나라',
+
+ // Day Detail Panel
+ 'day.precipProb': '강수 확률',
+ 'day.precipitation': '강수량',
+ 'day.wind': '바람',
+ 'day.sunrise': '일출',
+ 'day.sunset': '일몰',
+ 'day.hourlyForecast': '시간별 예보',
+ 'day.climateHint': '역사적 평균값 — 이 날짜로부터 16일 이내의 실제 예보를 사용할 수 있습니다.',
+ 'day.noWeather': '날씨 데이터가 없습니다. 좌표가 있는 장소를 추가하세요.',
+ 'day.overview': '일별 개요',
+ 'day.accommodation': '숙박',
+ 'day.addAccommodation': '숙박 추가',
+ 'day.hotelDayRange': '적용할 날',
+ 'day.noPlacesForHotel': '먼저 여행에 장소를 추가하세요',
+ 'day.allDays': '전체',
+ 'day.checkIn': '체크인',
+ 'day.checkInUntil': '까지',
+ 'day.checkOut': '체크아웃',
+ 'day.confirmation': '확인',
+ 'day.editAccommodation': '숙박 편집',
+ 'day.reservations': '예약',
+
+ // Photos / Immich
+ 'memories.title': '사진',
+ 'memories.notConnected': '{provider_name}이(가) 연결되지 않았습니다',
+ 'memories.notConnectedHint': '이 여행에 사진을 추가하려면 설정에서 {provider_name} 인스턴스를 연결하세요.',
+ 'memories.notConnectedMultipleHint': '이 여행에 사진을 추가하려면 설정에서 다음 사진 공급자 중 하나를 연결하세요: {provider_names}',
+ 'memories.noDates': '사진을 불러오려면 여행에 날짜를 추가하세요.',
+ 'memories.noPhotos': '사진을 찾을 수 없습니다',
+ 'memories.noPhotosHint': '{provider_name}에서 이 여행의 날짜 범위에 해당하는 사진을 찾을 수 없습니다.',
+ 'memories.photosFound': '장',
+ 'memories.fromOthers': '다른 사람으로부터',
+ 'memories.sharePhotos': '사진 공유',
+ 'memories.sharing': '공유',
+ 'memories.reviewTitle': '사진 검토',
+ 'memories.reviewHint': '사진을 클릭하여 공유에서 제외하세요.',
+ 'memories.shareCount': '사진 {count}장 공유',
+ 'memories.providerUrl': '서버 URL',
+ 'memories.providerApiKey': 'API 키',
+ 'memories.providerUsername': '사용자 이름',
+ 'memories.providerPassword': '비밀번호',
+ 'memories.providerOTP': 'MFA 코드 (활성화된 경우)',
+ 'memories.skipSSLVerification': 'SSL 인증서 확인 건너뛰기',
+ 'memories.immichAutoUpload': '업로드 시 Journey 사진을 Immich에 미러링',
+ 'memories.providerUrlHintSynology': 'URL에 Photos 앱 경로를 포함하세요. 예: https://nas:5001/photo',
+ 'memories.testConnection': '연결 테스트',
+ 'memories.testShort': '테스트',
+ 'memories.testFirst': '먼저 연결을 테스트하세요',
+ 'memories.connected': '연결됨',
+ 'memories.disconnected': '연결되지 않음',
+ 'memories.connectionSuccess': '{provider_name}에 연결되었습니다',
+ 'memories.connectionError': '{provider_name}에 연결할 수 없습니다',
+ 'memories.saved': '{provider_name} 설정이 저장되었습니다',
+ 'memories.providerDisconnectedBanner': '{provider_name} 연결이 끊어졌습니다. 사진을 보려면 설정에서 다시 연결하세요.',
+ 'memories.saveError': '{provider_name} 설정을 저장할 수 없습니다',
+ 'memories.addPhotos': '사진 추가',
+ 'memories.linkAlbum': '앨범 연결',
+ 'memories.selectAlbum': '{provider_name} 앨범 선택',
+ 'memories.selectAlbumMultiple': '앨범 선택',
+ 'memories.noAlbums': '앨범을 찾을 수 없습니다',
+ 'memories.syncAlbum': '앨범 동기화',
+ 'memories.unlinkAlbum': '앨범 연결 해제',
+ 'memories.photos': '장',
+ 'memories.selectPhotos': '{provider_name}에서 사진 선택',
+ 'memories.selectPhotosMultiple': '사진 선택',
+ 'memories.selectHint': '사진을 탭하여 선택하세요.',
+ 'memories.selected': '선택됨',
+ 'memories.addSelected': '{count}장 추가',
+ 'memories.alreadyAdded': '추가됨',
+ 'memories.private': '비공개',
+ 'memories.stopSharing': '공유 중지',
+ 'memories.oldest': '오래된 것 먼저',
+ 'memories.newest': '최신 것 먼저',
+ 'memories.allLocations': '모든 위치',
+ 'memories.tripDates': '여행 날짜',
+ 'memories.allPhotos': '모든 사진',
+ 'memories.confirmShareTitle': '여행 멤버와 공유할까요?',
+ 'memories.confirmShareHint': '{count}장의 사진이 이 여행의 모든 멤버에게 표시됩니다. 나중에 개별 사진을 비공개로 만들 수 있습니다.',
+ 'memories.confirmShareButton': '사진 공유',
+ 'memories.error.loadAlbums': '앨범 불러오기 실패',
+ 'memories.error.linkAlbum': '앨범 연결 실패',
+ 'memories.error.unlinkAlbum': '앨범 연결 해제 실패',
+ 'memories.error.syncAlbum': '앨범 동기화 실패',
+ 'memories.error.loadPhotos': '사진 불러오기 실패',
+ 'memories.error.addPhotos': '사진 추가 실패',
+ 'memories.error.removePhoto': '사진 제거 실패',
+ 'memories.error.toggleSharing': '공유 업데이트 실패',
+ 'memories.saveRouteNotConfigured': '이 공급자에 대해 저장 경로가 설정되지 않았습니다',
+ 'memories.testRouteNotConfigured': '이 공급자에 대해 테스트 경로가 설정되지 않았습니다',
+ 'memories.fillRequiredFields': '모든 필수 항목을 입력하세요',
+
+ // Collab Addon
+ 'collab.tabs.chat': '채팅',
+ 'collab.tabs.notes': '메모',
+ 'collab.tabs.polls': '투표',
+ 'collab.whatsNext.title': '다음 할 일',
+ 'collab.whatsNext.today': '오늘',
+ 'collab.whatsNext.tomorrow': '내일',
+ 'collab.whatsNext.empty': '예정된 활동이 없습니다',
+ 'collab.whatsNext.until': '까지',
+ 'collab.whatsNext.emptyHint': '시간이 있는 활동이 여기에 표시됩니다',
+ 'collab.chat.send': '전송',
+ 'collab.chat.placeholder': '메시지 입력...',
+ 'collab.chat.empty': '대화를 시작하세요',
+ 'collab.chat.emptyHint': '메시지는 모든 여행 멤버와 공유됩니다',
+ 'collab.chat.emptyDesc': '여행 그룹과 아이디어, 계획, 업데이트를 공유하세요',
+ 'collab.chat.today': '오늘',
+ 'collab.chat.yesterday': '어제',
+ 'collab.chat.deletedMessage': '메시지를 삭제했습니다',
+ 'collab.chat.reply': '답장',
+ 'collab.chat.loadMore': '이전 메시지 불러오기',
+ 'collab.chat.justNow': '방금 전',
+ 'collab.chat.minutesAgo': '{n}분 전',
+ 'collab.chat.hoursAgo': '{n}시간 전',
+ 'collab.notes.title': '메모',
+ 'collab.notes.new': '새 메모',
+ 'collab.notes.empty': '아직 메모가 없습니다',
+ 'collab.notes.emptyHint': '아이디어와 계획을 기록하세요',
+ 'collab.notes.all': '전체',
+ 'collab.notes.titlePlaceholder': '메모 제목',
+ 'collab.notes.contentPlaceholder': '내용을 입력하세요...',
+ 'collab.notes.categoryPlaceholder': '카테고리',
+ 'collab.notes.newCategory': '새 카테고리...',
+ 'collab.notes.category': '카테고리',
+ 'collab.notes.noCategory': '카테고리 없음',
+ 'collab.notes.color': '색상',
+ 'collab.notes.save': '저장',
+ 'collab.notes.cancel': '취소',
+ 'collab.notes.edit': '편집',
+ 'collab.notes.delete': '삭제',
+ 'collab.notes.pin': '고정',
+ 'collab.notes.unpin': '고정 해제',
+ 'collab.notes.daysAgo': '{n}일 전',
+ 'collab.notes.categorySettings': '카테고리 관리',
+ 'collab.notes.create': '생성',
+ 'collab.notes.website': '웹사이트',
+ 'collab.notes.websitePlaceholder': 'https://...',
+ 'collab.notes.attachFiles': '파일 첨부',
+ 'collab.notes.noCategoriesYet': '아직 카테고리가 없습니다',
+ 'collab.notes.emptyDesc': '메모를 만들어 시작하세요',
+ 'collab.polls.title': '투표',
+ 'collab.polls.new': '새 투표',
+ 'collab.polls.empty': '아직 투표가 없습니다',
+ 'collab.polls.emptyHint': '그룹에 질문하고 함께 투표하세요',
+ 'collab.polls.question': '질문',
+ 'collab.polls.questionPlaceholder': '무엇을 할까요?',
+ 'collab.polls.addOption': '+ 옵션 추가',
+ 'collab.polls.optionPlaceholder': '옵션 {n}',
+ 'collab.polls.create': '투표 만들기',
+ 'collab.polls.close': '닫기',
+ 'collab.polls.closed': '종료됨',
+ 'collab.polls.votes': '{n}표',
+ 'collab.polls.vote': '{n}표',
+ 'collab.polls.multipleChoice': '복수 선택',
+ 'collab.polls.multiChoice': '복수 선택',
+ 'collab.polls.deadline': '마감일',
+ 'collab.polls.option': '옵션',
+ 'collab.polls.options': '옵션',
+ 'collab.polls.delete': '삭제',
+ 'collab.polls.closedSection': '종료됨',
+
+ // Permissions
+ 'admin.tabs.permissions': '권한',
+ 'perm.title': '권한 설정',
+ 'perm.subtitle': '앱 전체에서 누가 작업을 수행할 수 있는지 제어합니다',
+ 'perm.saved': '권한 설정이 저장되었습니다',
+ 'perm.resetDefaults': '기본값으로 초기화',
+ 'perm.customized': '맞춤 설정됨',
+ 'perm.level.admin': '관리자만',
+ 'perm.level.tripOwner': '여행 소유자',
+ 'perm.level.tripMember': '여행 멤버',
+ 'perm.level.everybody': '모든 사람',
+ 'perm.cat.trip': '여행 관리',
+ 'perm.cat.members': '멤버 관리',
+ 'perm.cat.files': '파일',
+ 'perm.cat.content': '콘텐츠 및 일정',
+ 'perm.cat.extras': '예산, 짐 목록 및 협업',
+ 'perm.action.trip_create': '여행 만들기',
+ 'perm.action.trip_edit': '여행 상세 편집',
+ 'perm.action.trip_delete': '여행 삭제',
+ 'perm.action.trip_archive': '여행 보관/복원',
+ 'perm.action.trip_cover_upload': '커버 이미지 업로드',
+ 'perm.action.member_manage': '멤버 추가/제거',
+ 'perm.action.file_upload': '파일 업로드',
+ 'perm.action.file_edit': '파일 메타데이터 편집',
+ 'perm.action.file_delete': '파일 삭제',
+ 'perm.action.place_edit': '장소 추가/편집/삭제',
+ 'perm.action.day_edit': '날, 메모 및 배정 편집',
+ 'perm.action.reservation_edit': '예약 관리',
+ 'perm.action.budget_edit': '예산 관리',
+ 'perm.action.packing_edit': '짐 목록 관리',
+ 'perm.action.collab_edit': '협업 (메모, 투표, 채팅)',
+ 'perm.action.share_manage': '공유 링크 관리',
+ 'perm.actionHint.trip_create': '누가 새 여행을 만들 수 있는지',
+ 'perm.actionHint.trip_edit': '누가 여행 이름, 날짜, 설명, 통화를 변경할 수 있는지',
+ 'perm.actionHint.trip_delete': '누가 여행을 영구 삭제할 수 있는지',
+ 'perm.actionHint.trip_archive': '누가 여행을 보관하거나 복원할 수 있는지',
+ 'perm.actionHint.trip_cover_upload': '누가 커버 이미지를 업로드하거나 변경할 수 있는지',
+ 'perm.actionHint.member_manage': '누가 여행 멤버를 초대하거나 제거할 수 있는지',
+ 'perm.actionHint.file_upload': '누가 여행에 파일을 업로드할 수 있는지',
+ 'perm.actionHint.file_edit': '누가 파일 설명 및 링크를 편집할 수 있는지',
+ 'perm.actionHint.file_delete': '누가 파일을 휴지통으로 이동하거나 영구 삭제할 수 있는지',
+ 'perm.actionHint.place_edit': '누가 장소를 추가, 편집, 삭제할 수 있는지',
+ 'perm.actionHint.day_edit': '누가 날, 일별 메모, 장소 배정을 편집할 수 있는지',
+ 'perm.actionHint.reservation_edit': '누가 예약을 만들고, 편집하고, 삭제할 수 있는지',
+ 'perm.actionHint.budget_edit': '누가 예산 항목을 만들고, 편집하고, 삭제할 수 있는지',
+ 'perm.actionHint.packing_edit': '누가 짐 항목과 가방을 관리할 수 있는지',
+ 'perm.actionHint.collab_edit': '누가 메모, 투표를 만들고 메시지를 보낼 수 있는지',
+ 'perm.actionHint.share_manage': '누가 공개 공유 링크를 만들거나 삭제할 수 있는지',
+
+ // Undo
+ 'undo.button': '실행 취소',
+ 'undo.tooltip': '실행 취소: {action}',
+ 'undo.assignPlace': '장소가 날에 배정되었습니다',
+ 'undo.removeAssignment': '장소가 날에서 제거되었습니다',
+ 'undo.reorder': '장소 순서가 변경되었습니다',
+ 'undo.optimize': '경로가 최적화되었습니다',
+ 'undo.deletePlace': '장소가 삭제되었습니다',
+ 'undo.deletePlaces': '장소들이 삭제되었습니다',
+ 'undo.moveDay': '장소가 다른 날로 이동되었습니다',
+ 'undo.lock': '장소 잠금이 변경되었습니다',
+ 'undo.importGpx': 'GPX 가져오기',
+ 'undo.importKeyholeMarkup': 'KMZ/KML 가져오기',
+ 'undo.importGoogleList': 'Google Maps 가져오기',
+ 'undo.importNaverList': '네이버 지도 가져오기',
+ 'undo.addPlace': '장소가 추가되었습니다',
+ 'undo.done': '실행 취소됨: {action}',
+
+ // Notifications
+ 'notifications.title': '알림',
+ 'notifications.markAllRead': '모두 읽음으로 표시',
+ 'notifications.deleteAll': '모두 삭제',
+ 'notifications.showAll': '모든 알림 보기',
+ 'notifications.empty': '알림 없음',
+ 'notifications.emptyDescription': '모두 확인했습니다!',
+ 'notifications.all': '전체',
+ 'notifications.unreadOnly': '읽지 않음',
+ 'notifications.markRead': '읽음으로 표시',
+ 'notifications.markUnread': '읽지 않음으로 표시',
+ 'notifications.delete': '삭제',
+ 'notifications.system': '시스템',
+ 'notifications.synologySessionCleared.title': 'Synology Photos 연결 해제됨',
+ 'notifications.synologySessionCleared.text': '서버 또는 계정이 변경되었습니다 — 설정에서 연결을 다시 테스트하세요.',
+
+ // Notification test keys (dev only)
+ 'notifications.versionAvailable.title': '업데이트 사용 가능',
+ 'notifications.versionAvailable.text': 'TREK {version}이(가) 사용 가능합니다.',
+ 'notifications.versionAvailable.button': '상세 보기',
+ 'notifications.test.title': '{actor}의 테스트 알림',
+ 'notifications.test.text': '간단한 테스트 알림입니다.',
+ 'notifications.test.booleanTitle': '{actor}이(가) 승인을 요청합니다',
+ 'notifications.test.booleanText': '테스트 boolean 알림입니다. 아래에서 작업을 선택하세요.',
+ 'notifications.test.accept': '승인',
+ 'notifications.test.decline': '거절',
+ 'notifications.test.navigateTitle': '확인할 항목이 있습니다',
+ 'notifications.test.navigateText': '테스트 navigate 알림입니다.',
+ 'notifications.test.goThere': '이동',
+ 'notifications.test.adminTitle': '관리자 방송',
+ 'notifications.test.adminText': '{actor}이(가) 모든 관리자에게 테스트 알림을 보냈습니다.',
+ 'notifications.test.tripTitle': '{actor}이(가) 여행에 게시했습니다',
+ 'notifications.test.tripText': '여행 "{trip}"의 테스트 알림입니다.',
+
+ // Todo
+ 'todo.subtab.packing': '짐 목록',
+ 'todo.subtab.todo': '할 일',
+ 'todo.completed': '완료됨',
+ 'todo.filter.all': '전체',
+ 'todo.filter.open': '미완료',
+ 'todo.filter.done': '완료',
+ 'todo.uncategorized': '미분류',
+ 'todo.namePlaceholder': '작업 이름',
+ 'todo.descriptionPlaceholder': '설명 (선택)',
+ 'todo.unassigned': '미배정',
+ 'todo.noCategory': '카테고리 없음',
+ 'todo.hasDescription': '설명 있음',
+ 'todo.addItem': '새 작업 추가',
+ 'todo.sidebar.sortBy': '정렬 기준',
+ 'todo.priority': '우선순위',
+ 'todo.newCategoryLabel': '새로 만들기',
+ 'budget.categoriesLabel': '카테고리',
+ 'todo.newCategory': '카테고리 이름',
+ 'todo.addCategory': '카테고리 추가',
+ 'todo.newItem': '새 작업',
+ 'todo.empty': '아직 작업이 없습니다. 작업을 추가하여 시작하세요!',
+ 'todo.filter.my': '내 작업',
+ 'todo.filter.overdue': '기한 초과',
+ 'todo.sidebar.tasks': '작업',
+ 'todo.sidebar.categories': '카테고리',
+ 'todo.detail.title': '작업',
+ 'todo.detail.description': '설명',
+ 'todo.detail.category': '카테고리',
+ 'todo.detail.dueDate': '마감일',
+ 'todo.detail.assignedTo': '배정 대상',
+ 'todo.detail.delete': '삭제',
+ 'todo.detail.save': '변경 사항 저장',
+ 'todo.sortByPrio': '우선순위',
+ 'todo.detail.priority': '우선순위',
+ 'todo.detail.noPriority': '없음',
+ 'todo.detail.create': '작업 만들기',
+
+ // Notifications — dev test events
+ 'notif.test.title': '[테스트] 알림',
+ 'notif.test.simple.text': '간단한 테스트 알림입니다.',
+ 'notif.test.boolean.text': '이 테스트 알림을 수락하시겠습니까?',
+ 'notif.test.navigate.text': '아래를 클릭하여 대시보드로 이동하세요.',
+
+ // Notifications
+ 'notif.trip_invite.title': '여행 초대',
+ 'notif.trip_invite.text': '{actor}이(가) {trip}에 초대했습니다',
+ 'notif.booking_change.title': '예약 업데이트됨',
+ 'notif.booking_change.text': '{actor}이(가) {trip}의 예약을 업데이트했습니다',
+ 'notif.trip_reminder.title': '여행 리마인더',
+ 'notif.trip_reminder.text': '여행 {trip}이(가) 곧 시작됩니다!',
+ 'notif.todo_due.title': '할 일 마감',
+ 'notif.todo_due.text': '{trip}의 {todo}이(가) {due}에 마감됩니다',
+ 'notif.vacay_invite.title': 'Vacay 퓨전 초대',
+ 'notif.vacay_invite.text': '{actor}이(가) 휴가 계획 공유에 초대했습니다',
+ 'notif.photos_shared.title': '사진 공유됨',
+ 'notif.photos_shared.text': '{actor}이(가) {trip}에서 {count}장의 사진을 공유했습니다',
+ 'notif.collab_message.title': '새 메시지',
+ 'notif.collab_message.text': '{actor}이(가) {trip}에서 메시지를 보냈습니다',
+ 'notif.packing_tagged.title': '짐 목록 배정',
+ 'notif.packing_tagged.text': '{actor}이(가) {trip}의 {category}에 배정했습니다',
+ 'notif.version_available.title': '새 버전 사용 가능',
+ 'notif.version_available.text': 'TREK {version}이(가) 사용 가능합니다',
+ 'notif.action.view_trip': '여행 보기',
+ 'notif.action.view_collab': '메시지 보기',
+ 'notif.action.view_packing': '짐 목록 보기',
+ 'notif.action.view_photos': '사진 보기',
+ 'notif.action.view_vacay': 'Vacay 보기',
+ 'notif.action.view_admin': '관리자로 이동',
+ 'notif.action.view': '보기',
+ 'notif.action.accept': '수락',
+ 'notif.action.decline': '거절',
+ 'notif.generic.title': '알림',
+ 'notif.generic.text': '새 알림이 있습니다',
+ 'notif.dev.unknown_event.title': '[DEV] 알 수 없는 이벤트',
+ 'notif.dev.unknown_event.text': '이벤트 유형 "{event}"이(가) EVENT_NOTIFICATION_CONFIG에 등록되지 않았습니다',
+
+ // Journey addon
+ 'journey.search.placeholder': 'Journey 검색…',
+ 'journey.search.noResults': '"{query}"와(과) 일치하는 Journey가 없습니다',
+ 'journey.title': 'Journey',
+ 'journey.subtitle': '여행을 실시간으로 기록하세요',
+ 'journey.new': '새 Journey',
+ 'journey.create': '만들기',
+ 'journey.titlePlaceholder': '어디로 가시나요?',
+ 'journey.empty': '아직 Journey가 없습니다',
+ 'journey.emptyHint': '다음 여행을 기록하기 시작하세요',
+ 'journey.deleted': 'Journey가 삭제되었습니다',
+ 'journey.createError': 'Journey를 만들 수 없습니다',
+ 'journey.deleteError': 'Journey를 삭제할 수 없습니다',
+ 'journey.deleteConfirmTitle': '삭제',
+ 'journey.deleteConfirmMessage': '"{title}"을(를) 삭제할까요? 이 작업은 취소할 수 없습니다.',
+ 'journey.deleteConfirmGeneric': '정말로 삭제할까요?',
+ 'journey.notFound': 'Journey를 찾을 수 없습니다',
+ 'journey.photos': '사진',
+ 'journey.timelineEmpty': '아직 정류장이 없습니다',
+ 'journey.timelineEmptyHint': '체크인을 추가하거나 일기를 작성하여 시작하세요',
+ 'journey.status.draft': '초안',
+ 'journey.status.active': '활성',
+ 'journey.status.completed': '완료됨',
+ 'journey.status.upcoming': '예정됨',
+ 'journey.status.archived': '보관됨',
+ 'journey.checkin.add': '체크인',
+ 'journey.checkin.namePlaceholder': '위치 이름',
+ 'journey.checkin.notesPlaceholder': '메모 (선택)',
+ 'journey.checkin.save': '저장',
+ 'journey.checkin.error': '체크인을 저장할 수 없습니다',
+ 'journey.entry.add': '일기',
+ 'journey.entry.edit': '항목 편집',
+ 'journey.entry.titlePlaceholder': '제목 (선택)',
+ 'journey.entry.bodyPlaceholder': '오늘 무슨 일이 있었나요?',
+ 'journey.entry.save': '저장',
+ 'journey.entry.error': '항목을 저장할 수 없습니다',
+ 'journey.photo.add': '사진',
+ 'journey.photo.uploadError': '업로드 실패',
+ 'journey.share.share': '공유',
+ 'journey.share.public': '공개',
+ 'journey.share.linkCopied': '공개 링크가 복사되었습니다',
+ 'journey.share.disabled': '공개 공유 비활성화됨',
+ 'journey.editor.titlePlaceholder': '이 순간에 이름을 붙여주세요...',
+ 'journey.editor.bodyPlaceholder': '오늘의 이야기를 들려주세요...',
+ 'journey.editor.placePlaceholder': '위치 (선택)',
+ 'journey.editor.tagsPlaceholder': '태그: 숨겨진 명소, 최고의 식사, 다시 방문...',
+ 'journey.visibility.private': '비공개',
+ 'journey.visibility.shared': '공유됨',
+ 'journey.visibility.public': '공개',
+ 'journey.emptyState.title': '여기서 이야기가 시작됩니다',
+ 'journey.emptyState.subtitle': '장소에 체크인하거나 첫 번째 일기 항목을 작성하세요',
+
+ // Journey Frontpage
+ 'journey.frontpage.subtitle': '여행을 영원히 잊지 못할 이야기로 만드세요',
+ 'journey.frontpage.createJourney': 'Journey 만들기',
+ 'journey.frontpage.activeJourney': '활성 Journey',
+ 'journey.frontpage.allJourneys': '모든 Journey',
+ 'journey.frontpage.journeys': '개 Journey',
+ 'journey.frontpage.createNew': '새 Journey 만들기',
+ 'journey.frontpage.createNewSub': '여행을 선택하고, 이야기를 쓰고, 모험을 공유하세요',
+ 'journey.frontpage.live': '라이브',
+ 'journey.frontpage.synced': '동기화됨',
+ 'journey.frontpage.continueWriting': '계속 쓰기',
+ 'journey.frontpage.updated': '{time}에 업데이트됨',
+ 'journey.frontpage.suggestionLabel': '여행이 방금 종료됨',
+ 'journey.frontpage.suggestionText': '{title} 을(를) Journey로 만들어보세요',
+ 'journey.frontpage.dismiss': '닫기',
+ 'journey.frontpage.journeyName': 'Journey 이름',
+ 'journey.frontpage.namePlaceholder': '예: 동남아시아 2026',
+ 'journey.frontpage.selectTrips': '여행 선택',
+ 'journey.frontpage.tripsSelected': '개 여행 선택됨',
+ 'journey.frontpage.trips': '개 여행',
+ 'journey.frontpage.placesImported': '개 장소가 가져와집니다',
+ 'journey.frontpage.places': '개 장소',
+
+ // Journey Detail
+ 'journey.detail.backToJourney': 'Journey로 돌아가기',
+ 'journey.detail.syncedWithTrips': '여행과 동기화됨',
+ 'journey.detail.addEntry': '항목 추가',
+ 'journey.detail.newEntry': '새 항목',
+ 'journey.detail.editEntry': '항목 편집',
+ 'journey.detail.noEntries': '아직 항목이 없습니다',
+ 'journey.detail.noEntriesHint': '여행을 추가하여 스켈레톤 항목으로 시작하세요',
+ 'journey.detail.noPhotos': '아직 사진이 없습니다',
+ 'journey.detail.noPhotosHint': '항목에 사진을 업로드하거나 Immich/Synology 라이브러리를 탐색하세요',
+ 'journey.detail.journeyTab': 'Journey',
+ 'journey.detail.journeyStats': 'Journey 통계',
+ 'journey.detail.syncedTrips': '동기화된 여행',
+ 'journey.detail.noTripsLinked': '아직 연결된 여행이 없습니다',
+ 'journey.detail.contributors': '기여자',
+ 'journey.detail.readMore': '더 읽기',
+ 'journey.detail.prosCons': '장단점',
+ 'journey.detail.photos': '장',
+ 'journey.detail.day': '{number}일차',
+ 'journey.detail.places': '개 장소',
+
+ // Journey Detail — Stats
+ 'journey.stats.days': '일',
+ 'journey.stats.cities': '도시',
+ 'journey.stats.entries': '항목',
+ 'journey.stats.photos': '사진',
+ 'journey.stats.places': '장소',
+ 'journey.skeletons.show': '제안 보기',
+ 'journey.skeletons.hide': '제안 숨기기',
+
+ // Journey Detail — Verdict
+ 'journey.verdict.lovedIt': '정말 좋았어요',
+ 'journey.verdict.couldBeBetter': '더 좋을 수 있었어요',
+
+ // Journey Detail — Synced badge
+ 'journey.synced.places': '개 장소',
+ 'journey.synced.synced': '동기화됨',
+
+ // Journey Entry Editor
+ 'journey.editor.discardChangesConfirm': '저장되지 않은 변경 사항이 있습니다. 취소할까요?',
+ 'journey.editor.uploadPhotos': '사진 업로드',
+ 'journey.editor.uploading': '업로드 중...',
+ 'journey.editor.fromGallery': '갤러리에서',
+ 'journey.editor.allPhotosAdded': '모든 사진이 이미 추가되었습니다',
+ 'journey.editor.writeStory': '이야기를 써주세요...',
+ 'journey.editor.prosCons': '장단점',
+ 'journey.editor.pros': '장점',
+ 'journey.editor.cons': '단점',
+ 'journey.editor.proPlaceholder': '좋은 점...',
+ 'journey.editor.conPlaceholder': '아쉬운 점...',
+ 'journey.editor.addAnother': '하나 더 추가',
+ 'journey.editor.date': '날짜',
+ 'journey.editor.location': '위치',
+ 'journey.editor.searchLocation': '위치 검색...',
+ 'journey.editor.mood': '기분',
+ 'journey.editor.weather': '날씨',
+ 'journey.editor.photoFirst': '1번째',
+ 'journey.editor.makeFirst': '1번째로 설정',
+ 'journey.editor.searching': '검색 중...',
+
+ // Journey Entry — Moods
+ 'journey.mood.amazing': '최고!',
+ 'journey.mood.good': '좋음',
+ 'journey.mood.neutral': '보통',
+ 'journey.mood.rough': '힘들었음',
+
+ // Journey Entry — Weather
+ 'journey.weather.sunny': '맑음',
+ 'journey.weather.partly': '구름 조금',
+ 'journey.weather.cloudy': '흐림',
+ 'journey.weather.rainy': '비',
+ 'journey.weather.stormy': '폭풍',
+ 'journey.weather.cold': '눈',
+
+ // Journey — Trip Linking
+ 'journey.trips.linkTrip': '여행 연결',
+ 'journey.trips.searchTrip': '여행 검색',
+ 'journey.trips.searchPlaceholder': '여행 이름 또는 목적지...',
+ 'journey.trips.noTripsAvailable': '사용 가능한 여행이 없습니다',
+ 'journey.trips.link': '연결',
+ 'journey.trips.tripLinked': '여행이 연결되었습니다',
+ 'journey.trips.linkFailed': '여행 연결 실패',
+ 'journey.trips.addTrip': '여행 추가',
+ 'journey.trips.unlinkTrip': '여행 연결 해제',
+ 'journey.trips.unlinkMessage': '"{title}"을(를) 연결 해제할까요? 이 여행의 동기화된 모든 항목과 사진이 영구 삭제됩니다. 이 작업은 취소할 수 없습니다.',
+ 'journey.trips.unlink': '연결 해제',
+ 'journey.trips.tripUnlinked': '여행 연결이 해제되었습니다',
+ 'journey.trips.unlinkFailed': '여행 연결 해제 실패',
+ 'journey.trips.noTripsLinkedSettings': '연결된 여행이 없습니다',
+
+ // Journey — Contributors
+ 'journey.contributors.invite': '기여자 초대',
+ 'journey.contributors.searchUser': '사용자 검색',
+ 'journey.contributors.searchPlaceholder': '사용자 이름 또는 이메일...',
+ 'journey.contributors.noUsers': '사용자를 찾을 수 없습니다',
+ 'journey.contributors.role': '역할',
+ 'journey.contributors.added': '기여자가 추가되었습니다',
+ 'journey.contributors.addFailed': '기여자 추가 실패',
+ 'journey.contributors.remove': '기여자 제거',
+ 'journey.contributors.removeConfirm': '{username}을(를) 이 Journey에서 제거할까요?',
+ 'journey.contributors.removed': '기여자가 제거되었습니다',
+ 'journey.contributors.removeFailed': '기여자 제거 실패',
+
+ // Journey — Share
+ 'journey.share.publicShare': '공개 공유',
+ 'journey.share.createLink': '공유 링크 만들기',
+ 'journey.share.linkCreated': '공유 링크가 생성되었습니다',
+ 'journey.share.createFailed': '링크 생성 실패',
+ 'journey.share.copy': '복사',
+ 'journey.share.copied': '복사됨!',
+ 'journey.share.timeline': '타임라인',
+ 'journey.share.gallery': '갤러리',
+ 'journey.share.map': '지도',
+ 'journey.share.removeLink': '공유 링크 제거',
+ 'journey.share.linkDeleted': '공유 링크가 삭제되었습니다',
+ 'journey.share.deleteFailed': '삭제 실패',
+ 'journey.share.updateFailed': '업데이트 실패',
+
+ // Journey — Invite
+ 'journey.invite.role': '역할',
+ 'journey.invite.viewer': '뷰어',
+ 'journey.invite.editor': '편집자',
+ 'journey.invite.invite': '초대',
+ 'journey.invite.inviting': '초대 중...',
+
+ // Journey — Settings Dialog
+ 'journey.settings.title': 'Journey 설정',
+ 'journey.settings.coverImage': '커버 이미지',
+ 'journey.settings.changeCover': '커버 변경',
+ 'journey.settings.addCover': '커버 이미지 추가',
+ 'journey.settings.name': '이름',
+ 'journey.settings.subtitle': '부제목',
+ 'journey.settings.subtitlePlaceholder': '예: 태국, 베트남 & 캄보디아',
+ 'journey.settings.endJourney': 'Journey 보관',
+ 'journey.settings.reopenJourney': 'Journey 복원',
+ 'journey.settings.archived': 'Journey가 보관되었습니다',
+ 'journey.settings.reopened': 'Journey가 복원되었습니다',
+ 'journey.settings.endDescription': '라이브 배지를 숨깁니다. 언제든지 다시 열 수 있습니다.',
+ 'journey.settings.delete': '삭제',
+ 'journey.settings.deleteJourney': 'Journey 삭제',
+ 'journey.settings.deleteMessage': '"{title}"을(를) 삭제할까요? 모든 항목과 사진이 삭제됩니다.',
+ 'journey.settings.saved': '설정이 저장되었습니다',
+ 'journey.settings.saveFailed': '저장 실패',
+ 'journey.settings.coverUpdated': '커버가 업데이트되었습니다',
+ 'journey.settings.coverFailed': '업로드 실패',
+ 'journey.settings.failedToDelete': '삭제 실패',
+ 'journey.entries.deleteTitle': '항목 삭제',
+ 'journey.photosUploaded': '{count}장 사진이 업로드되었습니다',
+ 'journey.photosAdded': '{count}장 사진이 추가되었습니다',
+
+ // Journey — Public Page
+ 'journey.public.notFound': '찾을 수 없습니다',
+ 'journey.public.notFoundMessage': '이 Journey가 존재하지 않거나 링크가 만료되었습니다.',
+ 'journey.public.readOnly': '읽기 전용 · 공개 Journey',
+ 'journey.public.tagline': '여행 기록 및 탐험 키트',
+ 'journey.public.sharedVia': '공유 경로',
+ 'journey.public.madeWith': '으로 만들어짐',
+
+ // Journey — PDF Export
+ 'journey.pdf.journeyBook': 'Journey 책',
+ 'journey.pdf.madeWith': 'TREK으로 만들어짐',
+ 'journey.pdf.day': '일차',
+ 'journey.pdf.theEnd': '끝',
+ 'journey.pdf.saveAsPdf': 'PDF로 저장',
+ 'journey.pdf.pages': '페이지',
+ 'journey.picker.tripPeriod': '여행 기간',
+ 'journey.picker.dateRange': '날짜 범위',
+ 'journey.picker.allPhotos': '모든 사진',
+ 'journey.picker.albums': '앨범',
+ 'journey.picker.selected': '선택됨',
+ 'journey.picker.addTo': '추가',
+ 'journey.picker.newGallery': '새 갤러리',
+ 'journey.picker.selectAll': '전체 선택',
+ 'journey.picker.deselectAll': '전체 해제',
+ 'journey.picker.noAlbums': '앨범을 찾을 수 없습니다',
+ 'journey.picker.selectDate': '날짜 선택',
+ 'journey.picker.search': '검색',
+
+ // Dashboard Mobile
+ 'dashboard.greeting.morning': '좋은 아침이에요,',
+ 'dashboard.greeting.afternoon': '안녕하세요,',
+ 'dashboard.greeting.evening': '좋은 저녁이에요,',
+ 'dashboard.mobile.liveNow': '지금 라이브',
+ 'dashboard.mobile.tripProgress': '여행 진행도',
+ 'dashboard.mobile.daysLeft': '{count}일 남음',
+ 'dashboard.mobile.places': '장소',
+ 'dashboard.mobile.buddies': '동행자',
+ 'dashboard.mobile.newTrip': '새 여행',
+ 'dashboard.mobile.currency': '통화',
+ 'dashboard.mobile.timezone': '시간대',
+ 'dashboard.mobile.upcomingTrips': '예정된 여행',
+ 'dashboard.mobile.yourTrips': '내 여행',
+ 'dashboard.mobile.trips': '개 여행',
+ 'dashboard.mobile.starts': '시작',
+ 'dashboard.mobile.duration': '기간',
+ 'dashboard.mobile.day': '일',
+ 'dashboard.mobile.days': '일',
+ 'dashboard.mobile.ongoing': '진행 중',
+ 'dashboard.mobile.startsToday': '오늘 시작',
+ 'dashboard.mobile.tomorrow': '내일',
+ 'dashboard.mobile.inDays': '{count}일 후',
+ 'dashboard.mobile.inMonths': '{count}개월 후',
+ 'dashboard.mobile.completed': '완료됨',
+ 'dashboard.mobile.currencyConverter': '환율 계산기',
+
+ // BottomNav & Profile
+ 'nav.profile': '프로필',
+ 'nav.bottomSettings': '설정',
+ 'nav.bottomAdmin': '관리자 설정',
+ 'nav.bottomLogout': '로그아웃',
+ 'nav.bottomAdminBadge': '관리자',
+
+ // DayPlan Mobile
+ 'dayplan.mobile.addPlace': '장소 추가',
+ 'dayplan.mobile.searchPlaces': '장소 검색...',
+ 'dayplan.mobile.allAssigned': '모든 장소가 배정되었습니다',
+ 'dayplan.mobile.noMatch': '일치 없음',
+ 'dayplan.mobile.createNew': '새 장소 만들기',
+
+ 'admin.addons.catalog.journey.name': 'Journey',
+ 'admin.addons.catalog.journey.description': '체크인, 사진, 일별 이야기가 있는 여행 기록 및 여행 일지',
+
+ // OAuth scope groups
+ 'oauth.scope.group.trips': '여행',
+ 'oauth.scope.group.places': '장소',
+ 'oauth.scope.group.atlas': 'Atlas',
+ 'oauth.scope.group.packing': '짐 목록',
+ 'oauth.scope.group.todos': '할 일',
+ 'oauth.scope.group.budget': '예산',
+ 'oauth.scope.group.reservations': '예약',
+ 'oauth.scope.group.collab': '협업',
+ 'oauth.scope.group.notifications': '알림',
+ 'oauth.scope.group.vacay': '휴가',
+ 'oauth.scope.group.geo': '지리',
+ 'oauth.scope.group.weather': '날씨',
+ 'oauth.scope.group.journey': 'Journey',
+
+ // OAuth scope labels & descriptions
+ 'oauth.scope.trips:read.label': '여행 및 일정 보기',
+ 'oauth.scope.trips:read.description': '여행, 날, 일별 메모, 멤버 읽기',
+ 'oauth.scope.trips:write.label': '여행 및 일정 편집',
+ 'oauth.scope.trips:write.description': '여행, 날, 메모 만들기 및 업데이트, 멤버 관리',
+ 'oauth.scope.trips:delete.label': '여행 삭제',
+ 'oauth.scope.trips:delete.description': '전체 여행 영구 삭제 — 이 작업은 되돌릴 수 없습니다',
+ 'oauth.scope.trips:share.label': '공유 링크 관리',
+ 'oauth.scope.trips:share.description': '여행의 공개 공유 링크 만들기, 업데이트, 취소',
+ 'oauth.scope.places:read.label': '장소 및 지도 데이터 보기',
+ 'oauth.scope.places:read.description': '장소, 날 배정, 태그, 카테고리 읽기',
+ 'oauth.scope.places:write.label': '장소 관리',
+ 'oauth.scope.places:write.description': '장소, 배정, 태그 만들기, 업데이트, 삭제',
+ 'oauth.scope.atlas:read.label': 'Atlas 보기',
+ 'oauth.scope.atlas:read.description': '방문한 나라, 지역, 버킷 리스트 읽기',
+ 'oauth.scope.atlas:write.label': 'Atlas 관리',
+ 'oauth.scope.atlas:write.description': '방문한 나라 및 지역 표시, 버킷 리스트 관리',
+ 'oauth.scope.packing:read.label': '짐 목록 보기',
+ 'oauth.scope.packing:read.description': '짐 항목, 가방, 카테고리 배정 읽기',
+ 'oauth.scope.packing:write.label': '짐 목록 관리',
+ 'oauth.scope.packing:write.description': '짐 항목 및 가방 추가, 업데이트, 삭제, 체크, 순서 변경',
+ 'oauth.scope.todos:read.label': '할 일 목록 보기',
+ 'oauth.scope.todos:read.description': '여행 할 일 항목 및 카테고리 배정 읽기',
+ 'oauth.scope.todos:write.label': '할 일 목록 관리',
+ 'oauth.scope.todos:write.description': '할 일 항목 만들기, 업데이트, 체크, 삭제, 순서 변경',
+ 'oauth.scope.budget:read.label': '예산 보기',
+ 'oauth.scope.budget:read.description': '예산 항목 및 지출 내역 읽기',
+ 'oauth.scope.budget:write.label': '예산 관리',
+ 'oauth.scope.budget:write.description': '예산 항목 만들기, 업데이트, 삭제',
+ 'oauth.scope.reservations:read.label': '예약 보기',
+ 'oauth.scope.reservations:read.description': '예약 및 숙박 상세 정보 읽기',
+ 'oauth.scope.reservations:write.label': '예약 관리',
+ 'oauth.scope.reservations:write.description': '예약 만들기, 업데이트, 삭제, 순서 변경',
+ 'oauth.scope.collab:read.label': '협업 보기',
+ 'oauth.scope.collab:read.description': '협업 메모, 투표, 메시지 읽기',
+ 'oauth.scope.collab:write.label': '협업 관리',
+ 'oauth.scope.collab:write.description': '협업 메모, 투표, 메시지 만들기, 업데이트, 삭제',
+ 'oauth.scope.notifications:read.label': '알림 보기',
+ 'oauth.scope.notifications:read.description': '앱 내 알림 및 읽지 않은 수 읽기',
+ 'oauth.scope.notifications:write.label': '알림 관리',
+ 'oauth.scope.notifications:write.description': '알림 읽음 표시 및 응답',
+ 'oauth.scope.vacay:read.label': '휴가 계획 보기',
+ 'oauth.scope.vacay:read.description': '휴가 계획 데이터, 항목, 통계 읽기',
+ 'oauth.scope.vacay:write.label': '휴가 계획 관리',
+ 'oauth.scope.vacay:write.description': '휴가 항목, 공휴일, 팀 계획 만들기 및 관리',
+ 'oauth.scope.geo:read.label': '지도 및 지오코딩',
+ 'oauth.scope.geo:read.description': '위치 검색, 지도 URL 확인, 좌표 역지오코딩',
+ 'oauth.scope.weather:read.label': '날씨 예보',
+ 'oauth.scope.weather:read.description': '여행 위치 및 날짜의 날씨 예보 가져오기',
+ 'oauth.scope.journey:read.label': 'Journey 보기',
+ 'oauth.scope.journey:read.description': 'Journey, 항목, 기여자 목록 읽기',
+ 'oauth.scope.journey:write.label': 'Journey 관리',
+ 'oauth.scope.journey:write.description': 'Journey 및 항목 만들기, 업데이트, 삭제',
+ 'oauth.scope.journey:share.label': 'Journey 링크 관리',
+ 'oauth.scope.journey:share.description': 'Journey의 공개 공유 링크 만들기, 업데이트, 취소',
+
+ // System notices — 3.0.0 upgrade
+ 'system_notice.v3_photos.title': '3.0에서 사진이 이동했습니다',
+ 'system_notice.v3_photos.body': '여행 플래너의 **사진** 기능이 제거되었습니다. 사진은 안전합니다 — TREK은 Immich 또는 Synology 라이브러리를 수정하지 않았습니다.\n\n사진은 이제 **Journey** 애드온에 있습니다. Journey는 선택 사항입니다 — 아직 사용할 수 없다면 관리자에게 관리자 → 애드온에서 활성화를 요청하세요.',
+ 'system_notice.v3_journey.title': 'Journey를 만나보세요 — 여행 일지',
+ 'system_notice.v3_journey.body': '타임라인, 사진 갤러리, 인터랙티브 지도가 있는 풍부한 여행 이야기로 여행을 기록하세요.',
+ 'system_notice.v3_journey.cta_label': 'Journey 열기',
+ 'system_notice.v3_journey.highlight_timeline': '일별 타임라인 및 갤러리',
+ 'system_notice.v3_journey.highlight_photos': 'Immich 또는 Synology에서 가져오기',
+ 'system_notice.v3_journey.highlight_share': '공개 공유 — 로그인 불필요',
+ 'system_notice.v3_journey.highlight_export': 'PDF 사진 책으로 내보내기',
+ 'system_notice.v3_features.title': '3.0의 더 많은 하이라이트',
+ 'system_notice.v3_features.body': '이번 릴리스에서 알아두면 좋은 몇 가지 더.',
+ 'system_notice.v3_features.highlight_dashboard': '모바일 우선 대시보드 재설계',
+ 'system_notice.v3_features.highlight_offline': 'PWA로 완전한 오프라인 모드',
+ 'system_notice.v3_features.highlight_search': '실시간 장소 검색 자동완성',
+ 'system_notice.v3_features.highlight_import': 'KMZ/KML 파일에서 장소 가져오기',
+
+ // System notices — MCP OAuth 2.1 upgrade
+ 'system_notice.v3_mcp.title': 'MCP: OAuth 2.1 업그레이드',
+ 'system_notice.v3_mcp.body': 'MCP 통합이 완전히 개선되었습니다. OAuth 2.1이 이제 권장 인증 방법입니다. 기존 정적 토큰 (trek_…)은 더 이상 사용되지 않으며 향후 릴리스에서 제거될 예정입니다.',
+ 'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 권장 (mcp-remote)',
+ 'system_notice.v3_mcp.highlight_scopes': '24개 세분화된 권한 범위',
+ 'system_notice.v3_mcp.highlight_deprecated': '정적 trek_ 토큰 더 이상 사용 안 됨',
+ 'system_notice.v3_mcp.highlight_tools': '확장된 도구 모음 및 프롬프트',
+
+ // System notices — personal thank you
+ 'system_notice.v3_thankyou.title': '개인적인 감사 인사',
+ 'system_notice.v3_thankyou.body': '떠나시기 전에 잠깐 시간을 내주세요.\n\nTREK은 제 자신의 여행을 위해 만든 사이드 프로젝트로 시작했습니다. 4,000명이 넘는 분들이 모험을 계획하는 데 신뢰해 주실 줄은 상상도 못 했습니다. 모든 별, 모든 이슈, 모든 기능 요청 — 저는 다 읽고, 그것들이 풀타임 직장과 대학 사이의 늦은 밤을 버티게 해줍니다.\n\n알아주셨으면 합니다: TREK은 항상 오픈 소스이고, 항상 자체 호스팅이며, 항상 여러분의 것입니다. 추적 없음, 구독 없음, 조건 없음. 그저 여러분만큼 여행을 사랑하는 누군가가 만든 도구입니다.\n\n[jubnl](https://github.com/jubnl)에게 특별한 감사를. 당신은 훌륭한 협력자가 되었습니다. 3.0을 훌륭하게 만든 많은 부분에 당신의 손길이 담겨 있습니다. 거칠던 초기에 이 프로젝트를 믿어줘서 고맙습니다.\n\n그리고 버그를 제출하고, 문자열을 번역하고, TREK을 친구에게 공유하거나, 단순히 여행 계획에 사용해 주신 모든 분들께 — **감사합니다**. 여러분이 바로 이것이 존재하는 이유입니다.\n\n함께하는 더 많은 모험을 위해.\n\n— Maurice\n\n---\n\n[Discord 커뮤니티에 참여하세요](https://discord.gg/7Q6M6jDwzf)\n\nTREK이 여행을 더 즐겁게 만들어 준다면, [커피 한 잔](https://ko-fi.com/mauriceboe)으로 불을 켜두는 데 도움이 됩니다.',
+
+ // System notices — 3.0.14
+ 'system_notice.v3014_whitespace_collision.title': '조치 필요: 사용자 계정 충돌',
+ 'system_notice.v3014_whitespace_collision.body': '3.0.14 업그레이드 중 저장된 계정의 앞뒤 공백으로 인한 사용자 이름 또는 이메일 충돌이 감지되었습니다. 영향받은 계정은 자동으로 이름이 변경되었습니다. 검토가 필요한 계정을 확인하려면 **[migration] WHITESPACE COLLISION**으로 시작하는 줄의 서버 로그를 확인하세요.',
+
+ // System notices — onboarding
+ 'system_notice.welcome_v1.title': 'TREK에 오신 것을 환영합니다',
+ 'system_notice.welcome_v1.body': '올인원 여행 플래너. 일정을 만들고, 친구들과 여행을 공유하고, 온라인 또는 오프라인으로 체계적으로 유지하세요.',
+ 'system_notice.welcome_v1.cta_label': '여행 계획',
+ 'system_notice.welcome_v1.hero_alt': 'TREK 계획 UI 오버레이가 있는 아름다운 여행지',
+ 'system_notice.welcome_v1.highlight_plan': '모든 여행을 위한 일별 일정',
+ 'system_notice.welcome_v1.highlight_share': '여행 파트너와 협업',
+ 'system_notice.welcome_v1.highlight_offline': '모바일에서 오프라인으로 작동',
+ 'system_notice.dev_test_modal.title': '[Dev] 테스트 공지',
+ 'system_notice.dev_test_modal.body': '개발 전용 테스트 공지입니다.',
+ 'system_notice.pager.prev': '이전 공지',
+ 'system_notice.pager.next': '다음 공지',
+ 'system_notice.pager.counter': '{current} / {total}',
+ 'system_notice.pager.goto': '{n}번 공지로 이동',
+ 'system_notice.pager.position': '공지 {current}/{total}',
+ 'transport.addTransport': '교통 추가',
+ 'transport.modalTitle.create': '교통 추가',
+ 'transport.modalTitle.edit': '교통 편집',
+ 'transport.title': '교통',
+ 'transport.addManual': '직접 교통 입력',
+
+ // Added to match EN keys
+ 'journey.editor.uploadingProgress': '업로드 중 {done}/{total}…',
+ 'journey.editor.uploadFailed': '사진 업로드 실패',
+ 'journey.editor.uploadPartialFailed': '{total}개 중 {failed}개의 사진을 업로드하지 못했습니다 — 다시 저장하여 재시도하세요',
+ 'journey.photosUploadFailed': '일부 사진을 업로드하지 못했습니다',
+ 'settings.oauth.modal.machineClient': '머신 클라이언트(브라우저 로그인 없음)',
+ 'settings.oauth.modal.machineClientHint': 'client_credentials 권한 부여를 사용합니다 — 리디렉션 URI가 필요하지 않습니다. 토큰은 client_id + client_secret을 통해 직접 발급되며 선택한 범위 내에서 사용자로 작동합니다.',
+ 'settings.oauth.modal.machineClientUsage': '토큰 받기: grant_type=client_credentials, client_id, client_secret으로 POST /oauth/token을 호출하세요. 브라우저도 새로 고침 토큰도 필요 없습니다.',
+ 'settings.oauth.badge.machine': '머신',
+}
+
+export default ko
diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts
index 0cb55bc1..93694977 100644
--- a/client/src/i18n/translations/nl.ts
+++ b/client/src/i18n/translations/nl.ts
@@ -325,6 +325,10 @@ const nl: Record = {
'settings.oauth.toast.revoked': 'Sessie ingetrokken',
'settings.oauth.toast.revokeError': 'Sessie kon niet worden ingetrokken',
'settings.oauth.toast.rotateError': 'Clientgeheim kon niet worden vernieuwd',
+ 'settings.oauth.modal.machineClient': 'Machineclient (zonder browserinlog)',
+ 'settings.oauth.modal.machineClientHint': "Gebruikt de client_credentials grant — geen redirect-URI's nodig. Het token wordt direct verstrekt via client_id + client_secret en handelt namens jou binnen de geselecteerde scopes.",
+ 'settings.oauth.modal.machineClientUsage': 'Token ophalen: POST /oauth/token met grant_type=client_credentials, client_id en client_secret. Geen browser, geen vernieuwingstoken.',
+ 'settings.oauth.badge.machine': 'machine',
'settings.account': 'Account',
'settings.about': 'Over',
'settings.about.reportBug': 'Bug melden',
@@ -2078,8 +2082,11 @@ const nl: Record = {
'journey.synced.places': 'plaatsen',
'journey.synced.synced': 'gesynchroniseerd',
'journey.editor.discardChangesConfirm': 'Je hebt niet-opgeslagen wijzigingen. Verwerpen?',
+ 'journey.editor.uploadFailed': 'Foto uploaden mislukt',
'journey.editor.uploadPhotos': 'Foto\'s uploaden',
'journey.editor.uploading': 'Uploaden...',
+ 'journey.editor.uploadingProgress': 'Uploaden {done}/{total}…',
+ 'journey.editor.uploadPartialFailed': '{failed} van {total} foto\'s mislukt — sla opnieuw op om het opnieuw te proberen',
'journey.editor.fromGallery': 'Uit galerij',
'journey.editor.allPhotosAdded': 'Alle foto\'s al toegevoegd',
'journey.editor.writeStory': 'Schrijf je verhaal...',
@@ -2170,6 +2177,7 @@ const nl: Record = {
'journey.settings.failedToDelete': 'Verwijderen mislukt',
'journey.entries.deleteTitle': 'Vermelding verwijderen',
'journey.photosUploaded': "{count} foto's geüpload",
+ 'journey.photosUploadFailed': "Sommige foto's konden niet worden geüpload",
'journey.photosAdded': "{count} foto's toegevoegd",
'journey.public.notFound': 'Niet gevonden',
'journey.public.notFoundMessage': 'Dit reisverslag bestaat niet of de link is verlopen.',
diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts
index 87f768a9..205ccaab 100644
--- a/client/src/i18n/translations/pl.ts
+++ b/client/src/i18n/translations/pl.ts
@@ -295,6 +295,10 @@ const pl: Record = {
'settings.oauth.toast.revoked': 'Sesja unieważniona',
'settings.oauth.toast.revokeError': 'Nie udało się unieważnić sesji',
'settings.oauth.toast.rotateError': 'Nie udało się odnowić sekretu klienta',
+ 'settings.oauth.modal.machineClient': 'Klient maszynowy (bez logowania przez przeglądarkę)',
+ 'settings.oauth.modal.machineClientHint': 'Używa grantu client_credentials — nie są potrzebne URI przekierowania. Token jest wystawiany bezpośrednio przez client_id + client_secret i działa w Twoim imieniu w ramach wybranych zakresów.',
+ 'settings.oauth.modal.machineClientUsage': 'Pobierz token: POST /oauth/token z grant_type=client_credentials, client_id i client_secret. Bez przeglądarki, bez tokenu odświeżania.',
+ 'settings.oauth.badge.machine': 'maszynowy',
'settings.account': 'Konto',
'settings.about': 'O aplikacji',
'settings.about.reportBug': 'Zgłoś błąd',
@@ -2071,8 +2075,11 @@ const pl: Record = {
'journey.synced.places': 'miejsca',
'journey.synced.synced': 'zsynchronizowane',
'journey.editor.discardChangesConfirm': 'Masz niezapisane zmiany. Odrzucić?',
+ 'journey.editor.uploadFailed': 'Przesyłanie zdjęć nie powiodło się',
'journey.editor.uploadPhotos': 'Prześlij zdjęcia',
'journey.editor.uploading': 'Przesyłanie...',
+ 'journey.editor.uploadingProgress': 'Przesyłanie {done}/{total}…',
+ 'journey.editor.uploadPartialFailed': '{failed} z {total} zdjęć nie powiodło się — zapisz ponownie, aby spróbować',
'journey.editor.fromGallery': 'Z galerii',
'journey.editor.allPhotosAdded': 'Wszystkie zdjęcia już dodane',
'journey.editor.writeStory': 'Napisz swoją historię...',
@@ -2163,6 +2170,7 @@ const pl: Record = {
'journey.settings.failedToDelete': 'Nie udało się usunąć',
'journey.entries.deleteTitle': 'Usuń wpis',
'journey.photosUploaded': '{count} zdjęć przesłanych',
+ 'journey.photosUploadFailed': 'Nie udało się przesłać niektórych zdjęć',
'journey.photosAdded': '{count} zdjęć dodanych',
'journey.public.notFound': 'Nie znaleziono',
'journey.public.notFoundMessage': 'Ten dziennik podróży nie istnieje lub link wygasł.',
diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts
index f4f23fb8..75b2141c 100644
--- a/client/src/i18n/translations/ru.ts
+++ b/client/src/i18n/translations/ru.ts
@@ -325,6 +325,10 @@ const ru: Record = {
'settings.oauth.toast.revoked': 'Сессия отозвана',
'settings.oauth.toast.revokeError': 'Не удалось отозвать сессию',
'settings.oauth.toast.rotateError': 'Не удалось обновить секрет клиента',
+ 'settings.oauth.modal.machineClient': 'Машинный клиент (без входа через браузер)',
+ 'settings.oauth.modal.machineClientHint': 'Использует грант client_credentials — URI перенаправления не требуются. Токен выдаётся напрямую через client_id + client_secret и действует от вашего имени в пределах выбранных областей.',
+ 'settings.oauth.modal.machineClientUsage': 'Получить токен: POST /oauth/token с grant_type=client_credentials, client_id и client_secret. Без браузера, без токена обновления.',
+ 'settings.oauth.badge.machine': 'машинный',
'settings.account': 'Аккаунт',
'settings.about': 'О приложении',
'settings.about.reportBug': 'Сообщить об ошибке',
@@ -2078,8 +2082,11 @@ const ru: Record = {
'journey.synced.places': 'мест',
'journey.synced.synced': 'синхронизировано',
'journey.editor.discardChangesConfirm': 'У вас есть несохранённые изменения. Отменить?',
+ 'journey.editor.uploadFailed': 'Не удалось загрузить фото',
'journey.editor.uploadPhotos': 'Загрузить фото',
'journey.editor.uploading': 'Загрузка...',
+ 'journey.editor.uploadingProgress': 'Загрузка {done}/{total}…',
+ 'journey.editor.uploadPartialFailed': '{failed} из {total} фото не удалось загрузить — сохраните снова для повтора',
'journey.editor.fromGallery': 'Из галереи',
'journey.editor.allPhotosAdded': 'Все фото уже добавлены',
'journey.editor.writeStory': 'Напишите свою историю...',
@@ -2170,6 +2177,7 @@ const ru: Record = {
'journey.settings.failedToDelete': 'Не удалось удалить',
'journey.entries.deleteTitle': 'Удалить запись',
'journey.photosUploaded': '{count} фото загружено',
+ 'journey.photosUploadFailed': 'Некоторые фото не удалось загрузить',
'journey.photosAdded': '{count} фото добавлено',
'journey.public.notFound': 'Не найдено',
'journey.public.notFoundMessage': 'Это путешествие не существует или ссылка устарела.',
diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts
index ffa564b6..5c8f4979 100644
--- a/client/src/i18n/translations/zh.ts
+++ b/client/src/i18n/translations/zh.ts
@@ -325,6 +325,10 @@ const zh: Record = {
'settings.oauth.toast.revoked': '会话已撤销',
'settings.oauth.toast.revokeError': '撤销会话失败',
'settings.oauth.toast.rotateError': '轮换客户端密钥失败',
+ 'settings.oauth.modal.machineClient': '机器客户端(无需浏览器登录)',
+ 'settings.oauth.modal.machineClientHint': '使用 client_credentials 授权——无需重定向 URI。令牌通过 client_id + client_secret 直接颁发,并在所选范围内以您的身份运行。',
+ 'settings.oauth.modal.machineClientUsage': '获取令牌:向 /oauth/token 发送 POST 请求,携带 grant_type=client_credentials、client_id 和 client_secret。无需浏览器,无刷新令牌。',
+ 'settings.oauth.badge.machine': '机器',
'settings.account': '账户',
'settings.about': '关于',
'settings.about.reportBug': '报告错误',
@@ -2078,8 +2082,11 @@ const zh: Record = {
'journey.synced.places': '个地点',
'journey.synced.synced': '已同步',
'journey.editor.discardChangesConfirm': '您有未保存的更改。要放弃吗?',
+ 'journey.editor.uploadFailed': '照片上传失败',
'journey.editor.uploadPhotos': '上传照片',
'journey.editor.uploading': '上传中...',
+ 'journey.editor.uploadingProgress': '上传中 {done}/{total}…',
+ 'journey.editor.uploadPartialFailed': '{total} 张中有 {failed} 张上传失败 — 再次保存以重试',
'journey.editor.fromGallery': '从相册',
'journey.editor.allPhotosAdded': '所有照片已添加',
'journey.editor.writeStory': '写下你的故事...',
@@ -2170,6 +2177,7 @@ const zh: Record = {
'journey.settings.failedToDelete': '删除失败',
'journey.entries.deleteTitle': '删除条目',
'journey.photosUploaded': '{count} 张照片已上传',
+ 'journey.photosUploadFailed': '部分照片上传失败',
'journey.photosAdded': '{count} 张照片已添加',
'journey.public.notFound': '未找到',
'journey.public.notFoundMessage': '此旅程不存在或链接已过期。',
diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts
index 331596c5..695eb974 100644
--- a/client/src/i18n/translations/zhTw.ts
+++ b/client/src/i18n/translations/zhTw.ts
@@ -384,6 +384,10 @@ const zhTw: Record = {
'settings.oauth.toast.revoked': '工作階段已撤銷',
'settings.oauth.toast.revokeError': '撤銷工作階段失敗',
'settings.oauth.toast.rotateError': '輪換客戶端密鑰失敗',
+ 'settings.oauth.modal.machineClient': '機器客戶端(無需瀏覽器登入)',
+ 'settings.oauth.modal.machineClientHint': '使用 client_credentials 授權——無需重新導向 URI。令牌透過 client_id + client_secret 直接簽發,並在所選範圍內以您的身份運行。',
+ 'settings.oauth.modal.machineClientUsage': '取得令牌:向 /oauth/token 發送 POST 請求,攜帶 grant_type=client_credentials、client_id 和 client_secret。無需瀏覽器,無重整令牌。',
+ 'settings.oauth.badge.machine': '機器',
'settings.account': '賬戶',
'settings.about': '關於',
'settings.about.reportBug': '回報錯誤',
@@ -2036,8 +2040,11 @@ const zhTw: Record = {
'journey.synced.places': '個地點',
'journey.synced.synced': '已同步',
'journey.editor.discardChangesConfirm': '您有未儲存的變更。要放棄嗎?',
+ 'journey.editor.uploadFailed': '照片上傳失敗',
'journey.editor.uploadPhotos': '上傳照片',
'journey.editor.uploading': '上傳中...',
+ 'journey.editor.uploadingProgress': '上傳中 {done}/{total}…',
+ 'journey.editor.uploadPartialFailed': '{total} 張中有 {failed} 張上傳失敗 — 再次儲存以重試',
'journey.editor.fromGallery': '從相簿',
'journey.editor.allPhotosAdded': '所有照片已新增',
'journey.editor.writeStory': '寫下你的故事...',
@@ -2128,6 +2135,7 @@ const zhTw: Record = {
'journey.settings.failedToDelete': '刪除失敗',
'journey.entries.deleteTitle': '刪除條目',
'journey.photosUploaded': '{count} 張照片已上傳',
+ 'journey.photosUploadFailed': '部分照片上傳失敗',
'journey.photosAdded': '{count} 張照片已新增',
'journey.public.notFound': '未找到',
'journey.public.notFoundMessage': '此旅程不存在或連結已過期。',
diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx
index e7aa51f9..d0c20609 100644
--- a/client/src/pages/JourneyDetailPage.tsx
+++ b/client/src/pages/JourneyDetailPage.tsx
@@ -1,5 +1,7 @@
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
import { formatLocationName } from '../utils/formatters'
+import { normalizeImageFiles } from '../utils/convertHeic'
+import { type ResilientResult, type UploadProgress } from '../utils/uploadQueue'
import { createPortal } from 'react-dom'
import { useParams, useNavigate } from 'react-router-dom'
import { useJourneyStore } from '../store/journeyStore'
@@ -29,6 +31,7 @@ import MobileEntryView from '../components/Journey/MobileEntryView'
import { useIsMobile } from '../hooks/useIsMobile'
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip, JourneyDetail } from '../store/journeyStore'
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
+import { getApiErrorMessage } from '../types'
const GRADIENTS = [
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
@@ -746,8 +749,8 @@ export default function JourneyDetailPage() {
}
return entryId
}}
- onUploadPhotos={async (entryId, formData) => {
- return await uploadPhotos(entryId, formData)
+ onUploadPhotos={async (entryId, files, cbs) => {
+ return await uploadPhotos(entryId, files, cbs)
}}
onDone={() => {
setEditingEntry(null)
@@ -985,7 +988,8 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
const [showPicker, setShowPicker] = useState(false)
const [pickerProvider, setPickerProvider] = useState(null)
const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([])
- const [galleryUploading, setGalleryUploading] = useState(false)
+ 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
@@ -1025,17 +1029,22 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
const handleGalleryUpload = async (e: React.ChangeEvent) => {
const files = e.target.files
if (!files?.length) return
- setGalleryUploading(true)
+ setGalleryProgress({ done: 0, total: files.length })
try {
- const formData = new FormData()
- for (const f of files) formData.append('photos', f)
- await journeyApi.uploadGalleryPhotos(journeyId, formData)
- toast.success(t('journey.photosUploaded', { count: files.length }))
+ 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 {
- toast.error(t('journey.settings.coverFailed'))
+ } catch (err) {
+ toast.error(getApiErrorMessage(err, t('journey.photosUploadFailed')))
} finally {
- setGalleryUploading(false)
+ setGalleryProgress(null)
}
e.target.value = ''
}
@@ -1080,7 +1089,7 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
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 ? (
- <>
{t('journey.editor.uploading')}>
+ <>
{galleryProgress ? t('journey.editor.uploadingProgress', { done: String(galleryProgress.done), total: String(galleryProgress.total) }) : t('journey.editor.uploading')}>
) : (
<> {t('common.upload')}>
)}
@@ -1769,7 +1778,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
: t('journey.picker.newGallery')
return (
- { if (e.target === e.currentTarget) e.preventDefault() }}>
+
{ if (e.target === e.currentTarget) e.preventDefault() }}>
e.stopPropagation()}>
{/* Header */}
@@ -2169,10 +2178,11 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
galleryPhotos: GalleryPhoto[]
onClose: () => void
onSave: (data: Record
) => Promise
- onUploadPhotos: (entryId: number, formData: FormData) => Promise
+ onUploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise>
onDone: () => void
}) {
const { t } = useTranslation()
+ const toast = useToast()
const isMobile = useIsMobile()
const [title, setTitle] = useState(entry.title || '')
const [story, setStory] = useState(entry.story || '')
@@ -2191,7 +2201,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
const [pros, setPros] = useState(entry.pros_cons?.pros?.length ? entry.pros_cons.pros : [''])
const [cons, setCons] = useState(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
const [saving, setSaving] = useState(false)
- const [uploading, setUploading] = useState(false)
+ const [uploadProgress, setUploadProgress] = useState<{ done: number; total: number } | null>(null)
const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || [])
const [pendingFiles, setPendingFiles] = useState([])
const [pendingLinkIds, setPendingLinkIds] = useState([])
@@ -2244,9 +2254,21 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
})
// upload queued files after entry is created
if (pendingFiles.length > 0 && entryId) {
- const formData = new FormData()
- for (const f of pendingFiles) formData.append('photos', f)
- await onUploadPhotos(entryId, formData)
+ 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) {
@@ -2265,7 +2287,8 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
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.
- setPendingFiles(prev => [...prev, ...Array.from(files)])
+ const normalized = await normalizeImageFiles(files)
+ setPendingFiles(prev => [...prev, ...normalized])
}
return (
@@ -2300,11 +2323,11 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
fileRef.current?.click()}
- disabled={uploading}
+ 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"
>
- {uploading ? (
- <>
{t('journey.editor.uploading')}>
+ {uploadProgress ? (
+ <>
{t('journey.editor.uploadingProgress', { done: String(uploadProgress.done), total: String(uploadProgress.total) })}>
) : (
<> {t('journey.editor.uploadPhotos')}>
)}
diff --git a/client/src/pages/SharedTripPage.tsx b/client/src/pages/SharedTripPage.tsx
index 44a5ae6a..a7c17844 100644
--- a/client/src/pages/SharedTripPage.tsx
+++ b/client/src/pages/SharedTripPage.tsx
@@ -12,6 +12,7 @@ import { renderToStaticMarkup } from 'react-dom/server'
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
import { isDayInAccommodationRange } from '../utils/dayOrder'
import { getTransportForDay, getMergedItems } from '../utils/dayMerge'
+import { splitReservationDateTime } from '../utils/formatters'
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
@@ -219,7 +220,7 @@ export default function SharedTripPage() {
const r = item.data
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
- const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
+ const time = splitReservationDateTime(r.reservation_time).time ?? ''
let sub = ''
if (r.type === 'flight') sub = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
else if (r.type === 'train') sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ')
@@ -276,8 +277,9 @@ export default function SharedTripPage() {
{(reservations || []).map((r: any) => {
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
- const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
- const date = r.reservation_time ? new Date((r.reservation_time.includes('T') ? r.reservation_time.split('T')[0] : r.reservation_time) + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) : ''
+ const { date: rDate, time: rTime } = splitReservationDateTime(r.reservation_time)
+ const time = rTime ?? ''
+ const date = rDate ? new Date(rDate + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) : ''
return (
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx
index 94e41de7..c19ed55c 100644
--- a/client/src/pages/TripPlannerPage.tsx
+++ b/client/src/pages/TripPlannerPage.tsx
@@ -1003,6 +1003,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
rightWidth={isMobile ? 0 : (rightCollapsed ? 0 : rightWidth)}
collapsed={dayDetailCollapsed}
onToggleCollapse={() => setDayDetailCollapsed(c => !c)}
+ mobile={isMobile}
/>
)
})()}
@@ -1116,7 +1117,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
{mobileSidebarOpen === 'left'
- ?
{ handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} />
+ ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} />
: { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
}
diff --git a/client/src/store/journeyStore.test.ts b/client/src/store/journeyStore.test.ts
index d5c7a2a3..635e3f2c 100644
--- a/client/src/store/journeyStore.test.ts
+++ b/client/src/store/journeyStore.test.ts
@@ -1,6 +1,7 @@
// FE-STORE-JOURNEY-001 to FE-STORE-JOURNEY-015
import { http, HttpResponse } from 'msw';
import { server } from '../../tests/helpers/msw/server';
+import { journeyApi } from '../api/client';
import { useJourneyStore } from './journeyStore';
import type { JourneyDetail, JourneyEntry, JourneyPhoto } from './journeyStore';
@@ -282,16 +283,64 @@ describe('journeyStore', () => {
useJourneyStore.setState({ current: detail });
const newPhoto = buildPhoto({ id: 91, entry_id: 100 });
- server.use(
- http.post('/api/journeys/entries/100/photos', () =>
- HttpResponse.json({ photos: [newPhoto] })
- )
- );
- const result = await useJourneyStore.getState().uploadPhotos(100, new FormData());
- expect(result).toHaveLength(1);
- expect(result[0].id).toBe(91);
+ // MSW's XHR interceptor calls request.arrayBuffer() on FormData bodies to
+ // emit upload progress events, which hangs in jsdom+Node. Spy on the API
+ // layer directly so this test exercises store state management only.
+ const spy = vi.spyOn(journeyApi, 'uploadPhotos').mockResolvedValue({ photos: [newPhoto] } as any);
+ const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' });
+ const result = await useJourneyStore.getState().uploadPhotos(100, [file]);
+ expect(result.succeeded).toHaveLength(1);
+ expect(result.succeeded[0].id).toBe(91);
+ expect(result.failed).toHaveLength(0);
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
expect(storedEntry?.photos).toHaveLength(2);
+ spy.mockRestore();
+ });
+
+ it('FE-STORE-JOURNEY-017: uploadPhotos returns failed files and merges only succeeded on network error', async () => {
+ const entry = buildEntry({ id: 100, photos: [] });
+ const detail = buildJourneyDetail({ id: 50, entries: [entry] });
+ useJourneyStore.setState({ current: detail });
+
+ server.use(
+ http.post('/api/journeys/entries/100/photos', () =>
+ HttpResponse.error()
+ )
+ );
+ const file = new File(['x'], 'fail.jpg', { type: 'image/jpeg' });
+ const result = await useJourneyStore.getState().uploadPhotos(100, [file]);
+ expect(result.succeeded).toHaveLength(0);
+ expect(result.failed).toHaveLength(1);
+ expect(result.failed[0]).toBe(file);
+ const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
+ expect(storedEntry?.photos).toHaveLength(0);
+ });
+
+ it('FE-STORE-JOURNEY-018: uploadPhotos merges each file result incrementally on partial success', async () => {
+ const entry = buildEntry({ id: 100, photos: [] });
+ const detail = buildJourneyDetail({ id: 50, entries: [entry] });
+ useJourneyStore.setState({ current: detail });
+
+ const photo1 = buildPhoto({ id: 91, entry_id: 100 });
+ const photo2 = buildPhoto({ id: 92, entry_id: 100 });
+ let callCount = 0;
+ // Spy on the API layer to avoid MSW's FormData body hang (see FE-STORE-JOURNEY-013).
+ // Use a 4xx-shaped error for file2 so isRetryable returns false and the test runs instantly.
+ const spy = vi.spyOn(journeyApi, 'uploadPhotos').mockImplementation(async () => {
+ callCount++;
+ if (callCount === 1) return { photos: [photo1] } as any;
+ throw Object.assign(new Error('Bad Request'), { response: { status: 400 } });
+ });
+ const file1 = new File(['a'], 'ok.jpg', { type: 'image/jpeg' });
+ const file2 = new File(['b'], 'fail.jpg', { type: 'image/jpeg' });
+ const result = await useJourneyStore.getState().uploadPhotos(100, [file1, file2], undefined);
+ expect(result.succeeded).toHaveLength(1);
+ expect(result.succeeded[0].id).toBe(photo1.id);
+ expect(result.failed).toHaveLength(1);
+ const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
+ expect(storedEntry?.photos).toHaveLength(1);
+ void photo2; // referenced to avoid lint warning
+ spy.mockRestore();
});
// ── deletePhoto ──────────────────────────────────────────────────────────
diff --git a/client/src/store/journeyStore.ts b/client/src/store/journeyStore.ts
index c2edfa69..279b581f 100644
--- a/client/src/store/journeyStore.ts
+++ b/client/src/store/journeyStore.ts
@@ -1,5 +1,6 @@
import { create } from 'zustand'
import { journeyApi } from '../api/client'
+import { uploadFilesResilient, type ResilientResult, type UploadProgress } from '../utils/uploadQueue'
export interface Journey {
id: number
@@ -121,8 +122,8 @@ interface JourneyState {
deleteEntry: (entryId: number) => Promise
reorderEntries: (journeyId: number, orderedIds: number[]) => Promise
- uploadPhotos: (entryId: number, formData: FormData) => Promise
- uploadGalleryPhotos: (journeyId: number, formData: FormData) => Promise
+ uploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise>
+ uploadGalleryPhotos: (journeyId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise>
unlinkPhoto: (entryId: number, journeyPhotoId: number) => Promise
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => Promise
deletePhoto: (photoId: number) => Promise
@@ -237,32 +238,49 @@ export const useJourneyStore = create((set, get) => ({
}
},
- uploadPhotos: async (entryId, formData) => {
- const data = await journeyApi.uploadPhotos(entryId, formData)
- const photos = data.photos || []
- set(s => {
- if (!s.current) return s
- return {
- current: {
- ...s.current,
- entries: s.current.entries.map(e =>
- e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
- ),
- gallery: [...(s.current.gallery || []), ...(data.gallery || [])],
- },
- }
- })
- return photos
+ uploadPhotos: async (entryId, files, cbs) => {
+ return uploadFilesResilient(
+ files,
+ async (file, opts) => {
+ const fd = new FormData()
+ fd.append('photos', file)
+ const data = await journeyApi.uploadPhotos(entryId, fd, opts)
+ const photos: JourneyPhoto[] = data.photos || []
+ const gallery: GalleryPhoto[] = data.gallery || []
+ set(s => {
+ if (!s.current) return s
+ return {
+ current: {
+ ...s.current,
+ entries: s.current.entries.map(e =>
+ e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
+ ),
+ gallery: [...(s.current.gallery || []), ...gallery],
+ },
+ }
+ })
+ return photos
+ },
+ { onProgress: cbs?.onProgress },
+ )
},
- uploadGalleryPhotos: async (journeyId, formData) => {
- const data = await journeyApi.uploadGalleryPhotos(journeyId, formData)
- const photos: GalleryPhoto[] = data.photos || []
- set(s => {
- if (!s.current || s.current.id !== journeyId) return s
- return { current: { ...s.current, gallery: [...(s.current.gallery || []), ...photos] } }
- })
- return photos
+ uploadGalleryPhotos: async (journeyId, files, cbs) => {
+ return uploadFilesResilient(
+ files,
+ async (file, opts) => {
+ const fd = new FormData()
+ fd.append('photos', file)
+ const data = await journeyApi.uploadGalleryPhotos(journeyId, fd, opts)
+ const photos: GalleryPhoto[] = data.photos || []
+ set(s => {
+ if (!s.current || s.current.id !== journeyId) return s
+ return { current: { ...s.current, gallery: [...(s.current.gallery || []), ...photos] } }
+ })
+ return photos
+ },
+ { onProgress: cbs?.onProgress },
+ )
},
unlinkPhoto: async (entryId, journeyPhotoId) => {
diff --git a/client/src/utils/convertHeic.ts b/client/src/utils/convertHeic.ts
new file mode 100644
index 00000000..6f757792
--- /dev/null
+++ b/client/src/utils/convertHeic.ts
@@ -0,0 +1,17 @@
+function looksLikeHeic(file: File): boolean {
+ const ext = file.name.split('.').pop()?.toLowerCase() ?? ''
+ return ext === 'heic' || ext === 'heif' || file.type === 'image/heic' || file.type === 'image/heif'
+}
+
+export async function normalizeImageFile(file: File): Promise {
+ if (!looksLikeHeic(file)) return file
+ const { isHeic, heicTo } = await import('heic-to')
+ if (!(await isHeic(file))) return file
+ const blob = await heicTo({ blob: file, type: 'image/jpeg', quality: 0.92 })
+ const jpegName = file.name.replace(/\.(heic|heif)$/i, '.jpg')
+ return new File([blob], jpegName, { type: 'image/jpeg' })
+}
+
+export async function normalizeImageFiles(files: FileList | File[]): Promise {
+ return Promise.all(Array.from(files).map(normalizeImageFile))
+}
diff --git a/client/src/utils/dayMerge.test.ts b/client/src/utils/dayMerge.test.ts
index 1f894792..bc1b5d74 100644
--- a/client/src/utils/dayMerge.test.ts
+++ b/client/src/utils/dayMerge.test.ts
@@ -57,11 +57,27 @@ describe('getTransportForDay', () => {
{ id: 3, day_number: 3 },
]
- it('excludes non-transport types', () => {
+ it('excludes hotel (rendered via accommodation path)', () => {
const reservations = [{ id: 10, type: 'hotel', day_id: 1 }]
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(0)
})
+ it('includes tour booking on the correct day', () => {
+ const reservations = [{ id: 20, type: 'tour', day_id: 1 }]
+ expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(1)
+ expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(0)
+ })
+
+ it('includes restaurant, event, and other bookings by day_id', () => {
+ const reservations = [
+ { id: 30, type: 'restaurant', day_id: 2 },
+ { id: 31, type: 'event', day_id: 2 },
+ { id: 32, type: 'other', day_id: 2 },
+ ]
+ expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(3)
+ expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(0)
+ })
+
it('includes single-day transport on the correct day', () => {
const reservations = [{ id: 10, type: 'flight', day_id: 1, end_day_id: 1 }]
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(1)
diff --git a/client/src/utils/dayMerge.ts b/client/src/utils/dayMerge.ts
index 0889ace2..d465139c 100644
--- a/client/src/utils/dayMerge.ts
+++ b/client/src/utils/dayMerge.ts
@@ -55,7 +55,7 @@ export function getTransportForDay(opts: {
const thisDayOrder = getDayOrder(dayId)
return reservations.filter(r => {
- if (!TRANSPORT_TYPES.has(r.type)) return false
+ if (r.type === 'hotel') return false
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
const startDayId = r.day_id
diff --git a/client/src/utils/formatters.test.ts b/client/src/utils/formatters.test.ts
new file mode 100644
index 00000000..08c7ab73
--- /dev/null
+++ b/client/src/utils/formatters.test.ts
@@ -0,0 +1,50 @@
+import { describe, it, expect } from 'vitest'
+import { splitReservationDateTime } from './formatters'
+
+describe('splitReservationDateTime', () => {
+ it('parses full ISO datetime', () => {
+ expect(splitReservationDateTime('2026-06-25T10:00')).toEqual({ date: '2026-06-25', time: '10:00' })
+ })
+
+ it('parses full datetime with seconds', () => {
+ expect(splitReservationDateTime('2026-06-25T10:00:30')).toEqual({ date: '2026-06-25', time: '10:00' })
+ })
+
+ it('parses date-only string', () => {
+ expect(splitReservationDateTime('2026-06-25')).toEqual({ date: '2026-06-25', time: null })
+ })
+
+ it('parses bare HH:MM (new dateless format)', () => {
+ expect(splitReservationDateTime('10:00')).toEqual({ date: null, time: '10:00' })
+ })
+
+ it('parses bare single-digit hour time', () => {
+ expect(splitReservationDateTime('9:30')).toEqual({ date: null, time: '9:30' })
+ })
+
+ it('handles legacy malformed T-prefixed time ("T10:00")', () => {
+ expect(splitReservationDateTime('T10:00')).toEqual({ date: null, time: '10:00' })
+ })
+
+ it('returns null date for T-prefixed without valid date', () => {
+ const result = splitReservationDateTime('T23:59')
+ expect(result.date).toBeNull()
+ expect(result.time).toBe('23:59')
+ })
+
+ it('returns nulls for null input', () => {
+ expect(splitReservationDateTime(null)).toEqual({ date: null, time: null })
+ })
+
+ it('returns nulls for undefined input', () => {
+ expect(splitReservationDateTime(undefined)).toEqual({ date: null, time: null })
+ })
+
+ it('returns nulls for empty string', () => {
+ expect(splitReservationDateTime('')).toEqual({ date: null, time: null })
+ })
+
+ it('returns nulls for unrecognized string', () => {
+ expect(splitReservationDateTime('garbage')).toEqual({ date: null, time: null })
+ })
+})
diff --git a/client/src/utils/formatters.ts b/client/src/utils/formatters.ts
index 7a6c01a2..d586f1cb 100644
--- a/client/src/utils/formatters.ts
+++ b/client/src/utils/formatters.ts
@@ -65,6 +65,18 @@ export function formatTime(timeStr: string | null | undefined, locale: string, t
} catch { return timeStr }
}
+export function splitReservationDateTime(value?: string | null): { date: string | null; time: string | null } {
+ if (!value) return { date: null, time: null }
+ const isoDate = /^\d{4}-\d{2}-\d{2}$/
+ if (value.includes('T')) {
+ const [d, t] = value.split('T')
+ return { date: isoDate.test(d) ? d : null, time: t ? t.slice(0, 5) : null }
+ }
+ if (isoDate.test(value)) return { date: value, time: null }
+ if (/^\d{1,2}:\d{2}/.test(value)) return { date: null, time: value.slice(0, 5) }
+ return { date: null, time: null }
+}
+
export function dayTotalCost(dayId: number, assignments: AssignmentsMap, currency: string): string | null {
const da = assignments[String(dayId)] || []
const total = da.reduce((s, a) => s + (parseFloat(a.place?.price || '') || 0), 0)
diff --git a/client/src/utils/uploadQueue.ts b/client/src/utils/uploadQueue.ts
new file mode 100644
index 00000000..2b949bec
--- /dev/null
+++ b/client/src/utils/uploadQueue.ts
@@ -0,0 +1,106 @@
+import type { AxiosProgressEvent } from 'axios'
+
+export interface UploadProgress {
+ done: number
+ total: number
+ failed: number
+ percent: number
+}
+
+export interface ResilientResult {
+ succeeded: T[]
+ failed: File[]
+}
+
+export interface UploadOpts {
+ onUploadProgress: (e: AxiosProgressEvent) => void
+ idempotencyKey: string
+}
+
+const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
+
+function isRetryable(err: unknown): boolean {
+ if (err && typeof err === 'object' && 'response' in err) {
+ const status = (err as { response?: { status?: number } }).response?.status
+ if (status !== undefined && status >= 400 && status < 500) return false
+ }
+ return true
+}
+
+export async function uploadFilesResilient(
+ files: File[],
+ uploadOne: (file: File, opts: UploadOpts) => Promise,
+ cbs?: {
+ concurrency?: number
+ retries?: number
+ onProgress?: (p: UploadProgress) => void
+ onUploaded?: (items: T[]) => void
+ },
+): Promise> {
+ const concurrency = cbs?.concurrency ?? 3
+ const maxRetries = cbs?.retries ?? 2
+
+ const totalBytes = files.reduce((s, f) => s + f.size, 0)
+ const loadedMap = new Map()
+ let doneCount = 0
+ let failedCount = 0
+
+ const emitProgress = () => {
+ if (!cbs?.onProgress) return
+ const sumLoaded = Array.from(loadedMap.values()).reduce((a, b) => a + b, 0)
+ const percent = totalBytes > 0 ? Math.round((sumLoaded / totalBytes) * 100) : 0
+ cbs.onProgress({ done: doneCount, total: files.length, failed: failedCount, percent })
+ }
+
+ const succeeded: T[] = []
+ const failedFiles: File[] = []
+
+ let idx = 0
+
+ async function worker() {
+ while (true) {
+ const i = idx++
+ if (i >= files.length) break
+ const file = files[i]
+ const idempotencyKey = crypto.randomUUID()
+ loadedMap.set(i, 0)
+
+ let items: T[] | null = null
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
+ if (attempt > 0) await sleep(400 * attempt)
+ try {
+ items = await uploadOne(file, {
+ idempotencyKey,
+ onUploadProgress: (e) => {
+ loadedMap.set(i, e.loaded)
+ emitProgress()
+ },
+ })
+ break
+ } catch (err) {
+ if (!isRetryable(err) || attempt === maxRetries) {
+ items = null
+ break
+ }
+ }
+ }
+
+ if (items !== null) {
+ succeeded.push(...items)
+ cbs?.onUploaded?.(items)
+ loadedMap.set(i, file.size)
+ doneCount++
+ } else {
+ failedFiles.push(file)
+ loadedMap.set(i, 0)
+ failedCount++
+ }
+ emitProgress()
+ }
+ }
+
+ const workers = Array.from({ length: Math.min(concurrency, files.length) }, () => worker())
+ await Promise.all(workers)
+
+ return { succeeded, failed: failedFiles }
+}
diff --git a/client/tests/unit/i18n/index.test.ts b/client/tests/unit/i18n/index.test.ts
index 079333e3..9be7d94d 100644
--- a/client/tests/unit/i18n/index.test.ts
+++ b/client/tests/unit/i18n/index.test.ts
@@ -91,9 +91,10 @@ describe('isRtlLanguage', () => {
describe('SUPPORTED_LANGUAGES', () => {
it('FE-COMP-I18N-009: contains expected entries with value/label shape', () => {
expect(Array.isArray(SUPPORTED_LANGUAGES)).toBe(true)
- expect(SUPPORTED_LANGUAGES).toHaveLength(16)
+ expect(SUPPORTED_LANGUAGES).toHaveLength(17)
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'en', label: 'English' }))
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'tr', label: 'Türkçe' }))
+ expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ja', label: '日本語' }))
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ar', label: 'العربية' }))
})
})
diff --git a/client/tests/unit/shared-contract.test.ts b/client/tests/unit/shared-contract.test.ts
new file mode 100644
index 00000000..e82f5dd9
--- /dev/null
+++ b/client/tests/unit/shared-contract.test.ts
@@ -0,0 +1,10 @@
+import { describe, it, expect } from 'vitest';
+// Smoke test: proves the client toolchain (vite / vitest) resolves @trek/shared.
+import { idParamSchema, paginationQuerySchema } from '@trek/shared';
+
+describe('@trek/shared resolves in the client toolchain', () => {
+ it('imports and uses a shared schema', () => {
+ expect(idParamSchema.parse('7')).toBe(7);
+ expect(paginationQuerySchema.parse({})).toEqual({ page: 1, perPage: 50 });
+ });
+});
diff --git a/client/tsconfig.json b/client/tsconfig.json
index e2a6dd4d..0299dacd 100644
--- a/client/tsconfig.json
+++ b/client/tsconfig.json
@@ -7,6 +7,11 @@
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
+ "baseUrl": ".",
+ "paths": {
+ "@trek/shared": ["../shared/src/index.ts"],
+ "@trek/shared/*": ["../shared/src/*"]
+ },
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
diff --git a/client/vite.config.js b/client/vite.config.js
index 0d85df86..2142a45e 100644
--- a/client/vite.config.js
+++ b/client/vite.config.js
@@ -1,6 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
+import { fileURLToPath } from 'node:url'
export default defineConfig({
plugins: [
@@ -88,9 +89,18 @@ export default defineConfig({
},
}),
],
+ resolve: {
+ alias: {
+ // @trek/shared — Zod contract package (dev: resolved to TS source).
+ '@trek/shared': fileURLToPath(new URL('../shared/src/index.ts', import.meta.url)),
+ },
+ // @trek/shared imports zod from its own source; it lives outside this root,
+ // so pin zod to the client's copy (one instance, resolvable from anywhere).
+ dedupe: ['zod'],
+ },
build: {
sourcemap: false,
- modulePreload: { polyfill: false },
+ modulePreload: { polyfill: true },
},
server: {
port: 5173,
diff --git a/client/vitest.config.ts b/client/vitest.config.ts
index 97fdc1b0..a99cba8d 100644
--- a/client/vitest.config.ts
+++ b/client/vitest.config.ts
@@ -1,8 +1,19 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
+import { fileURLToPath } from 'node:url';
export default defineConfig({
plugins: [react()],
+ resolve: {
+ alias: {
+ // @trek/shared — Zod contract package (tests resolve it to TS source,
+ // mirroring the alias in vite.config.js used by the dev server / build).
+ '@trek/shared': fileURLToPath(new URL('../shared/src/index.ts', import.meta.url)),
+ },
+ // Mirror vite.config.js: keep a single zod instance resolvable from the
+ // shared source, which lives outside this project root.
+ dedupe: ['zod'],
+ },
test: {
root: '.',
globals: true,
diff --git a/server/package-lock.json b/server/package-lock.json
index 24482213..fbec0de2 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -1,14 +1,17 @@
{
"name": "trek-server",
- "version": "3.0.18",
+ "version": "3.0.22",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trek-server",
- "version": "3.0.18",
+ "version": "3.0.22",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.28.0",
+ "@nestjs/common": "^11.1.24",
+ "@nestjs/core": "^11.1.24",
+ "@nestjs/platform-express": "^11.1.24",
"archiver": "^6.0.1",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^12.8.0",
@@ -25,16 +28,20 @@
"nodemailer": "^8.0.5",
"otplib": "^12.0.1",
"qrcode": "^1.5.4",
+ "reflect-metadata": "^0.2.2",
+ "rxjs": "^7.8.2",
"semver": "^7.7.4",
"tsx": "^4.21.0",
"typescript": "^6.0.2",
"undici": "^7.0.0",
"unzipper": "^0.12.3",
"uuid": "^14.0.0",
- "ws": "^8.19.0",
+ "ws": "^8.21.0",
"zod": "^4.3.6"
},
"devDependencies": {
+ "@nestjs/testing": "^11.1.24",
+ "@swc/core": "^1.15.40",
"@types/archiver": "^7.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13",
@@ -55,7 +62,9 @@
"@vitest/coverage-v8": "^3.2.4",
"nodemon": "^3.1.0",
"supertest": "^7.2.2",
+ "tsc-alias": "^1.8.17",
"tz-lookup": "^6.1.25",
+ "unplugin-swc": "^1.5.9",
"vitest": "^3.2.4"
}
},
@@ -1144,6 +1153,17 @@
"@jridgewell/trace-mapping": "^0.3.24"
}
},
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"dev": true,
@@ -1166,6 +1186,15 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@lukeed/csprng": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz",
+ "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0",
"license": "MIT",
@@ -1434,6 +1463,426 @@
"node": ">= 0.6"
}
},
+ "node_modules/@nestjs/common": {
+ "version": "11.1.24",
+ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.24.tgz",
+ "integrity": "sha512-9zHxaDDM+oXW9As6UsP5yYB+UqczBmpeSCIFWdPEtEukMnZhxODG1BBjaUcdBB8Sc1uzojSJSJlp3yFp853t1g==",
+ "license": "MIT",
+ "dependencies": {
+ "file-type": "21.3.4",
+ "iterare": "1.2.1",
+ "load-esm": "1.0.3",
+ "tslib": "2.8.1",
+ "uid": "2.0.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/nest"
+ },
+ "peerDependencies": {
+ "class-transformer": ">=0.4.1",
+ "class-validator": ">=0.13.2",
+ "reflect-metadata": "^0.1.12 || ^0.2.0",
+ "rxjs": "^7.1.0"
+ },
+ "peerDependenciesMeta": {
+ "class-transformer": {
+ "optional": true
+ },
+ "class-validator": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@nestjs/core": {
+ "version": "11.1.24",
+ "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.24.tgz",
+ "integrity": "sha512-K4bzT+lEdd0Hhcsw3jtk56QAW6s6skK3ViN7hIROSN0kUf4ROwWEAKopJID6yhPQxB45kDtP2wEcjzE8171J3g==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nuxt/opencollective": "0.4.1",
+ "fast-safe-stringify": "2.1.1",
+ "iterare": "1.2.1",
+ "path-to-regexp": "8.4.2",
+ "tslib": "2.8.1",
+ "uid": "2.0.2"
+ },
+ "engines": {
+ "node": ">= 20"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/nest"
+ },
+ "peerDependencies": {
+ "@nestjs/common": "^11.0.0",
+ "@nestjs/microservices": "^11.0.0",
+ "@nestjs/platform-express": "^11.0.0",
+ "@nestjs/websockets": "^11.0.0",
+ "reflect-metadata": "^0.1.12 || ^0.2.0",
+ "rxjs": "^7.1.0"
+ },
+ "peerDependenciesMeta": {
+ "@nestjs/microservices": {
+ "optional": true
+ },
+ "@nestjs/platform-express": {
+ "optional": true
+ },
+ "@nestjs/websockets": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@nestjs/core/node_modules/path-to-regexp": {
+ "version": "8.4.2",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+ "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/@nestjs/platform-express": {
+ "version": "11.1.24",
+ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.24.tgz",
+ "integrity": "sha512-CeMKbRBm05aOBiWhIHWO2xDeHbxynBF9ySQv3gRjObz2N5+uJnYriAYkHvVqvC4JIydmMPmT5VdICFNlNz3qyA==",
+ "license": "MIT",
+ "dependencies": {
+ "cors": "2.8.6",
+ "express": "5.2.1",
+ "multer": "2.1.1",
+ "path-to-regexp": "8.4.2",
+ "tslib": "2.8.1"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/nest"
+ },
+ "peerDependencies": {
+ "@nestjs/common": "^11.0.0",
+ "@nestjs/core": "^11.0.0"
+ }
+ },
+ "node_modules/@nestjs/platform-express/node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/@nestjs/platform-express/node_modules/body-parser": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
+ "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^1.0.5",
+ "debug": "^4.4.3",
+ "http-errors": "^2.0.0",
+ "iconv-lite": "^0.7.0",
+ "on-finished": "^2.4.1",
+ "qs": "^6.14.1",
+ "raw-body": "^3.0.1",
+ "type-is": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/@nestjs/platform-express/node_modules/content-disposition": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
+ "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/@nestjs/platform-express/node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/@nestjs/platform-express/node_modules/express": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.1",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/@nestjs/platform-express/node_modules/finalhandler": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+ "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/@nestjs/platform-express/node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/@nestjs/platform-express/node_modules/iconv-lite": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+ "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/@nestjs/platform-express/node_modules/media-typer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+ "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/@nestjs/platform-express/node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@nestjs/platform-express/node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/@nestjs/platform-express/node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/@nestjs/platform-express/node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/@nestjs/platform-express/node_modules/path-to-regexp": {
+ "version": "8.4.2",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+ "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/@nestjs/platform-express/node_modules/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.1",
+ "mime-types": "^3.0.2",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/@nestjs/platform-express/node_modules/serve-static": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+ "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/@nestjs/platform-express/node_modules/type-is": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
+ "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^2.0.0",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/@nestjs/platform-express/node_modules/type-is/node_modules/content-type": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+ "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/@nestjs/testing": {
+ "version": "11.1.24",
+ "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.24.tgz",
+ "integrity": "sha512-+4M4UAnhtprBQN0J2uI6IP0wDqhy9aH8XCMu5SO8oCi0oB04YXA4a4PAEkxmsPn7gHW4dj1u4GFteNQOWgvTJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/nest"
+ },
+ "peerDependencies": {
+ "@nestjs/common": "^11.0.0",
+ "@nestjs/core": "^11.0.0",
+ "@nestjs/microservices": "^11.0.0",
+ "@nestjs/platform-express": "^11.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@nestjs/microservices": {
+ "optional": true
+ },
+ "@nestjs/platform-express": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@noble/hashes": {
"version": "1.8.0",
"dev": true,
@@ -1455,6 +1904,60 @@
],
"license": "MIT"
},
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nuxt/opencollective": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@nuxt/opencollective/-/opencollective-0.4.1.tgz",
+ "integrity": "sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "consola": "^3.2.3"
+ },
+ "bin": {
+ "opencollective": "bin/opencollective.js"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.10.0",
+ "npm": ">=5.10.0"
+ }
+ },
"node_modules/@otplib/core": {
"version": "12.0.1",
"license": "MIT"
@@ -1509,6 +2012,36 @@
"node": ">=14"
}
},
+ "node_modules/@rollup/pluginutils": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
+ "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "estree-walker": "^2.0.2",
+ "picomatch": "^4.0.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/pluginutils/node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
@@ -1601,9 +2134,6 @@
"arm"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1618,9 +2148,6 @@
"arm"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1635,9 +2162,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1652,9 +2176,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1669,9 +2190,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1686,9 +2204,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1703,9 +2218,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1720,9 +2232,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1737,9 +2246,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1754,9 +2260,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1771,9 +2274,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1786,9 +2286,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1803,9 +2300,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1896,6 +2390,268 @@
"win32"
]
},
+ "node_modules/@swc/core": {
+ "version": "1.15.40",
+ "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.40.tgz",
+ "integrity": "sha512-2kwzJikRvgtNAG7MwVZY2vEzZjTxKIq5jXOihuSV/8U+Hej8Va22t65aKnJZs3P+NwojZvR8Mf8kyM7O+V8sQg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/counter": "^0.1.3",
+ "@swc/types": "^0.1.26"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/swc"
+ },
+ "optionalDependencies": {
+ "@swc/core-darwin-arm64": "1.15.40",
+ "@swc/core-darwin-x64": "1.15.40",
+ "@swc/core-linux-arm-gnueabihf": "1.15.40",
+ "@swc/core-linux-arm64-gnu": "1.15.40",
+ "@swc/core-linux-arm64-musl": "1.15.40",
+ "@swc/core-linux-ppc64-gnu": "1.15.40",
+ "@swc/core-linux-s390x-gnu": "1.15.40",
+ "@swc/core-linux-x64-gnu": "1.15.40",
+ "@swc/core-linux-x64-musl": "1.15.40",
+ "@swc/core-win32-arm64-msvc": "1.15.40",
+ "@swc/core-win32-ia32-msvc": "1.15.40",
+ "@swc/core-win32-x64-msvc": "1.15.40"
+ },
+ "peerDependencies": {
+ "@swc/helpers": ">=0.5.17"
+ },
+ "peerDependenciesMeta": {
+ "@swc/helpers": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@swc/core-darwin-arm64": {
+ "version": "1.15.40",
+ "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.40.tgz",
+ "integrity": "sha512-PaYyclfmQ++77D8ityYvmmVzHv9aG8ROwt2GfG6/ccloy4Hgf80qtOnzb9VYvPsUT7Ty1uhuDRhv3XYpf62qhQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-darwin-x64": {
+ "version": "1.15.40",
+ "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.40.tgz",
+ "integrity": "sha512-HbbPzvfLBUXjIB1Ezks+//lNUjmLjfyd63XSwprJgrZaXYdm70kohXPJUWdqKZozolFxbPaO+xtBaiUp6BoueA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-arm-gnueabihf": {
+ "version": "1.15.40",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.40.tgz",
+ "integrity": "sha512-SlRZsCjOCPR2LvFs0Ri/Xrx/5o5TCt8vl4gW6mX1hEZOG0a625RxzRHpHdAQNGykmAN/7IeaFAJG+QnNmxlHcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-arm64-gnu": {
+ "version": "1.15.40",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.40.tgz",
+ "integrity": "sha512-Q8byxJt2fh8CR3EUX6snBpy47AoBVm+In/+Z3rjDHMjC38ZvR9/gtUUNCT0tfrn4EdVsO8/QPi59nxrxvqxvBQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-arm64-musl": {
+ "version": "1.15.40",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.40.tgz",
+ "integrity": "sha512-4z0MgHU+7M0pZDqBN1El7mFXDI1SBwinfcUkAyA4v8QrhOIUOZltySt2aStQLZGrdXVXM4Y4ylfiTC04ED+MoQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-ppc64-gnu": {
+ "version": "1.15.40",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.40.tgz",
+ "integrity": "sha512-fLI4iUgeSZu0eRWUXwe6YzPFx9gHbFiPkl8Rp3mJfP8OpNR3nTQCGPvHdDh9xniW7mVvgMY4ni7A4VzqI1KrpA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-s390x-gnu": {
+ "version": "1.15.40",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.40.tgz",
+ "integrity": "sha512-YqeKMAb7d4nQSGMJQ454IlaCENpzcDqhvBE9+CPfdnYpnUXxd+BSrB6Xk0YjW8UyoEhUj4p6quATCxbsp6J3jg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-x64-gnu": {
+ "version": "1.15.40",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.40.tgz",
+ "integrity": "sha512-7HOuS1iGcme/j/TuL1TfmmLGiMQrjv/GmjyZeydl00FKPtpGXEldwqfI56xgd1YzrzoB2svWjxbGGyQ0TEASxg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-x64-musl": {
+ "version": "1.15.40",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.40.tgz",
+ "integrity": "sha512-h4kZYHc7dpc9P9u4brRJaS8Pl7tPVHAeiLSzw7T5RfIJgAoSdaCMKzI/2Uay9gFhaw8uyCDl0L5q37r0EpAfIA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-win32-arm64-msvc": {
+ "version": "1.15.40",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.40.tgz",
+ "integrity": "sha512-+mQgKZXSj6mV38Zh05QaxSjUDmGP/R2JWlXZTDLSPkDzHU6p3GxN9eeSf5dfyDVU86946fmCvSzyl/ucImx8+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-win32-ia32-msvc": {
+ "version": "1.15.40",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.40.tgz",
+ "integrity": "sha512-yvwdPLGd25mcj/mNatjNQ0lZujtQD6psH3v9PNmMb+fSzjbNG8KIDxjFWrcV+fsFVLOkyOmdJsFmX7NAFjVyPw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-win32-x64-msvc": {
+ "version": "1.15.40",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.40.tgz",
+ "integrity": "sha512-OXtKsLU1bVtInzzDEAY2sYiF/rl4tvAnLLLpuMp3HzAOQZ5A+i69AKDhA1YLQTaMAqO3vzyYNVAYVRMPtSYD4w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/counter": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
+ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@swc/types": {
+ "version": "0.1.26",
+ "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz",
+ "integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/counter": "^0.1.3"
+ }
+ },
"node_modules/@tokenizer/inflate": {
"version": "0.4.1",
"license": "MIT",
@@ -2316,6 +3072,19 @@
"node": ">= 0.6"
}
},
+ "node_modules/acorn": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/ajv": {
"version": "8.18.0",
"license": "MIT",
@@ -2371,6 +3140,8 @@
},
"node_modules/anymatch": {
"version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -2420,6 +3191,16 @@
"version": "1.1.1",
"license": "MIT"
},
+ "node_modules/array-union": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
+ "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/asap": {
"version": "2.0.6",
"dev": true,
@@ -2590,6 +3371,8 @@
},
"node_modules/binary-extensions": {
"version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2678,6 +3461,8 @@
},
"node_modules/braces": {
"version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2803,29 +3588,6 @@
"node": ">= 16"
}
},
- "node_modules/chokidar": {
- "version": "3.6.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "anymatch": "~3.1.2",
- "braces": "~3.0.2",
- "glob-parent": "~5.1.2",
- "is-binary-path": "~2.1.0",
- "is-glob": "~4.0.1",
- "normalize-path": "~3.0.0",
- "readdirp": "~3.6.0"
- },
- "engines": {
- "node": ">= 8.10.0"
- },
- "funding": {
- "url": "https://paulmillr.com/funding/"
- },
- "optionalDependencies": {
- "fsevents": "~2.3.2"
- }
- },
"node_modules/chownr": {
"version": "1.1.4",
"license": "ISC"
@@ -2898,6 +3660,15 @@
"typedarray": "^0.0.6"
}
},
+ "node_modules/consola": {
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
+ "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
+ "license": "MIT",
+ "engines": {
+ "node": "^14.18.0 || >=16.10.0"
+ }
+ },
"node_modules/content-disposition": {
"version": "0.5.4",
"license": "MIT",
@@ -3087,6 +3858,19 @@
"version": "1.0.3",
"license": "MIT"
},
+ "node_modules/dir-glob": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
+ "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-type": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/dotenv": {
"version": "16.6.1",
"license": "BSD-2-Clause",
@@ -3396,9 +4180,25 @@
"version": "1.3.2",
"license": "MIT"
},
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
"node_modules/fast-safe-stringify": {
"version": "2.1.1",
- "dev": true,
"license": "MIT"
},
"node_modules/fast-uri": {
@@ -3452,6 +4252,16 @@
"fxparser": "src/cli/cli.js"
}
},
+ "node_modules/fastq": {
+ "version": "1.20.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
"node_modules/file-type": {
"version": "21.3.4",
"license": "MIT",
@@ -3474,6 +4284,8 @@
},
"node_modules/fill-range": {
"version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3703,6 +4515,8 @@
},
"node_modules/glob-parent": {
"version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -3712,6 +4526,27 @@
"node": ">= 6"
}
},
+ "node_modules/globby": {
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
+ "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-union": "^2.1.0",
+ "dir-glob": "^3.0.1",
+ "fast-glob": "^3.2.9",
+ "ignore": "^5.2.0",
+ "merge2": "^1.4.1",
+ "slash": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/gopd": {
"version": "1.2.0",
"license": "MIT",
@@ -3835,6 +4670,16 @@
],
"license": "BSD-3-Clause"
},
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
"node_modules/ignore-by-default": {
"version": "1.0.1",
"dev": true,
@@ -3885,6 +4730,8 @@
},
"node_modules/is-binary-path": {
"version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3896,6 +4743,8 @@
},
"node_modules/is-extglob": {
"version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3911,6 +4760,8 @@
},
"node_modules/is-glob": {
"version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3922,6 +4773,8 @@
},
"node_modules/is-number": {
"version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3986,6 +4839,15 @@
"node": ">=8"
}
},
+ "node_modules/iterare": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz",
+ "integrity": "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/jackspeak": {
"version": "3.4.3",
"dev": true,
@@ -4141,6 +5003,35 @@
"safe-buffer": "~5.1.0"
}
},
+ "node_modules/load-esm": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/load-esm/-/load-esm-1.0.3.tgz",
+ "integrity": "sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ },
+ {
+ "type": "buymeacoffee",
+ "url": "https://buymeacoffee.com/borewit"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=13.2.0"
+ }
+ },
+ "node_modules/load-tsconfig": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz",
+ "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ }
+ },
"node_modules/locate-path": {
"version": "5.0.0",
"license": "MIT",
@@ -4246,6 +5137,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/methods": {
"version": "1.1.2",
"license": "MIT",
@@ -4253,6 +5154,20 @@
"node": ">= 0.6"
}
},
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
"node_modules/mime": {
"version": "1.6.0",
"license": "MIT",
@@ -4340,6 +5255,20 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/mylas": {
+ "version": "2.1.14",
+ "resolved": "https://registry.npmjs.org/mylas/-/mylas-2.1.14.tgz",
+ "integrity": "sha512-BzQguy9W9NJgoVn2mRWzbFrFWWztGCcng2QI9+41frfk+Athwgx3qhqhvStz7ExeUUu7Kzw427sNzHpEZNINog==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/raouldeheer"
+ }
+ },
"node_modules/nanoid": {
"version": "3.3.11",
"dev": true,
@@ -4446,6 +5375,31 @@
"node": "18 || 20 || >=22"
}
},
+ "node_modules/nodemon/node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
"node_modules/nodemon/node_modules/has-flag": {
"version": "3.0.0",
"dev": true,
@@ -4468,6 +5422,19 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/nodemon/node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
"node_modules/nodemon/node_modules/supports-color": {
"version": "5.5.0",
"dev": true,
@@ -4641,6 +5608,16 @@
"version": "0.1.13",
"license": "MIT"
},
+ "node_modules/path-type": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
+ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/pathe": {
"version": "2.0.3",
"dev": true,
@@ -4696,6 +5673,19 @@
"node": ">=16.20.0"
}
},
+ "node_modules/plimit-lit": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/plimit-lit/-/plimit-lit-1.6.1.tgz",
+ "integrity": "sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "queue-lit": "^1.5.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/pngjs": {
"version": "5.0.0",
"license": "MIT",
@@ -4798,7 +5788,9 @@
}
},
"node_modules/qs": {
- "version": "6.14.2",
+ "version": "6.15.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
+ "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
@@ -4810,6 +5802,37 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/queue-lit": {
+ "version": "1.5.2",
+ "resolved": "https://registry.npmjs.org/queue-lit/-/queue-lit-1.5.2.tgz",
+ "integrity": "sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/range-parser": {
"version": "1.2.1",
"license": "MIT",
@@ -4876,16 +5899,11 @@
"minimatch": "^5.1.0"
}
},
- "node_modules/readdirp": {
- "version": "3.6.0",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "picomatch": "^2.2.1"
- },
- "engines": {
- "node": ">=8.10.0"
- }
+ "node_modules/reflect-metadata": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
+ "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
+ "license": "Apache-2.0"
},
"node_modules/require-directory": {
"version": "2.1.1",
@@ -4912,6 +5930,17 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/rollup": {
"version": "4.60.1",
"dev": true,
@@ -4977,6 +6006,39 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/rxjs": {
+ "version": "7.8.2",
+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
+ "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.1.0"
+ }
+ },
"node_modules/safe-buffer": {
"version": "5.2.1",
"funding": [
@@ -5226,6 +6288,16 @@
"node": ">=20.12.2"
}
},
+ "node_modules/slash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
+ "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"dev": true,
@@ -5634,6 +6706,8 @@
},
"node_modules/to-regex-range": {
"version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5674,6 +6748,82 @@
"nodetouch": "bin/nodetouch.js"
}
},
+ "node_modules/tsc-alias": {
+ "version": "1.8.17",
+ "resolved": "https://registry.npmjs.org/tsc-alias/-/tsc-alias-1.8.17.tgz",
+ "integrity": "sha512-EIduCZHqbNwPm8BZYfq1aD7BQ697A4h6uSGMOFQfYGoQwfrYFTKwYfy9Bv42YxHkduVBcn9Zx0DkX111DKskyg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chokidar": "^3.5.3",
+ "commander": "^9.0.0",
+ "get-tsconfig": "^4.10.0",
+ "globby": "^11.0.4",
+ "mylas": "^2.1.9",
+ "normalize-path": "^3.0.0",
+ "plimit-lit": "^1.2.6"
+ },
+ "bin": {
+ "tsc-alias": "dist/bin/index.js"
+ },
+ "engines": {
+ "node": ">=16.20.2"
+ }
+ },
+ "node_modules/tsc-alias/node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/tsc-alias/node_modules/commander": {
+ "version": "9.5.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
+ "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || >=14"
+ }
+ },
+ "node_modules/tsc-alias/node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
"node_modules/tsx": {
"version": "4.21.0",
"license": "MIT",
@@ -5732,6 +6882,18 @@
"dev": true,
"license": "CC0-1.0"
},
+ "node_modules/uid": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz",
+ "integrity": "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==",
+ "license": "MIT",
+ "dependencies": {
+ "@lukeed/csprng": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/uint8array-extras": {
"version": "1.5.0",
"license": "MIT",
@@ -5773,6 +6935,37 @@
"node": ">= 0.8"
}
},
+ "node_modules/unplugin": {
+ "version": "2.3.11",
+ "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz",
+ "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "acorn": "^8.15.0",
+ "picomatch": "^4.0.3",
+ "webpack-virtual-modules": "^0.6.2"
+ },
+ "engines": {
+ "node": ">=18.12.0"
+ }
+ },
+ "node_modules/unplugin-swc": {
+ "version": "1.5.9",
+ "resolved": "https://registry.npmjs.org/unplugin-swc/-/unplugin-swc-1.5.9.tgz",
+ "integrity": "sha512-RKwK3yf0M+MN17xZfF14bdKqfx0zMXYdtOdxLiE6jHAoidupKq3jGdJYANyIM1X/VmABhh1WpdO+/f4+Ol89+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rollup/pluginutils": "^5.3.0",
+ "load-tsconfig": "^0.2.5",
+ "unplugin": "^2.3.11"
+ },
+ "peerDependencies": {
+ "@swc/core": "^1.2.108"
+ }
+ },
"node_modules/unzipper": {
"version": "0.12.3",
"license": "MIT",
@@ -6001,6 +7194,13 @@
}
}
},
+ "node_modules/webpack-virtual-modules": {
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
+ "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/which": {
"version": "2.0.2",
"license": "ISC",
@@ -6067,7 +7267,9 @@
"license": "ISC"
},
"node_modules/ws": {
- "version": "8.20.0",
+ "version": "8.21.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
+ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
diff --git a/server/package.json b/server/package.json
index 1d61368c..87ef8302 100644
--- a/server/package.json
+++ b/server/package.json
@@ -1,19 +1,27 @@
{
"name": "trek-server",
- "version": "3.0.18",
+ "version": "3.0.22",
"main": "src/index.ts",
"scripts": {
- "start": "node --import tsx src/index.ts",
- "dev": "tsx watch src/index.ts",
+ "start": "node dist/index.js",
+ "dev": "node scripts/dev.mjs",
+ "build": "node scripts/build.mjs",
+ "start:prod": "node dist/index.js",
+ "typecheck": "tsc -p tsconfig.build.json --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:unit": "vitest run tests/unit",
"test:integration": "vitest run tests/integration",
"test:ws": "vitest run tests/websocket",
+ "test:parity": "vitest run tests/parity",
+ "test:e2e": "vitest run tests/e2e",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.28.0",
+ "@nestjs/common": "^11.1.24",
+ "@nestjs/core": "^11.1.24",
+ "@nestjs/platform-express": "^11.1.24",
"archiver": "^6.0.1",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^12.8.0",
@@ -30,22 +38,30 @@
"nodemailer": "^8.0.5",
"otplib": "^12.0.1",
"qrcode": "^1.5.4",
+ "reflect-metadata": "^0.2.2",
+ "rxjs": "^7.8.2",
"semver": "^7.7.4",
"tsx": "^4.21.0",
"typescript": "^6.0.2",
"undici": "^7.0.0",
"unzipper": "^0.12.3",
"uuid": "^14.0.0",
- "ws": "^8.19.0",
+ "ws": "^8.21.0",
"zod": "^4.3.6"
},
"overrides": {
"hono": "^4.12.16",
"@hono/node-server": "^1.19.13",
"picomatch": "^4.0.4",
- "ip-address": "^10.1.1"
+ "ip-address": "^10.1.1",
+ "multer": "^2.1.1",
+ "ws": "^8.21.0",
+ "qs": "^6.15.2",
+ "file-type": "^21.3.4"
},
"devDependencies": {
+ "@nestjs/testing": "^11.1.24",
+ "@swc/core": "^1.15.40",
"@types/archiver": "^7.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13",
@@ -66,7 +82,9 @@
"@vitest/coverage-v8": "^3.2.4",
"nodemon": "^3.1.0",
"supertest": "^7.2.2",
+ "tsc-alias": "^1.8.17",
"tz-lookup": "^6.1.25",
+ "unplugin-swc": "^1.5.9",
"vitest": "^3.2.4"
}
}
diff --git a/server/scripts/build.mjs b/server/scripts/build.mjs
new file mode 100644
index 00000000..c1e8ce74
--- /dev/null
+++ b/server/scripts/build.mjs
@@ -0,0 +1,14 @@
+import { execSync } from 'node:child_process';
+
+// tsc emits JS even with type errors (noEmitOnError:false), but still exits
+// non-zero to report them. We must run tsc-alias regardless, so run tsc in a
+// try/catch and always proceed to the path-rewrite step.
+// Type correctness is enforced separately via `npm run typecheck`.
+try {
+ execSync('tsc -p tsconfig.build.json', { stdio: 'inherit' });
+} catch {
+ console.warn('[build] tsc reported type errors — emitting anyway (gated by `npm run typecheck`).');
+}
+
+execSync('tsc-alias -p tsconfig.build.json', { stdio: 'inherit' });
+console.log('[build] dist ready (path aliases rewritten).');
diff --git a/server/scripts/dev.mjs b/server/scripts/dev.mjs
new file mode 100644
index 00000000..60946850
--- /dev/null
+++ b/server/scripts/dev.mjs
@@ -0,0 +1,22 @@
+import { execSync, spawn } from 'node:child_process';
+
+// Dev runtime for the co-hosted NestJS + legacy Express server.
+// NestJS DI needs decorator metadata, which the old tsx/esbuild runtime does not
+// emit — so dev runs the tsc build with watchers (same toolchain as prod `dist`).
+// Initial build first so `node --watch dist/index.js` has something to start.
+console.log('[dev] initial build...');
+execSync('node scripts/build.mjs', { stdio: 'inherit' });
+
+const watchers = [
+ ['npx', ['tsc', '-w', '-p', 'tsconfig.build.json', '--preserveWatchOutput']],
+ ['npx', ['tsc-alias', '-w', '-p', 'tsconfig.build.json']],
+ ['node', ['--watch', 'dist/index.js']],
+];
+
+const children = watchers.map(([cmd, args]) =>
+ spawn(cmd, args, { stdio: 'inherit', shell: true }),
+);
+
+const stop = () => { children.forEach((c) => { try { c.kill(); } catch {} }); process.exit(0); };
+process.on('SIGINT', stop);
+process.on('SIGTERM', stop);
diff --git a/server/src/app.ts b/server/src/app.ts
index c03c7583..06202a6a 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -5,6 +5,7 @@ import cookieParser from 'cookie-parser';
import path from 'node:path';
import fs from 'node:fs';
+import multer from 'multer';
import { logDebug, logWarn, logError } from './services/auditLog';
import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy';
import { authenticate, verifyJwtAndLoadUser } from './middleware/auth';
@@ -25,7 +26,6 @@ import airportsRoutes from './routes/airports';
import filesRoutes from './routes/files';
import reservationsRoutes from './routes/reservations';
import dayNotesRoutes from './routes/dayNotes';
-import weatherRoutes from './routes/weather';
import settingsRoutes from './routes/settings';
import budgetRoutes from './routes/budget';
import collabRoutes from './routes/collab';
@@ -122,7 +122,7 @@ export function createApp(): express.Application {
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
- scriptSrc: ["'self'", "'wasm-unsafe-eval'"],
+ scriptSrc: ["'self'", "'wasm-unsafe-eval'", "'unsafe-eval'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"],
imgSrc: ["'self'", "data:", "blob:", "https:"],
connectSrc: [
@@ -360,7 +360,8 @@ export function createApp(): express.Application {
app.use('/api/photos', photoRoutes);
app.use('/api/maps', mapsRoutes);
app.use('/api/airports', airportsRoutes);
- app.use('/api/weather', weatherRoutes);
+ // /api/weather is served by the NestJS weather module (see src/nest/weather);
+ // the legacy Express route was decommissioned after the migration (L1).
app.use('/api/settings', settingsRoutes);
app.use('/api/system-notices', systemNoticesRoutes);
app.use('/api/backup', backupRoutes);
@@ -396,7 +397,7 @@ export function createApp(): express.Application {
revocation_endpoint: `${base}/oauth/revoke`,
registration_endpoint: `${base}/oauth/register`,
response_types_supported: ['code'],
- grant_types_supported: ['authorization_code', 'refresh_token'],
+ grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
scopes_supported: ALL_SCOPES,
@@ -507,6 +508,10 @@ export function createApp(): express.Application {
} else {
console.error('Unhandled error:', err);
}
+ if (err instanceof multer.MulterError) {
+ const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
+ return res.status(status).json({ error: err.message });
+ }
const status = err.statusCode || err.status || 500;
// Expose the message for client errors (4xx); keep 'Internal server error' for 5xx.
const message = status < 500 ? err.message : 'Internal server error';
diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts
index bdd14379..a05cd371 100644
--- a/server/src/db/migrations.ts
+++ b/server/src/db/migrations.ts
@@ -2229,6 +2229,42 @@ function runMigrations(db: Database.Database): void {
db.exec(`ALTER TABLE schema_version_new RENAME TO schema_version`)
db.exec(`UPDATE app_settings SET value = '${process.env.APP_VERSION || '3.0.15'}' WHERE key = 'app_version'`);
},
+ // Migration: OAuth 2.0 client_credentials grant — allow user-owned confidential
+ // clients to skip the browser consent flow entirely and obtain tokens directly
+ // via client_id + client_secret. Flag is immutable after creation so existing
+ // authorization-code clients are not silently upgraded.
+ () => {
+ try { db.exec('ALTER TABLE oauth_clients ADD COLUMN allows_client_credentials INTEGER NOT NULL DEFAULT 0'); }
+ catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
+ },
+ // Drop stale atlas cache rows for territories that used to resolve to their
+ // surrounding country (Hong Kong/Macau as China, San Marino/Vatican as Italy,
+ // etc.) before their own bounding boxes existed. The next atlas stats request
+ // re-resolves any place inside these boxes with the corrected country code.
+ () => {
+ const enclaveBoxes: [number, number, number, number][] = [
+ [113.83, 22.15, 114.43, 22.56], // HK
+ [113.53, 22.10, 113.60, 22.21], // MO
+ [12.40, 43.89, 12.52, 43.99], // SM
+ [12.44, 41.90, 12.46, 41.91], // VA
+ [7.40, 43.72, 7.44, 43.75], // MC
+ [9.47, 47.05, 9.64, 47.27], // LI
+ [-5.36, 36.11, -5.33, 36.16], // GI
+ [-67.30, 17.88, -65.22, 18.53], // PR
+ ];
+ try {
+ const del = db.prepare(
+ `DELETE FROM place_regions WHERE place_id IN (
+ SELECT id FROM places WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?
+ )`
+ );
+ for (const [minLng, minLat, maxLng, maxLat] of enclaveBoxes) {
+ del.run(minLat, maxLat, minLng, maxLng);
+ }
+ } catch (err: any) {
+ if (!err.message?.includes('no such table')) throw err;
+ }
+ },
];
if (currentVersion < migrations.length) {
diff --git a/server/src/index.ts b/server/src/index.ts
index 92a280cb..b90cbcd0 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -1,7 +1,16 @@
+import 'reflect-metadata';
import 'dotenv/config';
import path from 'node:path';
import fs from 'node:fs';
+import http from 'node:http';
+import express from 'express';
+import cookieParser from 'cookie-parser';
+import { NestFactory } from '@nestjs/core';
+import { ExpressAdapter } from '@nestjs/platform-express';
+import type { INestApplication } from '@nestjs/common';
import { createApp } from './app';
+import { AppModule } from './nest/app.module';
+import { getNestPrefixes, makeNestPathMatcher } from './nest/strangler';
// Create upload and data directories on startup
const uploadsDir = path.join(__dirname, '../uploads');
@@ -16,7 +25,10 @@ const tmpDir = path.join(__dirname, '../data/tmp');
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
});
-const app = createApp();
+// Legacy Express app — unchanged. NestJS (its own Express 5 instance) is mounted
+// in front of it (strangler pattern): migrated route prefixes are served by Nest,
+// everything else falls through to this app via a fallback middleware.
+const legacyApp = createApp();
import * as scheduler from './scheduler';
import { getAppUrl, getMcpSafeUrl } from './services/notifications';
@@ -49,6 +61,11 @@ const onListen = () => {
'──────────────────────────────────────',
];
banner.forEach(l => console.log(l));
+ sLogInfo(
+ NEST_PREFIXES.length
+ ? `NestJS handling prefixes: ${NEST_PREFIXES.join(', ')} (override via NEST_PREFIXES)`
+ : 'NestJS prefixes: none — all routes served by the legacy Express app',
+ );
if (process.env.APP_URL) {
let parsedAppUrl: URL | null = null;
try { parsedAppUrl = new URL(process.env.APP_URL); } catch { /* invalid */ }
@@ -84,9 +101,42 @@ const onListen = () => {
});
};
-const server = HOST
- ? app.listen(PORT, HOST, onListen)
- : app.listen(PORT, onListen);
+let server: http.Server;
+let nestApp: INestApplication;
+
+// Strangler toggle: prefixes served by Nest (env-overridable, instant rollback).
+const NEST_PREFIXES = getNestPrefixes();
+const isNestPath = makeNestPathMatcher(NEST_PREFIXES);
+
+async function bootstrap(): Promise {
+ // Nest runs on its own Express instance (bodyParser off so request bodies reach
+ // the legacy app untouched — it has its own parsers; /mcp relies on raw body).
+ // Nest body parsing is safe here: the dispatcher only forwards migrated
+ // prefixes to this instance, so the legacy app (and raw-body routes like /mcp)
+ // is reached separately and never passes through Nest's parser.
+ nestApp = await NestFactory.create(AppModule, new ExpressAdapter());
+ // cookie-parser so the auth guard can read the existing `trek_session` cookie.
+ nestApp.use(cookieParser());
+ // (TrekExceptionFilter is registered globally via APP_FILTER in AppModule.)
+ await nestApp.init();
+ const nestInstance = nestApp.getHttpAdapter().getInstance();
+
+ // Top-level dispatcher: migrated prefixes -> Nest, everything else -> legacy
+ // Express (unchanged). Nest never sees non-migrated paths, so its 404 handler
+ // only applies within migrated prefixes.
+ const top = express();
+ top.use((req, res, next) => (isNestPath(req.path) ? nestInstance(req, res, next) : next()));
+ top.use(legacyApp);
+
+ server = http.createServer(top);
+ if (HOST) server.listen(PORT, HOST, onListen);
+ else server.listen(PORT, onListen);
+}
+
+bootstrap().catch((err) => {
+ console.error('Fatal: failed to bootstrap server', err);
+ process.exit(1);
+});
// Graceful shutdown
function shutdown(signal: string): void {
@@ -95,6 +145,7 @@ function shutdown(signal: string): void {
sLogInfo(`${signal} received — shutting down gracefully...`);
scheduler.stop();
closeMcpSessions();
+ void nestApp?.close();
server.close(() => {
sLogInfo('HTTP server closed');
const { closeDb } = require('./db/database');
@@ -111,4 +162,4 @@ function shutdown(signal: string): void {
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
-export default app;
+export default legacyApp;
diff --git a/server/src/mcp/oauthProvider.ts b/server/src/mcp/oauthProvider.ts
index 943a515f..592fd282 100644
--- a/server/src/mcp/oauthProvider.ts
+++ b/server/src/mcp/oauthProvider.ts
@@ -147,7 +147,8 @@ export const trekOAuthProvider: OAuthServerProvider = {
if (params.state) qs.set('state', params.state);
if (params.resource) qs.set('resource', params.resource.href);
- res.redirect(302, `/oauth/consent?${qs.toString()}`);
+ const base = getMcpSafeUrl().replace(/\/+$/, '');
+ res.redirect(302, `${base}/oauth/consent?${qs.toString()}`);
},
// Not called because skipLocalPkceValidation = true.
diff --git a/server/src/mcp/tools/days.ts b/server/src/mcp/tools/days.ts
index e6b22a05..83ff881c 100644
--- a/server/src/mcp/tools/days.ts
+++ b/server/src/mcp/tools/days.ts
@@ -116,7 +116,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
server.registerTool(
'create_place_accommodation',
{
- description: 'Create a new place and immediately set it as an accommodation for a date range in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use create_accommodation directly.',
+ description: 'Create a new place and immediately set it as an accommodation for a date range in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use create_accommodation directly. Set price + currency to record the accommodation cost so it shows on the item.',
inputSchema: {
tripId: z.number().int().positive(),
name: z.string().min(1).max(200),
@@ -136,17 +136,19 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'),
confirmation: z.string().max(100).optional(),
accommodation_notes: z.string().max(1000).optional().describe('Notes for the accommodation'),
+ price: z.number().nonnegative().optional().describe('Total accommodation cost (shown on the item)'),
+ currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
- async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_out, confirmation, accommodation_notes }) => {
+ async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_out, confirmation, accommodation_notes, price, currency }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const dayErrors = validateAccommodationRefs(tripId, undefined, start_day_id, end_day_id);
if (dayErrors.length > 0) return { content: [{ type: 'text' as const, text: dayErrors.map(e => e.message).join(', ') }], isError: true };
try {
const run = db.transaction(() => {
- const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone });
+ const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone, price, currency });
const accommodation = createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_out, confirmation, notes: accommodation_notes });
return { place, accommodation };
});
diff --git a/server/src/mcp/tools/places.ts b/server/src/mcp/tools/places.ts
index 451edaf2..a5bdcfe4 100644
--- a/server/src/mcp/tools/places.ts
+++ b/server/src/mcp/tools/places.ts
@@ -23,7 +23,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
if (W) server.registerTool(
'create_place',
{
- description: 'Add a new place/POI to a trip. Set google_place_id or osm_id (from search_place) so the app can show opening hours and ratings.',
+ description: 'Add a new place/POI to a trip. Set google_place_id or osm_id (from search_place) so the app can show opening hours and ratings. Set price + currency to record the cost so it shows on the item.',
inputSchema: {
tripId: z.number().int().positive(),
name: z.string().min(1).max(200),
@@ -37,13 +37,15 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
notes: z.string().max(2000).optional(),
website: z.string().max(500).optional(),
phone: z.string().max(50).optional(),
+ price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
+ currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
- async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone }) => {
+ async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, price, currency }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
- const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone });
+ const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, price, currency });
safeBroadcast(tripId, 'place:created', { place });
return ok({ place });
}
@@ -52,7 +54,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
if (W) server.registerTool(
'create_and_assign_place',
{
- description: 'Create a new place and immediately assign it to a day in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use assign_place_to_day directly.',
+ description: 'Create a new place and immediately assign it to a day in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use assign_place_to_day directly. Set price + currency to record the cost so it shows on the item.',
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive().describe('Day to assign the place to'),
@@ -68,16 +70,18 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
website: z.string().max(500).optional(),
phone: z.string().max(50).optional(),
assignment_notes: z.string().max(500).optional().describe('Notes for this day assignment'),
+ price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
+ currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
- async ({ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, assignment_notes }) => {
+ async ({ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, assignment_notes, price, currency }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
try {
const run = db.transaction(() => {
- const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone });
+ const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone, price, currency });
const assignment = createAssignment(dayId, place.id, assignment_notes ?? null);
return { place, assignment };
});
diff --git a/server/src/mcp/tools/reservations.ts b/server/src/mcp/tools/reservations.ts
index 1a2acef5..9c8825fb 100644
--- a/server/src/mcp/tools/reservations.ts
+++ b/server/src/mcp/tools/reservations.ts
@@ -6,6 +6,7 @@ import {
createReservation, getReservation, updateReservation, deleteReservation,
updatePositions as updateReservationPositions,
} from '../../services/reservationService';
+import { linkBudgetItemToReservation } from '../../services/budgetService';
import { getDay } from '../../services/dayService';
import { placeExists, getAssignmentForTrip } from '../../services/assignmentService';
import {
@@ -22,7 +23,7 @@ export function registerReservationTools(server: McpServer, userId: number, scop
server.registerTool(
'create_reservation',
{
- description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. For flights, trains, cars, and cruises, use create_transport instead. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/event/tour/activity/other → use assignment_id.',
+ description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. For flights, trains, cars, and cruises, use create_transport instead. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/event/tour/activity/other → use assignment_id. Set price to record the cost; it will appear on the booking and in the Budget tab.',
inputSchema: {
tripId: z.number().int().positive(),
title: z.string().min(1).max(200),
@@ -38,10 +39,12 @@ export function registerReservationTools(server: McpServer, userId: number, scop
check_in: z.string().max(10).optional().describe('Check-in time (e.g. "15:00", hotel type only)'),
check_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00", hotel type only)'),
assignment_id: z.number().int().positive().optional().describe('Link to a day assignment (restaurant, train, car, cruise, event, tour, activity, other)'),
+ price: z.number().nonnegative().optional().describe('Reservation cost — shown on the booking and linked in the Budget tab'),
+ budget_category: z.string().max(100).optional().describe('Budget category for the price entry (defaults to reservation type)'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
- async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, check_in, check_out, assignment_id }) => {
+ async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, check_in, check_out, assignment_id, price, budget_category }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
@@ -61,15 +64,28 @@ export function registerReservationTools(server: McpServer, userId: number, scop
? { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined, confirmation: confirmation_number || undefined }
: undefined;
+ const metadata = price != null ? { price: String(price) } : undefined;
+
const { reservation, accommodationCreated } = createReservation(tripId, {
title, type, reservation_time, location, confirmation_number,
notes, day_id, place_id, assignment_id,
create_accommodation: createAccommodation,
+ metadata,
});
if (accommodationCreated) {
safeBroadcast(tripId, 'accommodation:created', {});
}
+
+ if (price != null && price > 0) {
+ const item = linkBudgetItemToReservation(tripId, reservation.id, {
+ name: title,
+ category: budget_category || type,
+ total_price: price,
+ });
+ safeBroadcast(tripId, 'budget:created', { item });
+ }
+
safeBroadcast(tripId, 'reservation:created', { reservation });
return ok({ reservation });
}
diff --git a/server/src/mcp/tools/transports.ts b/server/src/mcp/tools/transports.ts
index 535ab7bc..d2cf022e 100644
--- a/server/src/mcp/tools/transports.ts
+++ b/server/src/mcp/tools/transports.ts
@@ -5,6 +5,7 @@ import { isDemoUser } from '../../services/authService';
import {
createReservation, deleteReservation, getReservation, updateReservation,
} from '../../services/reservationService';
+import { linkBudgetItemToReservation } from '../../services/budgetService';
import { getDay } from '../../services/dayService';
import {
safeBroadcast, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
@@ -32,7 +33,7 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
server.registerTool(
'create_transport',
{
- description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). Created as pending — confirm with update_transport.',
+ description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). Created as pending — confirm with update_transport. Set price to record the cost; it will appear on the booking and in the Budget tab.',
inputSchema: {
tripId: z.number().int().positive(),
type: z.enum(['flight', 'train', 'car', 'cruise']),
@@ -47,10 +48,12 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
metadata: z.record(z.string(), z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'),
endpoints: endpointSchema,
needs_review: z.boolean().optional(),
+ price: z.number().nonnegative().optional().describe('Transport cost — shown on the booking and linked in the Budget tab'),
+ budget_category: z.string().max(100).optional().describe('Budget category for the price entry (defaults to transport type)'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
- async ({ tripId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review }) => {
+ async ({ tripId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review, price, budget_category }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
@@ -59,6 +62,9 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
if (end_day_id && !getDay(end_day_id, tripId))
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
+ const meta: Record = { ...(metadata ?? {}) };
+ if (price != null) meta.price = String(price);
+
const { reservation } = createReservation(tripId, {
title,
type,
@@ -70,10 +76,20 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
day_id: start_day_id,
end_day_id: end_day_id ?? start_day_id,
status: status ?? 'pending',
- metadata,
+ metadata: Object.keys(meta).length > 0 ? meta : undefined,
endpoints,
needs_review,
});
+
+ if (price != null && price > 0) {
+ const item = linkBudgetItemToReservation(tripId, reservation.id, {
+ name: title,
+ category: budget_category || type,
+ total_price: price,
+ });
+ safeBroadcast(tripId, 'budget:created', { item });
+ }
+
safeBroadcast(tripId, 'reservation:created', { reservation });
return ok({ reservation });
}
diff --git a/server/src/nest/README.md b/server/src/nest/README.md
new file mode 100644
index 00000000..9c86ee84
--- /dev/null
+++ b/server/src/nest/README.md
@@ -0,0 +1,58 @@
+# NestJS migration layer — module & test guide
+
+This folder holds the co-hosted NestJS app that incrementally strangles the legacy
+Express API (see the "Brownfield Rewrite" board). Until a prefix is migrated, the
+top-level dispatcher in `src/index.ts` routes it to the legacy app; migrated
+prefixes go to Nest. **Weather (`weather/`) is the reference implementation** — copy
+its shape when migrating a new domain.
+
+## Module layout (per domain)
+
+```
+shared/src//.schema.ts(.spec.ts) # Zod contract — single source of truth
+server/src/nest//.service.ts # business logic (ported 1:1 from the Express service)
+server/src/nest//.controller.ts # same routes/verbs/params/status codes as Express
+server/src/nest//.module.ts # registered in app.module.ts
+```
+
+Add the prefix to `DEFAULT_NEST_PREFIXES` in `strangler.ts` to route it to Nest
+(operators can override at runtime via the `NEST_PREFIXES` env var — instant
+rollback, no redeploy).
+
+## Parity is law
+
+A migrated route must be **byte-identical** for the client: same URL, method,
+query/body, HTTP status, `Set-Cookie`, and JSON body — including bespoke error
+strings. Where the legacy route returns a hand-written error (e.g. weather's
+`{ error: 'Latitude and longitude are required' }`), reproduce that exact body in
+the controller rather than relying on the generic `ZodValidationPipe` envelope.
+
+## How to write the tests
+
+Every module ships three kinds of tests; the coverage gate (`vitest.config.ts`,
+scoped to `src/nest/**`) requires ≥80%.
+
+1. **Service / controller unit spec** — `tests/unit/nest/.controller.test.ts`.
+ Instantiate the controller with a mocked service; assert status codes, the exact
+ `{ error }` bodies, and that inputs are forwarded correctly (defaults, coercion).
+ See `weather.controller.test.ts`.
+
+2. **Parity test** — `tests/parity/.parity.test.ts`. Mock the shared service
+ identically for both apps, then fire the same request at the Express route and the
+ Nest controller with the `expectParity()` harness (`tests/parity/parity.ts`) and
+ assert identical status + body. This is the gate before flipping the toggle.
+ See `weather.parity.test.ts`.
+
+3. **e2e** — `tests/e2e/.e2e.test.ts`. Boot the Nest module against a temp
+ in-memory SQLite db via the shared harness (`tests/e2e/harness.ts`:
+ `createTempDb`/`seedUser`/`sessionCookie`), exercising the **real** `JwtAuthGuard`
+ end-to-end (401 without cookie, 200 with a signed session). Mock external I/O
+ (HTTP/etc.). See `weather.e2e.test.ts`.
+
+## Definition of Done (per module)
+
+Contract in `@trek/shared` → service ported 1:1 → controller with identical routes →
+validation/error parity → unit + parity + e2e tests over the gate → prefix toggled to
+Nest → parity verified on the demo DB → **then** decommission the old Express
+route/service (separate step, after the toggle is confirmed in prod) → frontend points
+at the typed contract (Frontend Track).
diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts
new file mode 100644
index 00000000..30e87991
--- /dev/null
+++ b/server/src/nest/app.module.ts
@@ -0,0 +1,23 @@
+import { Module } from '@nestjs/common';
+import { APP_FILTER } from '@nestjs/core';
+import { DatabaseModule } from './database/database.module';
+import { HealthController } from './health/health.controller';
+import { HealthService } from './health/health.service';
+import { WeatherModule } from './weather/weather.module';
+import { TrekExceptionFilter } from './common/trek-exception.filter';
+
+/**
+ * Root NestJS module for the incremental migration. Domain modules
+ * (weather, notifications, ...) get registered here as they are migrated.
+ */
+@Module({
+ imports: [DatabaseModule, WeatherModule],
+ controllers: [HealthController],
+ providers: [
+ HealthService,
+ // Global error-envelope normaliser (DI-registered so it also catches
+ // framework-level exceptions like the not-found handler).
+ { provide: APP_FILTER, useClass: TrekExceptionFilter },
+ ],
+})
+export class AppModule {}
diff --git a/server/src/nest/auth/admin.guard.ts b/server/src/nest/auth/admin.guard.ts
new file mode 100644
index 00000000..1b31a703
--- /dev/null
+++ b/server/src/nest/auth/admin.guard.ts
@@ -0,0 +1,19 @@
+import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
+import type { Request } from 'express';
+import type { User } from '../../types';
+
+/**
+ * Mirrors the legacy `adminOnly` middleware: requires an authenticated admin.
+ * Use together with JwtAuthGuard (which populates req.user):
+ * `@UseGuards(JwtAuthGuard, AdminGuard)`.
+ */
+@Injectable()
+export class AdminGuard implements CanActivate {
+ canActivate(context: ExecutionContext): boolean {
+ const req = context.switchToHttp().getRequest();
+ if (!req.user || req.user.role !== 'admin') {
+ throw new HttpException({ error: 'Admin access required' }, 403);
+ }
+ return true;
+ }
+}
diff --git a/server/src/nest/auth/current-user.decorator.ts b/server/src/nest/auth/current-user.decorator.ts
new file mode 100644
index 00000000..c4734891
--- /dev/null
+++ b/server/src/nest/auth/current-user.decorator.ts
@@ -0,0 +1,12 @@
+import { createParamDecorator, ExecutionContext } from '@nestjs/common';
+import type { User } from '../../types';
+
+/**
+ * Resolves the authenticated user attached by JwtAuthGuard.
+ * Use on guarded handlers: `getThing(@CurrentUser() user: User) { ... }`.
+ */
+export const CurrentUser = createParamDecorator(
+ (_data: unknown, context: ExecutionContext): User | undefined => {
+ return context.switchToHttp().getRequest().user;
+ },
+);
diff --git a/server/src/nest/auth/jwt-auth.guard.ts b/server/src/nest/auth/jwt-auth.guard.ts
new file mode 100644
index 00000000..120c80e2
--- /dev/null
+++ b/server/src/nest/auth/jwt-auth.guard.ts
@@ -0,0 +1,28 @@
+import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
+import type { Request } from 'express';
+import { extractToken, verifyJwtAndLoadUser } from '../../middleware/auth';
+
+/**
+ * Validates TREK's existing JWT session — the same httpOnly `trek_session`
+ * cookie (or `Authorization: Bearer`) the legacy app uses. Reuses the canonical
+ * `verifyJwtAndLoadUser` so the secret, the password_version invalidation gate
+ * and the loaded user are IDENTICAL to the Express middleware. No new tokens.
+ *
+ * Error bodies match the legacy 401 shape exactly so the client is unaffected.
+ */
+@Injectable()
+export class JwtAuthGuard implements CanActivate {
+ canActivate(context: ExecutionContext): boolean {
+ const req = context.switchToHttp().getRequest();
+ const token = extractToken(req);
+ if (!token) {
+ throw new HttpException({ error: 'Access token required', code: 'AUTH_REQUIRED' }, 401);
+ }
+ const user = verifyJwtAndLoadUser(token);
+ if (!user) {
+ throw new HttpException({ error: 'Invalid or expired token', code: 'AUTH_REQUIRED' }, 401);
+ }
+ (req as Request & { user?: unknown }).user = user;
+ return true;
+ }
+}
diff --git a/server/src/nest/common/trek-exception.filter.ts b/server/src/nest/common/trek-exception.filter.ts
new file mode 100644
index 00000000..193ddf79
--- /dev/null
+++ b/server/src/nest/common/trek-exception.filter.ts
@@ -0,0 +1,42 @@
+import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
+import type { Response } from 'express';
+
+/**
+ * Normalises every Nest exception to TREK's legacy error envelope so migrated
+ * routes are byte-identical for the client:
+ * - 4xx -> { error: } (5xx -> { error: 'Internal server error' })
+ * - exceptions already throwing { error, code? } (e.g. the auth guards) pass through
+ * This replaces Nest's default { statusCode, message, error } body, which the
+ * TREK client does not expect.
+ */
+@Catch()
+export class TrekExceptionFilter implements ExceptionFilter {
+ catch(exception: unknown, host: ArgumentsHost): void {
+ const res = host.switchToHttp().getResponse();
+
+ if (exception instanceof HttpException) {
+ const status = exception.getStatus();
+ const body = exception.getResponse();
+
+ // Already in TREK shape (e.g. guards throw { error, code }): pass through.
+ if (body && typeof body === 'object' && 'error' in (body as Record)) {
+ res.status(status).json(body);
+ return;
+ }
+
+ const raw = typeof body === 'string' ? body : (body as { message?: unknown })?.message;
+ const message =
+ status < 500
+ ? Array.isArray(raw)
+ ? raw.join(', ')
+ : String(raw ?? 'Error')
+ : 'Internal server error';
+ res.status(status).json({ error: message });
+ return;
+ }
+
+ // Unknown/unhandled error — mirror the legacy 500 behaviour.
+ console.error('Unhandled error:', exception);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+}
diff --git a/server/src/nest/common/zod-validation.pipe.ts b/server/src/nest/common/zod-validation.pipe.ts
new file mode 100644
index 00000000..3a855b59
--- /dev/null
+++ b/server/src/nest/common/zod-validation.pipe.ts
@@ -0,0 +1,26 @@
+import { ArgumentMetadata, HttpException, Injectable, PipeTransform } from '@nestjs/common';
+import type { ZodType } from 'zod';
+
+/**
+ * Validates an incoming @Body()/@Query() against a Zod schema (from @trek/shared)
+ * and returns the parsed, typed value. On failure it throws TREK's error envelope
+ * `{ error: string }` with status 400 — the same shape the legacy routes produce,
+ * so the client's error handling is unaffected.
+ *
+ * Usage: `@Body(new ZodValidationPipe(someSchema)) dto: Dto`.
+ */
+@Injectable()
+export class ZodValidationPipe implements PipeTransform {
+ constructor(private readonly schema: ZodType) {}
+
+ transform(value: unknown, _metadata: ArgumentMetadata): unknown {
+ const result = this.schema.safeParse(value);
+ if (!result.success) {
+ const message = result.error.issues
+ .map((i) => `${i.path.join('.') || 'body'}: ${i.message}`)
+ .join('; ');
+ throw new HttpException({ error: message }, 400);
+ }
+ return result.data;
+ }
+}
diff --git a/server/src/nest/database/database.module.ts b/server/src/nest/database/database.module.ts
new file mode 100644
index 00000000..e805238b
--- /dev/null
+++ b/server/src/nest/database/database.module.ts
@@ -0,0 +1,13 @@
+import { Global, Module } from '@nestjs/common';
+import { DatabaseService } from './database.service';
+
+/**
+ * Global so every migrated module can inject DatabaseService without re-importing.
+ * Wraps the existing better-sqlite3 singleton (no new connection).
+ */
+@Global()
+@Module({
+ providers: [DatabaseService],
+ exports: [DatabaseService],
+})
+export class DatabaseModule {}
diff --git a/server/src/nest/database/database.service.ts b/server/src/nest/database/database.service.ts
new file mode 100644
index 00000000..b43731b8
--- /dev/null
+++ b/server/src/nest/database/database.service.ts
@@ -0,0 +1,39 @@
+import { Injectable } from '@nestjs/common';
+import type Database from 'better-sqlite3';
+import { db } from '../../db/database';
+
+/**
+ * Injectable wrapper around TREK's existing better-sqlite3 connection.
+ *
+ * `db` is a Proxy onto the singleton connection the legacy app already uses
+ * (WAL enabled), so Nest modules share the exact same connection — no second
+ * connection, no split state, single writer preserved.
+ */
+@Injectable()
+export class DatabaseService {
+ /** The shared better-sqlite3 connection (same singleton the legacy app uses). */
+ get connection(): Database.Database {
+ return db;
+ }
+
+ prepare(sql: string): Database.Statement {
+ return db.prepare(sql);
+ }
+
+ get(sql: string, ...params: unknown[]): T | undefined {
+ return db.prepare(sql).get(...params) as T | undefined;
+ }
+
+ all(sql: string, ...params: unknown[]): T[] {
+ return db.prepare(sql).all(...params) as T[];
+ }
+
+ run(sql: string, ...params: unknown[]): Database.RunResult {
+ return db.prepare(sql).run(...params);
+ }
+
+ /** Run `fn` inside a synchronous better-sqlite3 transaction. */
+ transaction(fn: (conn: Database.Database) => T): T {
+ return db.transaction(() => fn(db))();
+ }
+}
diff --git a/server/src/nest/health/health.controller.ts b/server/src/nest/health/health.controller.ts
new file mode 100644
index 00000000..e4c907d3
--- /dev/null
+++ b/server/src/nest/health/health.controller.ts
@@ -0,0 +1,41 @@
+import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
+import { z } from 'zod';
+import type { User } from '../../types';
+import { HealthService } from './health.service';
+import { JwtAuthGuard } from '../auth/jwt-auth.guard';
+import { CurrentUser } from '../auth/current-user.decorator';
+import { ZodValidationPipe } from '../common/zod-validation.pipe';
+
+// Local demo schema (real domains import their schema from @trek/shared).
+const echoSchema = z.object({ name: z.string().min(1) });
+
+/**
+ * Foundation smoke endpoints for the co-hosted NestJS app.
+ * Proves: boot, routing, type-based DI, the shared SQLite connection, the
+ * JWT-cookie auth guard, and the Zod validation pipe + error-envelope parity.
+ *
+ * Lives under /api/_nest/* so it never collides with the legacy Express API.
+ */
+@Controller('api/_nest')
+export class HealthController {
+ constructor(private readonly healthService: HealthService) {}
+
+ @Get('health')
+ getHealth() {
+ return { ok: true, ...this.healthService.info() };
+ }
+
+ /** Guarded: returns the authenticated user, proving JwtAuthGuard + @CurrentUser. */
+ @Get('me')
+ @UseGuards(JwtAuthGuard)
+ me(@CurrentUser() user: User) {
+ return user;
+ }
+
+ /** Validated: proves the Zod pipe (400 + { error } on failure) and body parsing. */
+ @Post('echo')
+ @UseGuards(JwtAuthGuard)
+ echo(@Body(new ZodValidationPipe(echoSchema)) body: z.infer) {
+ return { youSent: body };
+ }
+}
diff --git a/server/src/nest/health/health.service.ts b/server/src/nest/health/health.service.ts
new file mode 100644
index 00000000..4e5b6335
--- /dev/null
+++ b/server/src/nest/health/health.service.ts
@@ -0,0 +1,21 @@
+import { Injectable } from '@nestjs/common';
+import { DatabaseService } from '../database/database.service';
+
+/**
+ * Smoke service proving NestJS DI works under the chosen runtime AND that the
+ * injected DatabaseService talks to TREK's existing SQLite connection.
+ */
+@Injectable()
+export class HealthService {
+ constructor(private readonly database: DatabaseService) {}
+
+ info() {
+ const row = this.database.get<{ n: number }>('SELECT COUNT(*) AS n FROM users');
+ return {
+ runtime: 'nestjs',
+ diInjected: true,
+ // Proof the shared connection works: real row count from the existing DB.
+ userCount: row?.n ?? null,
+ };
+ }
+}
diff --git a/server/src/nest/strangler.ts b/server/src/nest/strangler.ts
new file mode 100644
index 00000000..038001ae
--- /dev/null
+++ b/server/src/nest/strangler.ts
@@ -0,0 +1,24 @@
+/**
+ * Strangler toggle for the incremental NestJS migration.
+ *
+ * `getNestPrefixes()` returns the request path prefixes that NestJS handles;
+ * every other path falls through to the legacy Express app. The default is the
+ * set of prefixes whose Nest modules exist. Operators can override it at runtime
+ * via the `NEST_PREFIXES` env var (comma-separated) for instant Nest<->Express
+ * rollback — no redeploy, no code change. Setting `NEST_PREFIXES=` (empty) routes
+ * everything back to the legacy app.
+ */
+const DEFAULT_NEST_PREFIXES = ['/api/_nest', '/api/weather'];
+
+export function getNestPrefixes(): string[] {
+ const raw = process.env.NEST_PREFIXES;
+ if (raw !== undefined) {
+ return raw.split(',').map((s) => s.trim()).filter(Boolean);
+ }
+ return DEFAULT_NEST_PREFIXES;
+}
+
+/** Builds a matcher: true when `path` belongs to one of the migrated prefixes. */
+export function makeNestPathMatcher(prefixes: string[]): (path: string) => boolean {
+ return (path) => prefixes.some((prefix) => path === prefix || path.startsWith(prefix + '/'));
+}
diff --git a/server/src/nest/weather/weather.controller.ts b/server/src/nest/weather/weather.controller.ts
new file mode 100644
index 00000000..44760625
--- /dev/null
+++ b/server/src/nest/weather/weather.controller.ts
@@ -0,0 +1,66 @@
+import { Controller, Get, HttpException, Query, UseGuards } from '@nestjs/common';
+import type { WeatherResult } from '@trek/shared';
+import { WeatherService } from './weather.service';
+import { JwtAuthGuard } from '../auth/jwt-auth.guard';
+import { ApiError } from '../../services/weatherService';
+
+/**
+ * /api/weather — first migrated leaf module (the pilot).
+ *
+ * Behaviour is byte-identical to the legacy Express route (server/src/routes/
+ * weather.ts): same paths, query params, status codes and `{ error }` bodies.
+ *
+ * Parity note: the "X is required" 400s and the 500 fallback messages are bespoke
+ * strings, not the generic Zod-pipe envelope, so they are reproduced here exactly
+ * rather than derived from the schema. The Zod contract/types live in
+ * @trek/shared/weather and are used for typing; `lang` defaults to 'de' only when
+ * the param is absent, matching the Express destructuring default.
+ */
+@Controller('api/weather')
+@UseGuards(JwtAuthGuard)
+export class WeatherController {
+ constructor(private readonly weather: WeatherService) {}
+
+ @Get()
+ async getWeather(
+ @Query('lat') lat?: string,
+ @Query('lng') lng?: string,
+ @Query('date') date?: string,
+ @Query('lang') lang?: string,
+ ): Promise {
+ if (!lat || !lng) {
+ throw new HttpException({ error: 'Latitude and longitude are required' }, 400);
+ }
+ try {
+ return await this.weather.get(lat, lng, date, lang ?? 'de');
+ } catch (err: unknown) {
+ throw toHttp(err, 'Weather error:', 'Error fetching weather data');
+ }
+ }
+
+ @Get('detailed')
+ async getDetailed(
+ @Query('lat') lat?: string,
+ @Query('lng') lng?: string,
+ @Query('date') date?: string,
+ @Query('lang') lang?: string,
+ ): Promise {
+ if (!lat || !lng || !date) {
+ throw new HttpException({ error: 'Latitude, longitude, and date are required' }, 400);
+ }
+ try {
+ return await this.weather.getDetailed(lat, lng, date, lang ?? 'de');
+ } catch (err: unknown) {
+ throw toHttp(err, 'Detailed weather error:', 'Error fetching detailed weather data');
+ }
+ }
+}
+
+/** Maps a thrown error to the same status + `{ error }` body the Express route sent. */
+function toHttp(err: unknown, logPrefix: string, fallback: string): HttpException {
+ if (err instanceof ApiError) {
+ return new HttpException({ error: err.message }, err.status);
+ }
+ console.error(logPrefix, err);
+ return new HttpException({ error: fallback }, 500);
+}
diff --git a/server/src/nest/weather/weather.module.ts b/server/src/nest/weather/weather.module.ts
new file mode 100644
index 00000000..3615fa1a
--- /dev/null
+++ b/server/src/nest/weather/weather.module.ts
@@ -0,0 +1,10 @@
+import { Module } from '@nestjs/common';
+import { WeatherController } from './weather.controller';
+import { WeatherService } from './weather.service';
+
+/** Weather domain (pilot leaf module). Registered in AppModule. */
+@Module({
+ controllers: [WeatherController],
+ providers: [WeatherService],
+})
+export class WeatherModule {}
diff --git a/server/src/nest/weather/weather.service.ts b/server/src/nest/weather/weather.service.ts
new file mode 100644
index 00000000..a9319d3a
--- /dev/null
+++ b/server/src/nest/weather/weather.service.ts
@@ -0,0 +1,21 @@
+import { Injectable } from '@nestjs/common';
+import type { WeatherResult } from '@trek/shared';
+import { getWeather, getDetailedWeather } from '../../services/weatherService';
+
+/**
+ * Thin Nest wrapper around the existing weather service. It delegates to the
+ * exact same `getWeather` / `getDetailedWeather` functions the legacy route and
+ * the MCP tools use, so behaviour — including the shared in-memory cache and the
+ * Open-Meteo calls — is identical. No logic is duplicated; the upstream service
+ * stays the single source of truth (still consumed by the MCP weather tools).
+ */
+@Injectable()
+export class WeatherService {
+ get(lat: string, lng: string, date: string | undefined, lang: string): Promise {
+ return getWeather(lat, lng, date, lang) as Promise;
+ }
+
+ getDetailed(lat: string, lng: string, date: string, lang: string): Promise {
+ return getDetailedWeather(lat, lng, date, lang) as Promise;
+ }
+}
diff --git a/server/src/routes/journey.ts b/server/src/routes/journey.ts
index 1336bd50..b655d7ce 100644
--- a/server/src/routes/journey.ts
+++ b/server/src/routes/journey.ts
@@ -98,7 +98,7 @@ router.delete('/entries/:entryId', authenticate, (req: Request, res: Response) =
// ── Photos (prefix /photos and /entries — before /:id) ───────────────────
-router.post('/entries/:entryId/photos', authenticate, upload.array('photos', 10), async (req: Request, res: Response) => {
+router.post('/entries/:entryId/photos', authenticate, upload.array('photos'), async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const files = req.files as Express.Multer.File[];
if (!files?.length) return res.status(400).json({ error: 'No files uploaded' });
@@ -201,7 +201,7 @@ router.delete('/photos/:photoId', authenticate, async (req: Request, res: Respon
// ── Gallery (prefix /:id/gallery — before /:id) ──────────────────────────
// Upload photos directly to the journey gallery (no entry association)
-router.post('/:id/gallery/photos', authenticate, upload.array('photos', 20), async (req: Request, res: Response) => {
+router.post('/:id/gallery/photos', authenticate, upload.array('photos'), async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const files = req.files as Express.Multer.File[];
if (!files?.length) return res.status(400).json({ error: 'No files uploaded' });
diff --git a/server/src/routes/oauth.ts b/server/src/routes/oauth.ts
index d7964329..e5666dc1 100644
--- a/server/src/routes/oauth.ts
+++ b/server/src/routes/oauth.ts
@@ -10,6 +10,7 @@ import {
consumeAuthCode,
saveConsent,
issueTokens,
+ issueClientCredentialsToken,
refreshTokens,
revokeToken,
verifyPKCE,
@@ -24,6 +25,7 @@ import {
AuthorizeParams,
} from '../services/oauthService';
import { writeAudit, getClientIp, logWarn } from '../services/auditLog';
+import { getMcpSafeUrl } from '../services/notifications';
// ---------------------------------------------------------------------------
// Minimal in-file rate limiter (same pattern as auth.ts)
@@ -151,6 +153,48 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons
return res.json(result.tokens);
}
+ // ---- client_credentials grant ----
+ if (grant_type === 'client_credentials') {
+ if (!client_secret) {
+ return res.status(401).json({ error: 'invalid_client', error_description: 'client_secret is required for client_credentials grant' });
+ }
+
+ const client = authenticateClient(client_id, client_secret);
+ if (!client) {
+ logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`);
+ writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id }, ip });
+ return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
+ }
+
+ // Public clients and DCR-anonymous clients are ineligible for client_credentials.
+ if (client.is_public || !client.allows_client_credentials || client.user_id == null) {
+ writeAudit({ userId: client.user_id ?? null, action: 'oauth.token.grant_failed', details: { client_id, reason: 'unauthorized_client' }, ip });
+ return res.status(400).json({ error: 'unauthorized_client', error_description: 'This client is not authorized for the client_credentials grant' });
+ }
+
+ // Scope: use requested subset or fall back to all allowed scopes.
+ const allowedScopes: string[] = JSON.parse(client.allowed_scopes);
+ let grantedScopes: string[];
+ if (body.scope) {
+ const requested = body.scope.split(' ').filter(Boolean);
+ const invalid = requested.filter(s => !allowedScopes.includes(s));
+ if (invalid.length > 0) {
+ return res.status(400).json({ error: 'invalid_scope', error_description: `Scopes not allowed for this client: ${invalid.join(', ')}` });
+ }
+ grantedScopes = requested;
+ } else {
+ grantedScopes = allowedScopes;
+ }
+
+ // Audience: honour RFC 8707 resource param; default to the MCP endpoint so the
+ // token passes audience binding in mcp/index.ts without extra configuration.
+ const audience = resource ? resource.replace(/\/+$/, '') : `${getMcpSafeUrl().replace(/\/+$/, '')}/mcp`;
+
+ const tokens = issueClientCredentialsToken(client_id, client.user_id, grantedScopes, audience);
+ writeAudit({ userId: client.user_id, action: 'oauth.token.issue', details: { client_id, scopes: grantedScopes, audience, grant: 'client_credentials' }, ip });
+ return res.json(tokens);
+ }
+
return res.status(400).json({ error: 'unsupported_grant_type', error_description: `Unsupported grant_type: ${grant_type}` });
});
@@ -327,13 +371,14 @@ oauthApiRouter.get('/clients', authenticate, (req: Request, res: Response) => {
oauthApiRouter.post('/clients', requireCookieAuth, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
const { user } = req as AuthRequest;
- const { name, redirect_uris, allowed_scopes } = req.body as {
+ const { name, redirect_uris, allowed_scopes, allows_client_credentials } = req.body as {
name: string;
- redirect_uris: string[];
+ redirect_uris?: string[];
allowed_scopes: string[];
+ allows_client_credentials?: boolean;
};
- const result = createOAuthClient(user.id, name, redirect_uris, allowed_scopes, getClientIp(req));
+ const result = createOAuthClient(user.id, name, redirect_uris ?? [], allowed_scopes, getClientIp(req), { allowsClientCredentials: allows_client_credentials });
if (result.error) return res.status(result.status || 400).json({ error: result.error });
return res.status(201).json(result);
});
diff --git a/server/src/routes/reservations.ts b/server/src/routes/reservations.ts
index 52cfdc63..48e9ef03 100644
--- a/server/src/routes/reservations.ts
+++ b/server/src/routes/reservations.ts
@@ -13,7 +13,7 @@ import {
updateReservation,
deleteReservation,
} from '../services/reservationService';
-import { createBudgetItem, updateBudgetItem, deleteBudgetItem } from '../services/budgetService';
+import { createBudgetItem, updateBudgetItem, deleteBudgetItem, linkBudgetItemToReservation } from '../services/budgetService';
const router = express.Router({ mergeParams: true });
@@ -55,13 +55,11 @@ router.post('/', authenticate, (req: Request, res: Response) => {
// Auto-create budget entry if price was provided
if (create_budget_entry && create_budget_entry.total_price > 0) {
try {
- const budgetItem = createBudgetItem(tripId, {
+ const budgetItem = linkBudgetItemToReservation(tripId, reservation.id, {
name: title,
category: create_budget_entry.category || type || 'Other',
total_price: create_budget_entry.total_price,
});
- db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(reservation.id, budgetItem.id);
- budgetItem.reservation_id = reservation.id;
broadcast(tripId, 'budget:created', { item: budgetItem }, req.headers['x-socket-id'] as string);
} catch (err) {
console.error('[reservations] Failed to create budget entry:', err);
diff --git a/server/src/routes/weather.ts b/server/src/routes/weather.ts
deleted file mode 100644
index 08194dd8..00000000
--- a/server/src/routes/weather.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import express, { Request, Response } from 'express';
-import { authenticate } from '../middleware/auth';
-import { getWeather, getDetailedWeather, ApiError } from '../services/weatherService';
-
-const router = express.Router();
-
-router.get('/', authenticate, async (req: Request, res: Response) => {
- const { lat, lng, date, lang = 'de' } = req.query as { lat: string; lng: string; date?: string; lang?: string };
-
- if (!lat || !lng) {
- return res.status(400).json({ error: 'Latitude and longitude are required' });
- }
-
- try {
- const result = await getWeather(lat, lng, date, lang);
- res.json(result);
- } catch (err: unknown) {
- if (err instanceof ApiError) {
- return res.status(err.status).json({ error: err.message });
- }
- console.error('Weather error:', err);
- res.status(500).json({ error: 'Error fetching weather data' });
- }
-});
-
-router.get('/detailed', authenticate, async (req: Request, res: Response) => {
- const { lat, lng, date, lang = 'de' } = req.query as { lat: string; lng: string; date: string; lang?: string };
-
- if (!lat || !lng || !date) {
- return res.status(400).json({ error: 'Latitude, longitude, and date are required' });
- }
-
- try {
- const result = await getDetailedWeather(lat, lng, date, lang);
- res.json(result);
- } catch (err: unknown) {
- if (err instanceof ApiError) {
- return res.status(err.status).json({ error: err.message });
- }
- console.error('Detailed weather error:', err);
- res.status(500).json({ error: 'Error fetching detailed weather data' });
- }
-});
-
-export default router;
diff --git a/server/src/services/atlasService.ts b/server/src/services/atlasService.ts
index 95f70d27..ddb1c074 100644
--- a/server/src/services/atlasService.ts
+++ b/server/src/services/atlasService.ts
@@ -100,6 +100,12 @@ export const COUNTRY_BOXES: Record = {
UG:[29.6,-1.5,35.0,4.2],UY:[-58.4,-34.9,-53.1,-30.1],UZ:[55.9,37.2,73.1,45.6],VE:[-73.4,0.7,-59.8,12.2],
AE:[51.6,22.6,56.4,26.1],GB:[-8,49.9,2,60.9],US:[-125,24.5,-66.9,49.4],VN:[102.1,8.6,109.5,23.4],XK:[20.0,41.9,21.8,43.3],
YE:[42.5,12.1,54.0,19.0],ZM:[21.9,-18.1,33.7,-8.2],ZW:[25.2,-22.4,33.1,-15.6],
+ // Territories with their own ISO code that sit inside a larger country's box.
+ // Listed so getCountryFromCoords()'s smallest-box match picks them over the host
+ // (e.g. Hong Kong/Macau over China, San Marino/Vatican over Italy).
+ HK:[113.83,22.15,114.43,22.56],MO:[113.53,22.10,113.60,22.21],SM:[12.40,43.89,12.52,43.99],
+ VA:[12.44,41.90,12.46,41.91],MC:[7.40,43.72,7.44,43.75],LI:[9.47,47.05,9.64,47.27],
+ GI:[-5.36,36.11,-5.33,36.16],PR:[-67.30,17.88,-65.22,18.53],
};
export const NAME_TO_CODE: Record = {
@@ -144,6 +150,9 @@ export const NAME_TO_CODE: Record = {
'angola':'AO','namibia':'NA','botswana':'BW','zimbabwe':'ZW','zambia':'ZM','malawi':'MW',
'mozambique':'MZ','mozambik':'MZ','madagascar':'MG','rwanda':'RW','burundi':'BI',
'somalia':'SO','papua new guinea':'PG','brunei':'BN',
+ 'hong kong':'HK','hong kong sar':'HK','macau':'MO','macao':'MO','macau sar':'MO',
+ 'san marino':'SM','vatican':'VA','vatican city':'VA','holy see':'VA','monaco':'MC',
+ 'liechtenstein':'LI','gibraltar':'GI','puerto rico':'PR',
};
export const CONTINENT_MAP: Record = {
@@ -167,6 +176,7 @@ export const CONTINENT_MAP: Record = {
ZA:'Africa',SE:'Europe',CH:'Europe',TH:'Asia',TR:'Europe',UA:'Europe',UG:'Africa',UY:'South America',
UZ:'Asia',VE:'South America',AE:'Asia',GB:'Europe',US:'North America',VN:'Asia',XK:'Europe',
YE:'Asia',ZM:'Africa',ZW:'Africa',NG:'Africa',
+ HK:'Asia',MO:'Asia',SM:'Europe',VA:'Europe',MC:'Europe',LI:'Europe',GI:'Europe',PR:'North America',
};
// ── Geocoding helpers ───────────────────────────────────────────────────────
@@ -366,11 +376,17 @@ export async function getStats(userId: number) {
for (const place of places) {
if (place.address) {
const parts = place.address.split(',').map((s: string) => s.trim()).filter(Boolean);
- let raw = parts.length >= 2 ? parts[parts.length - 2] : parts[0];
- if (raw) {
- const city = raw.replace(/[\d\-\u2212\u3012]+/g, '').trim().toLowerCase();
- if (city) citySet.add(city);
+ // The last part is the country; the city is usually right before it, but a
+ // full formatted address can have a postal code sitting between them
+ // (e.g. "Bucharest, 010071, Romania"). Walk back from the country and take
+ // the first part that still has letters once digits/postal noise is stripped.
+ const candidates = parts.length >= 2 ? parts.slice(0, -1) : parts;
+ let city = '';
+ for (let i = candidates.length - 1; i >= 0; i--) {
+ const cleaned = candidates[i].replace(/[\d\-\u2212\u3012]+/g, '').trim();
+ if (cleaned) { city = cleaned.toLowerCase(); break; }
}
+ if (city) citySet.add(city);
}
}
const totalCities = citySet.size;
diff --git a/server/src/services/budgetService.ts b/server/src/services/budgetService.ts
index f98d5d9e..7c5934c4 100644
--- a/server/src/services/budgetService.ts
+++ b/server/src/services/budgetService.ts
@@ -96,6 +96,17 @@ export function createBudgetItem(
return item;
}
+export function linkBudgetItemToReservation(
+ tripId: string | number,
+ reservationId: number,
+ data: { name: string; category?: string; total_price: number },
+) {
+ const item = createBudgetItem(tripId, data) as BudgetItem & { reservation_id?: number | null };
+ db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(reservationId, item.id);
+ item.reservation_id = reservationId;
+ return item;
+}
+
export function updateBudgetItem(
id: string | number,
tripId: string | number,
diff --git a/server/src/services/mapsService.ts b/server/src/services/mapsService.ts
index 00151fe2..c336135c 100644
--- a/server/src/services/mapsService.ts
+++ b/server/src/services/mapsService.ts
@@ -1,6 +1,7 @@
import { db } from '../db/database';
import { decrypt_api_key } from './apiKeyCrypto';
import { checkSsrf } from '../utils/ssrfGuard';
+import { getAppUrl } from './notifications';
// ── Google API call counter ───────────────────────────────────────────────────
@@ -12,7 +13,11 @@ export function resetGoogleApiCallCount(): void { googleApiCallCount = 0; }
function googleFetch(endpoint: string, label: string, init?: RequestInit): Promise {
googleApiCallCount++;
console.debug(`[Google API] #${googleApiCallCount} ${label} → ${endpoint}`);
- return fetch(endpoint, init);
+ const referer = process.env.APP_URL ? getAppUrl() : undefined;
+ return fetch(endpoint, {
+ ...init,
+ headers: { ...(referer ? { Referer: referer } : {}), ...(init?.headers as Record ?? {}) },
+ });
}
// ── Interfaces ───────────────────────────────────────────────────────────────
diff --git a/server/src/services/notifications.ts b/server/src/services/notifications.ts
index ecc9a3bc..94b6b34c 100644
--- a/server/src/services/notifications.ts
+++ b/server/src/services/notifications.ts
@@ -316,12 +316,12 @@ export function getEventText(lang: string, event: NotifEventType, params: Record
// ── Email HTML builder ─────────────────────────────────────────────────────
-export function buildEmailHtml(subject: string, body: string, lang: string, navigateTarget?: string): string {
+export function buildEmailHtml(subject: string, body: string, lang: string, navigateTarget?: string, rawBody = false): string {
const s = I18N[lang] || I18N.en;
const appUrl = getAppUrl();
const ctaHref = escapeHtml(navigateTarget ? `${appUrl}${navigateTarget}` : (appUrl || ''));
const safeSubject = escapeHtml(subject);
- const safeBody = escapeHtml(body);
+ const safeBody = rawBody ? body : escapeHtml(body);
return `
@@ -396,7 +396,7 @@ function buildPasswordResetHtml(subject: string, strings: PasswordResetStrings,
${safeExpiry}
${safeIgnore}
`;
- return buildEmailHtml(subject, block, lang);
+ return buildEmailHtml(subject, block, lang, undefined, true);
}
/**
diff --git a/server/src/services/oauthService.ts b/server/src/services/oauthService.ts
index 01864156..2e782f97 100644
--- a/server/src/services/oauthService.ts
+++ b/server/src/services/oauthService.ts
@@ -60,6 +60,7 @@ interface OAuthClientRow {
created_at: string;
is_public: number; // 0 | 1 (SQLite boolean)
created_via: string; // 'settings_ui' | 'browser-registration'
+ allows_client_credentials: number; // 0 | 1
}
interface OAuthTokenRow {
@@ -106,11 +107,12 @@ function generateRefreshToken(): string {
export function listOAuthClients(userId: number): Record[] {
const rows = db.prepare(
- 'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via FROM oauth_clients WHERE user_id = ? ORDER BY created_at DESC'
+ 'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via, allows_client_credentials FROM oauth_clients WHERE user_id = ? ORDER BY created_at DESC'
).all(userId) as OAuthClientRow[];
return rows.map(r => ({
...r,
is_public: Boolean(r.is_public),
+ allows_client_credentials: Boolean(r.allows_client_credentials),
redirect_uris: JSON.parse(r.redirect_uris),
allowed_scopes: JSON.parse(r.allowed_scopes),
}));
@@ -132,11 +134,12 @@ export function createOAuthClient(
redirectUris: string[],
allowedScopes: string[],
ip?: string | null,
- options?: { isPublic?: boolean; createdVia?: string },
+ options?: { isPublic?: boolean; createdVia?: string; allowsClientCredentials?: boolean },
): { error?: string; status?: number; client?: Record } {
if (!name?.trim()) return { error: 'Name is required', status: 400 };
if (name.trim().length > 100) return { error: 'Name must be 100 characters or less', status: 400 };
- if (!redirectUris || redirectUris.length === 0) return { error: 'At least one redirect URI is required', status: 400 };
+ const isMachineClient = Boolean(options?.allowsClientCredentials);
+ if (!isMachineClient && (!redirectUris || redirectUris.length === 0)) return { error: 'At least one redirect URI is required', status: 400 };
if (redirectUris.length > 10) return { error: 'Maximum 10 redirect URIs per client', status: 400 };
for (const uri of redirectUris) {
@@ -164,7 +167,8 @@ export function createOAuthClient(
if (count >= 500) return { error: 'server_error', status: 503 };
}
- const isPublic = options?.isPublic ?? false;
+ // Machine clients (client_credentials) must always be confidential — ignore isPublic for them.
+ const isPublic = isMachineClient ? false : (options?.isPublic ?? false);
const createdVia = options?.createdVia ?? 'settings_ui';
const id = randomUUID();
const clientId = randomUUID();
@@ -173,14 +177,14 @@ export function createOAuthClient(
const secretHash = rawSecret ? hashToken(rawSecret) : randomBytes(32).toString('hex');
db.prepare(
- 'INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, is_public, created_via) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
- ).run(id, userId, name.trim(), clientId, secretHash, JSON.stringify(redirectUris), JSON.stringify(allowedScopes), isPublic ? 1 : 0, createdVia);
+ 'INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, is_public, created_via, allows_client_credentials) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
+ ).run(id, userId, name.trim(), clientId, secretHash, JSON.stringify(redirectUris), JSON.stringify(allowedScopes), isPublic ? 1 : 0, createdVia, isMachineClient ? 1 : 0);
const row = db.prepare(
- 'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via FROM oauth_clients WHERE id = ?'
+ 'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via, allows_client_credentials FROM oauth_clients WHERE id = ?'
).get(id) as OAuthClientRow;
- writeAudit({ userId, action: 'oauth.client.create', details: { client_id: clientId, name: name.trim(), is_public: isPublic }, ip });
+ writeAudit({ userId, action: 'oauth.client.create', details: { client_id: clientId, name: name.trim(), is_public: isPublic, allows_client_credentials: isMachineClient }, ip });
return {
client: {
@@ -192,6 +196,7 @@ export function createOAuthClient(
allowed_scopes: JSON.parse(row.allowed_scopes),
created_at: row.created_at,
is_public: Boolean(row.is_public),
+ allows_client_credentials: Boolean(row.allows_client_credentials),
created_via: row.created_via,
// client_secret only present for confidential clients — shown once, not stored in plain text
...(rawSecret ? { client_secret: rawSecret } : {}),
@@ -330,6 +335,43 @@ export function issueTokens(
};
}
+// Issues an access token only — no refresh token (RFC 6749 §4.4.3).
+// Used exclusively for the client_credentials grant. A random opaque hash is
+// stored in refresh_token_hash to satisfy the NOT NULL/UNIQUE constraint; it
+// can never be presented as a valid refresh token (same precedent as public
+// client secret hashes stored in client_secret_hash).
+export function issueClientCredentialsToken(
+ clientId: string,
+ userId: number,
+ scopes: string[],
+ audience: string,
+): {
+ access_token: string;
+ token_type: 'Bearer';
+ expires_in: number;
+ scope: string;
+} {
+ const rawAccess = generateAccessToken();
+ const accessHash = hashToken(rawAccess);
+ const placeholderHash = randomBytes(32).toString('hex');
+
+ const now = new Date();
+ const accessExpiry = new Date(now.getTime() + ACCESS_TOKEN_TTL_S * 1000);
+
+ db.prepare(`
+ INSERT INTO oauth_tokens
+ (client_id, user_id, access_token_hash, refresh_token_hash, scopes, audience, access_token_expires_at, refresh_token_expires_at, parent_token_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `).run(clientId, userId, accessHash, placeholderHash, JSON.stringify(scopes), audience, accessExpiry.toISOString(), now.toISOString(), null);
+
+ return {
+ access_token: rawAccess,
+ token_type: 'Bearer',
+ expires_in: ACCESS_TOKEN_TTL_S,
+ scope: scopes.join(' '),
+ };
+}
+
// ---------------------------------------------------------------------------
// Token verification (used by MCP handler on every request)
// ---------------------------------------------------------------------------
diff --git a/server/src/services/tripService.ts b/server/src/services/tripService.ts
index c977a260..83ad7171 100644
--- a/server/src/services/tripService.ts
+++ b/server/src/services/tripService.ts
@@ -506,6 +506,11 @@ export function exportICS(tripId: string | number): { ics: string; filename: str
// Reservations as events
for (const r of reservations) {
if (!r.reservation_time) continue;
+ // Skip time-only values (no calendar date — occurs on relative "Day N" trips)
+ const hasDate = r.reservation_time.includes('T')
+ ? /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_time.split('T')[0])
+ : /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_time);
+ if (!hasDate) continue;
const hasTime = r.reservation_time.includes('T');
const meta = r.metadata ? (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata) : {};
diff --git a/server/tests/e2e/harness.ts b/server/tests/e2e/harness.ts
new file mode 100644
index 00000000..e829bce0
--- /dev/null
+++ b/server/tests/e2e/harness.ts
@@ -0,0 +1,65 @@
+import Database from 'better-sqlite3';
+import jwt from 'jsonwebtoken';
+import { JWT_SECRET } from '../../src/config';
+
+/**
+ * Shared e2e harness for migrated Nest modules.
+ *
+ * Gives each module e2e test a throwaway in-memory SQLite db (the same shape the
+ * shared connection exposes), a seed helper for demo data, and a session-cookie
+ * signer that produces tokens the REAL JwtAuthGuard accepts — so e2e tests cover
+ * the actual auth path end-to-end, not a stubbed guard.
+ *
+ * Wire it in a test with `vi.mock('../../src/db/database', () => ({ db, ... }))`
+ * using the db returned here, then build the Nest app under test.
+ */
+
+export interface SeededUser {
+ id: number;
+ username: string;
+ email: string;
+ role: 'user' | 'admin';
+ password_version: number;
+}
+
+/** Fresh in-memory db with the minimal `users` table the auth guard reads. */
+export function createTempDb(): Database.Database {
+ const db = new Database(':memory:');
+ db.exec('PRAGMA journal_mode = WAL');
+ db.exec('PRAGMA foreign_keys = ON');
+ db.exec(`
+ CREATE TABLE users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT NOT NULL,
+ email TEXT NOT NULL UNIQUE,
+ role TEXT NOT NULL DEFAULT 'user',
+ password_version INTEGER NOT NULL DEFAULT 0
+ );
+ `);
+ return db;
+}
+
+/** Insert a demo user and return its row. */
+export function seedUser(db: Database.Database, overrides: Partial = {}): SeededUser {
+ const user: SeededUser = {
+ id: overrides.id ?? 1,
+ username: overrides.username ?? 'e2e-user',
+ email: overrides.email ?? 'e2e@example.test',
+ role: overrides.role ?? 'user',
+ password_version: overrides.password_version ?? 0,
+ };
+ db.prepare(
+ 'INSERT INTO users (id, username, email, role, password_version) VALUES (?, ?, ?, ?, ?)',
+ ).run(user.id, user.username, user.email, user.role, user.password_version);
+ return user;
+}
+
+/** Sign a `trek_session` token the real guard will accept (matching JWT_SECRET + pv). */
+export function signSession(userId: number, passwordVersion = 0): string {
+ return jwt.sign({ id: userId, pv: passwordVersion }, JWT_SECRET, { algorithm: 'HS256' });
+}
+
+/** Convenience: the Cookie header value for a signed session. */
+export function sessionCookie(userId: number, passwordVersion = 0): string {
+ return `trek_session=${signSession(userId, passwordVersion)}`;
+}
diff --git a/server/tests/e2e/weather.e2e.test.ts b/server/tests/e2e/weather.e2e.test.ts
new file mode 100644
index 00000000..fa470857
--- /dev/null
+++ b/server/tests/e2e/weather.e2e.test.ts
@@ -0,0 +1,88 @@
+/**
+ * Weather module e2e — exercises the migrated /api/weather endpoints through the
+ * real JwtAuthGuard against a temp SQLite db (seeded via the shared harness).
+ * The weather service is mocked so no real Open-Meteo calls happen.
+ */
+import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
+import request from 'supertest';
+import cookieParser from 'cookie-parser';
+import type { Server } from 'http';
+import { Test } from '@nestjs/testing';
+import { createTempDb, seedUser, sessionCookie } from './harness';
+
+const { db } = vi.hoisted(() => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const Database = require('better-sqlite3');
+ const tmp = new Database(':memory:');
+ tmp.exec('PRAGMA journal_mode = WAL');
+ tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
+ email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
+ return { db: tmp };
+});
+
+vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
+
+const { mockGet, mockGetDetailed } = vi.hoisted(() => ({ mockGet: vi.fn(), mockGetDetailed: vi.fn() }));
+vi.mock('../../src/services/weatherService', async (importActual) => {
+ const actual = await importActual();
+ return { ...actual, getWeather: mockGet, getDetailedWeather: mockGetDetailed };
+});
+
+import { WeatherModule } from '../../src/nest/weather/weather.module';
+import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
+
+describe('Weather e2e (real auth guard + temp SQLite)', () => {
+ let server: Server;
+ let app: Awaited>;
+
+ async function build() {
+ const moduleRef = await Test.createTestingModule({ imports: [WeatherModule] }).compile();
+ const nest = moduleRef.createNestApplication();
+ nest.use(cookieParser());
+ nest.useGlobalFilters(new TrekExceptionFilter());
+ await nest.init();
+ return nest;
+ }
+
+ beforeAll(async () => {
+ seedUser(db as never, { id: 1 });
+ app = await build();
+ server = app.getHttpServer();
+ mockGet.mockResolvedValue({ temp: 21, main: 'Clear', description: 'Klar', type: 'current' });
+ mockGetDetailed.mockResolvedValue({ temp: 20, main: 'Rain', description: 'Regen', type: 'forecast', hourly: [] });
+ });
+
+ afterAll(async () => {
+ await app.close();
+ });
+
+ it('401 { error, code } without a session cookie', async () => {
+ const res = await request(server).get('/api/weather').query({ lat: '1', lng: '2' });
+ expect(res.status).toBe(401);
+ expect(res.body).toEqual({ error: 'Access token required', code: 'AUTH_REQUIRED' });
+ });
+
+ it('401 with an invalid token', async () => {
+ const res = await request(server).get('/api/weather').set('Cookie', 'trek_session=not-a-jwt').query({ lat: '1', lng: '2' });
+ expect(res.status).toBe(401);
+ expect(res.body).toEqual({ error: 'Invalid or expired token', code: 'AUTH_REQUIRED' });
+ });
+
+ it('400 when authenticated but lat/lng missing', async () => {
+ const res = await request(server).get('/api/weather').set('Cookie', sessionCookie(1)).query({ lng: '2' });
+ expect(res.status).toBe(400);
+ expect(res.body).toEqual({ error: 'Latitude and longitude are required' });
+ });
+
+ it('200 with a valid session cookie', async () => {
+ const res = await request(server).get('/api/weather').set('Cookie', sessionCookie(1)).query({ lat: '52.5', lng: '13.4' });
+ expect(res.status).toBe(200);
+ expect(res.body).toMatchObject({ temp: 21, main: 'Clear', type: 'current' });
+ });
+
+ it('200 on /detailed with a valid session cookie', async () => {
+ const res = await request(server).get('/api/weather/detailed').set('Cookie', sessionCookie(1)).query({ lat: '1', lng: '2', date: '2026-07-01' });
+ expect(res.status).toBe(200);
+ expect(res.body).toMatchObject({ type: 'forecast' });
+ });
+});
diff --git a/server/tests/integration/oauth.test.ts b/server/tests/integration/oauth.test.ts
index 5afb6104..beeebd9b 100644
--- a/server/tests/integration/oauth.test.ts
+++ b/server/tests/integration/oauth.test.ts
@@ -63,7 +63,7 @@ import { resetTestDb } from '../helpers/test-db';
import { createUser } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
-import { createOAuthClient, createAuthCode } from '../../src/services/oauthService';
+import { createOAuthClient, createAuthCode, getUserByAccessToken } from '../../src/services/oauthService';
const app: Application = createApp();
@@ -1285,4 +1285,141 @@ describe('C3 — Refresh token replay detection', () => {
expect(t4.status).toBe(400);
expect(t4.body.error).toBe('invalid_grant');
});
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// POST /oauth/token — client_credentials grant
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe('POST /oauth/token — client_credentials grant', () => {
+ it('OAUTH-CC-001 — happy path: issues access token with no refresh_token', async () => {
+ const { user } = createUser(testDb);
+ const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
+
+ const res = await request(app)
+ .post('/oauth/token')
+ .send({
+ grant_type: 'client_credentials',
+ client_id: r.client!.client_id,
+ client_secret: r.client!.client_secret,
+ });
+
+ expect(res.status).toBe(200);
+ expect(res.body.access_token).toBeDefined();
+ expect(res.body.token_type).toBe('Bearer');
+ expect(typeof res.body.expires_in).toBe('number');
+ expect(res.body.scope).toBe('trips:read');
+ expect(res.body.refresh_token).toBeUndefined();
+ });
+
+ it('OAUTH-CC-002 — issued token resolves to the client owner user', async () => {
+ const { user } = createUser(testDb);
+ const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
+
+ const res = await request(app)
+ .post('/oauth/token')
+ .send({
+ grant_type: 'client_credentials',
+ client_id: r.client!.client_id,
+ client_secret: r.client!.client_secret,
+ });
+
+ expect(res.status).toBe(200);
+ const info = getUserByAccessToken(res.body.access_token);
+ expect(info).not.toBeNull();
+ expect(info!.user.id).toBe(user.id);
+ expect(info!.scopes).toEqual(['trips:read']);
+ });
+
+ it('OAUTH-CC-003 — wrong client_secret returns 401 invalid_client', async () => {
+ const { user } = createUser(testDb);
+ const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
+
+ const res = await request(app)
+ .post('/oauth/token')
+ .send({
+ grant_type: 'client_credentials',
+ client_id: r.client!.client_id,
+ client_secret: 'trekcs_wrong',
+ });
+
+ expect(res.status).toBe(401);
+ expect(res.body.error).toBe('invalid_client');
+ });
+
+ it('OAUTH-CC-004 — missing client_secret returns 401 invalid_client', async () => {
+ const { user } = createUser(testDb);
+ const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
+
+ const res = await request(app)
+ .post('/oauth/token')
+ .send({
+ grant_type: 'client_credentials',
+ client_id: r.client!.client_id,
+ });
+
+ expect(res.status).toBe(401);
+ expect(res.body.error).toBe('invalid_client');
+ });
+
+ it('OAUTH-CC-005 — non-machine client returns 400 unauthorized_client', async () => {
+ const { user } = createUser(testDb);
+ const r = createOAuthClient(user.id, 'BrowserApp', ['https://app.example.com/cb'], ['trips:read']);
+
+ const res = await request(app)
+ .post('/oauth/token')
+ .send({
+ grant_type: 'client_credentials',
+ client_id: r.client!.client_id,
+ client_secret: r.client!.client_secret,
+ });
+
+ expect(res.status).toBe(400);
+ expect(res.body.error).toBe('unauthorized_client');
+ });
+
+ it('OAUTH-CC-006 — scope narrowing: requested subset is honoured', async () => {
+ const { user } = createUser(testDb);
+ const r = createOAuthClient(user.id, 'Machine', [], ['trips:read', 'places:read'], null, { allowsClientCredentials: true });
+
+ const res = await request(app)
+ .post('/oauth/token')
+ .send({
+ grant_type: 'client_credentials',
+ client_id: r.client!.client_id,
+ client_secret: r.client!.client_secret,
+ scope: 'trips:read',
+ });
+
+ expect(res.status).toBe(200);
+ expect(res.body.scope).toBe('trips:read');
+ });
+
+ it('OAUTH-CC-007 — scope outside allowed_scopes returns 400 invalid_scope', async () => {
+ const { user } = createUser(testDb);
+ const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
+
+ const res = await request(app)
+ .post('/oauth/token')
+ .send({
+ grant_type: 'client_credentials',
+ client_id: r.client!.client_id,
+ client_secret: r.client!.client_secret,
+ scope: 'places:write',
+ });
+
+ expect(res.status).toBe(400);
+ expect(res.body.error).toBe('invalid_scope');
+ });
+
+ it('OAUTH-CC-008 — createOAuthClient with allowsClientCredentials succeeds without redirect URIs', () => {
+ const { user } = createUser(testDb);
+ const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
+
+ expect(r.error).toBeUndefined();
+ expect(r.client).toBeDefined();
+ expect(r.client!.allows_client_credentials).toBe(true);
+ expect((r.client!.redirect_uris as string[]).length).toBe(0);
+ expect(r.client!.client_secret).toBeDefined();
+ });
});
\ No newline at end of file
diff --git a/server/tests/integration/weather.test.ts b/server/tests/integration/weather.test.ts
deleted file mode 100644
index 5acded6f..00000000
--- a/server/tests/integration/weather.test.ts
+++ /dev/null
@@ -1,262 +0,0 @@
-/**
- * Weather integration tests.
- * Covers WEATHER-001 to WEATHER-007.
- *
- * External API calls (Open-Meteo) are mocked via vi.mock.
- */
-import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
-import request from 'supertest';
-import type { Application } from 'express';
-
-const { testDb, dbMock } = vi.hoisted(() => {
- const Database = require('better-sqlite3');
- const db = new Database(':memory:');
- db.exec('PRAGMA journal_mode = WAL');
- db.exec('PRAGMA foreign_keys = ON');
- db.exec('PRAGMA busy_timeout = 5000');
- const mock = {
- db,
- closeDb: () => {},
- reinitialize: () => {},
- getPlaceWithTags: (placeId: number) => {
- const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
- if (!place) return null;
- const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
- return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
- },
- canAccessTrip: (tripId: any, userId: number) =>
- db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
- isOwner: (tripId: any, userId: number) =>
- !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
- };
- return { testDb: db, dbMock: mock };
-});
-
-vi.mock('../../src/db/database', () => dbMock);
-vi.mock('../../src/config', () => ({
- JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
- ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
- updateJwtSecret: () => {},
-}));
-
-// Prevent real HTTP calls to Open-Meteo
-vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
- ok: true,
- json: () => Promise.resolve({
- current: { temperature_2m: 22, weathercode: 1, windspeed_10m: 10, relativehumidity_2m: 60, precipitation: 0 },
- daily: {
- time: ['2025-06-01'],
- temperature_2m_max: [25],
- temperature_2m_min: [18],
- weathercode: [1],
- precipitation_sum: [0],
- windspeed_10m_max: [15],
- sunrise: ['2025-06-01T06:00'],
- sunset: ['2025-06-01T21:00'],
- },
- }),
-}));
-
-import { createApp } from '../../src/app';
-import { createTables } from '../../src/db/schema';
-import { runMigrations } from '../../src/db/migrations';
-import { resetTestDb } from '../helpers/test-db';
-import { createUser } from '../helpers/factories';
-import { authCookie } from '../helpers/auth';
-import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
-
-const app: Application = createApp();
-
-beforeAll(() => {
- createTables(testDb);
- runMigrations(testDb);
-});
-
-beforeEach(() => {
- resetTestDb(testDb);
- loginAttempts.clear();
- mfaAttempts.clear();
-});
-
-afterAll(() => {
- testDb.close();
- vi.unstubAllGlobals();
-});
-
-describe('Weather validation', () => {
- it('WEATHER-001 — GET /weather without lat/lng returns 400', async () => {
- const { user } = createUser(testDb);
-
- const res = await request(app)
- .get('/api/weather')
- .set('Cookie', authCookie(user.id));
- expect(res.status).toBe(400);
- });
-
- it('WEATHER-001 — GET /weather without lng returns 400', async () => {
- const { user } = createUser(testDb);
-
- const res = await request(app)
- .get('/api/weather?lat=48.8566')
- .set('Cookie', authCookie(user.id));
- expect(res.status).toBe(400);
- });
-
- it('WEATHER-005 — GET /weather/detailed without date returns 400', async () => {
- const { user } = createUser(testDb);
-
- const res = await request(app)
- .get('/api/weather/detailed?lat=48.8566&lng=2.3522')
- .set('Cookie', authCookie(user.id));
- expect(res.status).toBe(400);
- });
-
- it('WEATHER-001 — GET /weather without auth returns 401', async () => {
- const res = await request(app)
- .get('/api/weather?lat=48.8566&lng=2.3522');
- expect(res.status).toBe(401);
- });
-});
-
-describe('Weather with mocked API', () => {
- it('WEATHER-001 — GET /weather with lat/lng returns weather data', async () => {
- const { user } = createUser(testDb);
-
- const res = await request(app)
- .get('/api/weather?lat=48.8566&lng=2.3522')
- .set('Cookie', authCookie(user.id));
- expect(res.status).toBe(200);
- expect(res.body).toHaveProperty('temp');
- expect(res.body).toHaveProperty('main');
- });
-
- it('WEATHER-002 — GET /weather?date=future returns forecast data', async () => {
- const { user } = createUser(testDb);
- const futureDate = new Date();
- futureDate.setDate(futureDate.getDate() + 5);
- const dateStr = futureDate.toISOString().slice(0, 10);
-
- const res = await request(app)
- .get(`/api/weather?lat=48.8566&lng=2.3522&date=${dateStr}`)
- .set('Cookie', authCookie(user.id));
- expect(res.status).toBe(200);
- expect(res.body).toHaveProperty('temp');
- expect(res.body).toHaveProperty('type');
- });
-
- it('WEATHER-006 — GET /weather accepts lang parameter', async () => {
- const { user } = createUser(testDb);
-
- const res = await request(app)
- .get('/api/weather?lat=48.8566&lng=2.3522&lang=en')
- .set('Cookie', authCookie(user.id));
- expect(res.status).toBe(200);
- expect(res.body).toHaveProperty('temp');
- });
-
- it('WEATHER-007 — GET /weather returns 500 on non-ok API response (ApiError path)', async () => {
- const { user } = createUser(testDb);
- // Use unique coords to avoid cache from previous tests
- vi.mocked(global.fetch as any).mockResolvedValueOnce({
- ok: false,
- status: 503,
- json: () => Promise.resolve({ error: true, reason: 'Service unavailable' }),
- });
- const futureDate = new Date();
- futureDate.setDate(futureDate.getDate() + 3);
- const dateStr = futureDate.toISOString().slice(0, 10);
-
- const res = await request(app)
- .get(`/api/weather?lat=55.0&lng=25.0&date=${dateStr}`)
- .set('Cookie', authCookie(user.id));
- expect(res.status).toBe(503);
- expect(res.body).toHaveProperty('error');
- });
-
- it('WEATHER-008 — GET /weather returns 500 on network error (generic error path)', async () => {
- const { user } = createUser(testDb);
- vi.mocked(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
- const futureDate = new Date();
- futureDate.setDate(futureDate.getDate() + 4);
- const dateStr = futureDate.toISOString().slice(0, 10);
-
- const res = await request(app)
- .get(`/api/weather?lat=56.0&lng=26.0&date=${dateStr}`)
- .set('Cookie', authCookie(user.id));
- expect(res.status).toBe(500);
- expect(res.body).toHaveProperty('error');
- });
-
- it('WEATHER-009 — GET /weather/detailed returns detailed weather data', async () => {
- const { user } = createUser(testDb);
- const futureDate = new Date();
- futureDate.setDate(futureDate.getDate() + 2);
- const dateStr = futureDate.toISOString().slice(0, 10);
-
- // Override mock with full detailed forecast response
- vi.mocked(global.fetch as any).mockResolvedValueOnce({
- ok: true,
- json: () => Promise.resolve({
- daily: {
- time: [dateStr],
- temperature_2m_max: [24],
- temperature_2m_min: [16],
- weathercode: [1],
- precipitation_sum: [0],
- windspeed_10m_max: [12],
- sunrise: [`${dateStr}T06:00`],
- sunset: [`${dateStr}T21:00`],
- precipitation_probability_max: [10],
- },
- hourly: {
- time: [`${dateStr}T12:00`],
- temperature_2m: [20],
- precipitation_probability: [5],
- precipitation: [0],
- weathercode: [1],
- windspeed_10m: [10],
- relativehumidity_2m: [55],
- },
- }),
- });
-
- const res = await request(app)
- .get(`/api/weather/detailed?lat=50.0&lng=10.0&date=${dateStr}`)
- .set('Cookie', authCookie(user.id));
- expect(res.status).toBe(200);
- expect(res.body).toHaveProperty('temp');
- expect(res.body.type).toBe('forecast');
- });
-
- it('WEATHER-010 — GET /weather/detailed returns error status on ApiError', async () => {
- const { user } = createUser(testDb);
- vi.mocked(global.fetch as any).mockResolvedValueOnce({
- ok: false,
- status: 502,
- json: () => Promise.resolve({ error: true, reason: 'Bad Gateway' }),
- });
- const futureDate = new Date();
- futureDate.setDate(futureDate.getDate() + 6);
- const dateStr = futureDate.toISOString().slice(0, 10);
-
- const res = await request(app)
- .get(`/api/weather/detailed?lat=57.0&lng=27.0&date=${dateStr}`)
- .set('Cookie', authCookie(user.id));
- expect(res.status).toBe(502);
- expect(res.body).toHaveProperty('error');
- });
-
- it('WEATHER-011 — GET /weather/detailed returns 500 on network error', async () => {
- const { user } = createUser(testDb);
- vi.mocked(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
- const futureDate = new Date();
- futureDate.setDate(futureDate.getDate() + 7);
- const dateStr = futureDate.toISOString().slice(0, 10);
-
- const res = await request(app)
- .get(`/api/weather/detailed?lat=58.0&lng=28.0&date=${dateStr}`)
- .set('Cookie', authCookie(user.id));
- expect(res.status).toBe(500);
- expect(res.body).toHaveProperty('error');
- });
-});
diff --git a/server/tests/parity/parity.ts b/server/tests/parity/parity.ts
new file mode 100644
index 00000000..dd5dfa6a
--- /dev/null
+++ b/server/tests/parity/parity.ts
@@ -0,0 +1,39 @@
+import request from 'supertest';
+import { expect } from 'vitest';
+import type { Server } from 'http';
+
+export interface ParityRequest {
+ method?: 'get' | 'post' | 'put' | 'patch' | 'delete';
+ path: string;
+ query?: Record;
+ body?: unknown;
+}
+
+/**
+ * Reusable Nest-vs-Express parity harness.
+ *
+ * Fires the same HTTP request at the legacy Express app and the migrated Nest app
+ * and asserts the response is client-identical — same status code and same JSON
+ * body. With the underlying service mocked identically for both, any difference is
+ * purely framework-layer (routing, validation, error envelope), which is exactly
+ * what a migration must not change. Use one assertion per migrated route/case.
+ */
+export async function expectParity(
+ expressServer: Server | Express.Application,
+ nestServer: Server,
+ req: ParityRequest,
+): Promise {
+ const fire = (target: Server | Express.Application) => {
+ const method = req.method ?? 'get';
+ let r = request(target as never)[method](req.path);
+ if (req.query) r = r.query(req.query);
+ if (req.body !== undefined) r = r.send(req.body as object);
+ return r;
+ };
+
+ const [ex, ne] = await Promise.all([fire(expressServer), fire(nestServer)]);
+
+ const label = `${(req.method ?? 'GET').toUpperCase()} ${req.path}`;
+ expect(ne.status, `${label}: status mismatch`).toBe(ex.status);
+ expect(ne.body, `${label}: body mismatch`).toEqual(ex.body);
+}
diff --git a/server/tests/unit/nest/auth-guard.test.ts b/server/tests/unit/nest/auth-guard.test.ts
new file mode 100644
index 00000000..2a95aef8
--- /dev/null
+++ b/server/tests/unit/nest/auth-guard.test.ts
@@ -0,0 +1,26 @@
+import { describe, it, expect } from 'vitest';
+import { HttpException } from '@nestjs/common';
+import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard';
+
+function context(req: unknown) {
+ return { switchToHttp: () => ({ getRequest: () => req }) } as never;
+}
+
+describe('JwtAuthGuard', () => {
+ const guard = new JwtAuthGuard();
+
+ it('rejects with the legacy 401 { error, code } when no token is present', () => {
+ let thrown: unknown;
+ try {
+ guard.canActivate(context({ headers: {}, cookies: {} }));
+ } catch (e) {
+ thrown = e;
+ }
+ expect(thrown).toBeInstanceOf(HttpException);
+ expect((thrown as HttpException).getStatus()).toBe(401);
+ expect((thrown as HttpException).getResponse()).toEqual({
+ error: 'Access token required',
+ code: 'AUTH_REQUIRED',
+ });
+ });
+});
diff --git a/server/tests/unit/nest/database-service.test.ts b/server/tests/unit/nest/database-service.test.ts
new file mode 100644
index 00000000..fdfe5a15
--- /dev/null
+++ b/server/tests/unit/nest/database-service.test.ts
@@ -0,0 +1,36 @@
+/**
+ * DatabaseService — the shared better-sqlite3 provider (F3). Exercises every
+ * helper against the real connection so the typed query surface is covered.
+ */
+import { describe, it, expect } from 'vitest';
+import { DatabaseService } from '../../../src/nest/database/database.service';
+
+describe('DatabaseService (typed query helpers)', () => {
+ const svc = new DatabaseService();
+
+ it('exposes the shared connection', () => {
+ expect(typeof svc.connection.prepare).toBe('function');
+ });
+
+ it('prepare + get + all return rows from the live connection', () => {
+ expect(svc.prepare('SELECT 1 AS one').get()).toEqual({ one: 1 });
+ expect(svc.get('SELECT 2 AS two')).toEqual({ two: 2 });
+ expect(svc.all('SELECT 3 AS three')).toEqual([{ three: 3 }]);
+ });
+
+ it('run + transaction operate on a scratch table', () => {
+ svc.run('CREATE TEMP TABLE IF NOT EXISTS _dbsvc_test (n INTEGER)');
+ svc.run('DELETE FROM _dbsvc_test');
+
+ const info = svc.run('INSERT INTO _dbsvc_test (n) VALUES (?)', 41);
+ expect(info.changes).toBe(1);
+
+ const total = svc.transaction((conn) => {
+ conn.prepare('INSERT INTO _dbsvc_test (n) VALUES (?)').run(1);
+ return conn.prepare('SELECT SUM(n) AS s FROM _dbsvc_test').get() as { s: number };
+ });
+ expect(total.s).toBe(42);
+
+ svc.run('DROP TABLE _dbsvc_test');
+ });
+});
diff --git a/server/tests/unit/nest/exception-filter.test.ts b/server/tests/unit/nest/exception-filter.test.ts
new file mode 100644
index 00000000..264a24a1
--- /dev/null
+++ b/server/tests/unit/nest/exception-filter.test.ts
@@ -0,0 +1,34 @@
+import { describe, it, expect, vi } from 'vitest';
+import { HttpException } from '@nestjs/common';
+import { TrekExceptionFilter } from '../../../src/nest/common/trek-exception.filter';
+
+function mockHost() {
+ const res = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis() };
+ const host = { switchToHttp: () => ({ getResponse: () => res }) } as never;
+ return { res, host };
+}
+
+describe('TrekExceptionFilter', () => {
+ const filter = new TrekExceptionFilter();
+
+ it('passes through { error, code } bodies (auth guards) unchanged', () => {
+ const { res, host } = mockHost();
+ filter.catch(new HttpException({ error: 'Access token required', code: 'AUTH_REQUIRED' }, 401), host);
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(res.json).toHaveBeenCalledWith({ error: 'Access token required', code: 'AUTH_REQUIRED' });
+ });
+
+ it('normalises a string HttpException to { error }', () => {
+ const { res, host } = mockHost();
+ filter.catch(new HttpException('Bad thing', 400), host);
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith({ error: 'Bad thing' });
+ });
+
+ it('maps unknown errors to 500 { error: Internal server error }', () => {
+ const { res, host } = mockHost();
+ filter.catch(new Error('boom'), host);
+ expect(res.status).toHaveBeenCalledWith(500);
+ expect(res.json).toHaveBeenCalledWith({ error: 'Internal server error' });
+ });
+});
diff --git a/server/tests/unit/nest/health.di.test.ts b/server/tests/unit/nest/health.di.test.ts
new file mode 100644
index 00000000..33aec89a
--- /dev/null
+++ b/server/tests/unit/nest/health.di.test.ts
@@ -0,0 +1,25 @@
+import { describe, it, expect } from 'vitest';
+import { Test } from '@nestjs/testing';
+import { HealthController } from '../../../src/nest/health/health.controller';
+import { HealthService } from '../../../src/nest/health/health.service';
+import { DatabaseService } from '../../../src/nest/database/database.service';
+
+describe('Nest dependency injection (vitest + swc)', () => {
+ it('injects HealthService + DatabaseService into HealthController by type', async () => {
+ const moduleRef = await Test.createTestingModule({
+ controllers: [HealthController],
+ providers: [
+ HealthService,
+ { provide: DatabaseService, useValue: { get: () => ({ n: 7 }) } },
+ ],
+ }).compile();
+
+ const controller = moduleRef.get(HealthController);
+ expect(controller.getHealth()).toEqual({
+ ok: true,
+ runtime: 'nestjs',
+ diInjected: true,
+ userCount: 7,
+ });
+ });
+});
diff --git a/server/tests/unit/nest/strangler.test.ts b/server/tests/unit/nest/strangler.test.ts
new file mode 100644
index 00000000..89970059
--- /dev/null
+++ b/server/tests/unit/nest/strangler.test.ts
@@ -0,0 +1,33 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { getNestPrefixes, makeNestPathMatcher } from '../../../src/nest/strangler';
+
+describe('strangler toggle', () => {
+ const original = process.env.NEST_PREFIXES;
+ afterEach(() => {
+ if (original === undefined) delete process.env.NEST_PREFIXES;
+ else process.env.NEST_PREFIXES = original;
+ });
+
+ it('defaults to the migrated prefixes (/api/_nest + /api/weather) when NEST_PREFIXES is unset', () => {
+ delete process.env.NEST_PREFIXES;
+ expect(getNestPrefixes()).toEqual(['/api/_nest', '/api/weather']);
+ });
+
+ it('parses NEST_PREFIXES (comma-separated, trimmed)', () => {
+ process.env.NEST_PREFIXES = '/api/weather, /api/airports';
+ expect(getNestPrefixes()).toEqual(['/api/weather', '/api/airports']);
+ });
+
+ it('treats an empty NEST_PREFIXES as "all routes on legacy"', () => {
+ process.env.NEST_PREFIXES = '';
+ expect(getNestPrefixes()).toEqual([]);
+ });
+
+ it('matches exact prefixes and subpaths but not lookalikes', () => {
+ const match = makeNestPathMatcher(['/api/_nest']);
+ expect(match('/api/_nest')).toBe(true);
+ expect(match('/api/_nest/health')).toBe(true);
+ expect(match('/api/_nestxyz')).toBe(false);
+ expect(match('/api/health')).toBe(false);
+ });
+});
diff --git a/server/tests/unit/nest/weather.controller.test.ts b/server/tests/unit/nest/weather.controller.test.ts
new file mode 100644
index 00000000..d6dff1f4
--- /dev/null
+++ b/server/tests/unit/nest/weather.controller.test.ts
@@ -0,0 +1,93 @@
+import { describe, it, expect, vi } from 'vitest';
+import { HttpException } from '@nestjs/common';
+import { WeatherController } from '../../../src/nest/weather/weather.controller';
+import { ApiError } from '../../../src/services/weatherService';
+import type { WeatherService } from '../../../src/nest/weather/weather.service';
+
+function makeController(svc: Partial) {
+ return new WeatherController(svc as WeatherService);
+}
+
+/** Run `fn`, expecting it to throw an HttpException; return its { status, body }. */
+async function thrown(fn: () => Promise): Promise<{ status: number; body: unknown }> {
+ try {
+ await fn();
+ } catch (err) {
+ expect(err).toBeInstanceOf(HttpException);
+ const e = err as HttpException;
+ return { status: e.getStatus(), body: e.getResponse() };
+ }
+ throw new Error('expected the handler to throw');
+}
+
+describe('WeatherController (parity with the legacy /api/weather route)', () => {
+ const sample = { temp: 21, main: 'Clear', description: 'Klar', type: 'current' };
+
+ describe('GET /api/weather', () => {
+ it('400 { error } with the exact legacy message when lat/lng missing', async () => {
+ const c = makeController({ get: vi.fn() });
+ expect(await thrown(() => c.getWeather(undefined, '13.4'))).toEqual({
+ status: 400,
+ body: { error: 'Latitude and longitude are required' },
+ });
+ });
+
+ it('returns the service result and defaults lang to "de" when absent', async () => {
+ const get = vi.fn().mockResolvedValue(sample);
+ const c = makeController({ get });
+ const res = await c.getWeather('52.5', '13.4', undefined, undefined);
+ expect(res).toEqual(sample);
+ expect(get).toHaveBeenCalledWith('52.5', '13.4', undefined, 'de');
+ });
+
+ it('passes an explicit lang and date through unchanged', async () => {
+ const get = vi.fn().mockResolvedValue(sample);
+ const c = makeController({ get });
+ await c.getWeather('1', '2', '2026-07-01', 'en');
+ expect(get).toHaveBeenCalledWith('1', '2', '2026-07-01', 'en');
+ });
+
+ it('maps an ApiError to its status + { error: message }', async () => {
+ const c = makeController({ get: vi.fn().mockRejectedValue(new ApiError(404, 'Open-Meteo API error')) });
+ expect(await thrown(() => c.getWeather('1', '2'))).toEqual({
+ status: 404,
+ body: { error: 'Open-Meteo API error' },
+ });
+ });
+
+ it('maps an unexpected error to the exact legacy 500 body', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ const c = makeController({ get: vi.fn().mockRejectedValue(new Error('boom')) });
+ expect(await thrown(() => c.getWeather('1', '2'))).toEqual({
+ status: 500,
+ body: { error: 'Error fetching weather data' },
+ });
+ });
+ });
+
+ describe('GET /api/weather/detailed', () => {
+ it('400 { error } with the exact legacy message when date missing', async () => {
+ const c = makeController({ getDetailed: vi.fn() });
+ expect(await thrown(() => c.getDetailed('1', '2', undefined))).toEqual({
+ status: 400,
+ body: { error: 'Latitude, longitude, and date are required' },
+ });
+ });
+
+ it('returns the detailed result and defaults lang to "de"', async () => {
+ const getDetailed = vi.fn().mockResolvedValue(sample);
+ const c = makeController({ getDetailed });
+ await c.getDetailed('1', '2', '2026-07-01', undefined);
+ expect(getDetailed).toHaveBeenCalledWith('1', '2', '2026-07-01', 'de');
+ });
+
+ it('maps an unexpected error to the exact detailed 500 body', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ const c = makeController({ getDetailed: vi.fn().mockRejectedValue(new Error('boom')) });
+ expect(await thrown(() => c.getDetailed('1', '2', '2026-07-01'))).toEqual({
+ status: 500,
+ body: { error: 'Error fetching detailed weather data' },
+ });
+ });
+ });
+});
diff --git a/server/tests/unit/nest/wiring.test.ts b/server/tests/unit/nest/wiring.test.ts
new file mode 100644
index 00000000..0dc64fab
--- /dev/null
+++ b/server/tests/unit/nest/wiring.test.ts
@@ -0,0 +1,40 @@
+import { describe, it, expect } from 'vitest';
+import { HttpException } from '@nestjs/common';
+import { Test } from '@nestjs/testing';
+import { AppModule } from '../../../src/nest/app.module';
+import { HealthController } from '../../../src/nest/health/health.controller';
+import { DatabaseService } from '../../../src/nest/database/database.service';
+import { AdminGuard } from '../../../src/nest/auth/admin.guard';
+
+function ctx(user: unknown) {
+ return { switchToHttp: () => ({ getRequest: () => ({ user }) }) } as never;
+}
+
+describe('AppModule wiring', () => {
+ it('compiles with the global filter + DB provider and resolves the controller', async () => {
+ const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
+ .overrideProvider(DatabaseService)
+ .useValue({ get: () => ({ n: 0 }) })
+ .compile();
+ expect(moduleRef.get(HealthController)).toBeInstanceOf(HealthController);
+ });
+});
+
+describe('AdminGuard', () => {
+ const guard = new AdminGuard();
+ it('allows admins', () => {
+ expect(guard.canActivate(ctx({ role: 'admin' }))).toBe(true);
+ });
+ it('blocks non-admins and anonymous with 403 { error }', () => {
+ expect(() => guard.canActivate(ctx({ role: 'user' }))).toThrow(HttpException);
+ expect(() => guard.canActivate(ctx(undefined))).toThrow(HttpException);
+ });
+});
+
+describe('DatabaseService (shared connection)', () => {
+ it('runs real queries against the existing SQLite connection', () => {
+ const svc = new DatabaseService();
+ expect(svc.get('SELECT 1 AS one')).toEqual({ one: 1 });
+ expect(svc.all('SELECT 1 AS one')).toEqual([{ one: 1 }]);
+ });
+});
diff --git a/server/tests/unit/nest/zod-pipe.test.ts b/server/tests/unit/nest/zod-pipe.test.ts
new file mode 100644
index 00000000..b6b774a2
--- /dev/null
+++ b/server/tests/unit/nest/zod-pipe.test.ts
@@ -0,0 +1,25 @@
+import { describe, it, expect } from 'vitest';
+import { z } from 'zod';
+import { HttpException } from '@nestjs/common';
+import { ZodValidationPipe } from '../../../src/nest/common/zod-validation.pipe';
+
+describe('ZodValidationPipe', () => {
+ const pipe = new ZodValidationPipe(z.object({ name: z.string().min(1) }));
+ const meta = {} as never;
+
+ it('returns the parsed value for valid input', () => {
+ expect(pipe.transform({ name: 'x' }, meta)).toEqual({ name: 'x' });
+ });
+
+ it('throws TREK { error } envelope with status 400 on invalid input', () => {
+ let thrown: unknown;
+ try {
+ pipe.transform({ name: '' }, meta);
+ } catch (e) {
+ thrown = e;
+ }
+ expect(thrown).toBeInstanceOf(HttpException);
+ expect((thrown as HttpException).getStatus()).toBe(400);
+ expect((thrown as HttpException).getResponse()).toHaveProperty('error');
+ });
+});
diff --git a/server/tests/unit/shared-contract.test.ts b/server/tests/unit/shared-contract.test.ts
new file mode 100644
index 00000000..a8cff30a
--- /dev/null
+++ b/server/tests/unit/shared-contract.test.ts
@@ -0,0 +1,10 @@
+import { describe, it, expect } from 'vitest';
+// Smoke test: proves the server toolchain (tsx / vitest) resolves @trek/shared.
+import { idParamSchema, paginationQuerySchema } from '@trek/shared';
+
+describe('@trek/shared resolves in the server toolchain', () => {
+ it('imports and uses a shared schema', () => {
+ expect(idParamSchema.parse('7')).toBe(7);
+ expect(paginationQuerySchema.parse({})).toEqual({ page: 1, perPage: 50 });
+ });
+});
diff --git a/server/tsconfig.build.json b/server/tsconfig.build.json
new file mode 100644
index 00000000..52bfd263
--- /dev/null
+++ b/server/tsconfig.build.json
@@ -0,0 +1,12 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "noEmit": false,
+ "noEmitOnError": false,
+ "outDir": "./dist",
+ "sourceMap": false,
+ "declaration": false
+ },
+ "include": ["src"],
+ "exclude": ["node_modules", "dist", "tests", "**/*.spec.ts", "**/*.test.ts"]
+}
diff --git a/server/tsconfig.json b/server/tsconfig.json
index 360a4efc..39596505 100644
--- a/server/tsconfig.json
+++ b/server/tsconfig.json
@@ -3,6 +3,9 @@
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
+ "experimentalDecorators": true,
+ "emitDecoratorMetadata": true,
+ "baseUrl": ".",
"outDir": "./dist",
"rootDir": "./src",
"strict": false,
@@ -19,16 +22,18 @@
// (e.g. "./*": "./dist/esm/*") which TypeScript cannot resolve — it only strips .js suffixes.
// These paths manually redirect to the CJS dist until the SDK fixes its exports map.
"paths": {
- "@modelcontextprotocol/sdk/server/mcp": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/mcp"],
- "@modelcontextprotocol/sdk/server/streamableHttp": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/streamableHttp"],
- "@modelcontextprotocol/sdk/server/auth/router": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/router"],
- "@modelcontextprotocol/sdk/server/auth/handlers/authorize": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/handlers/authorize"],
- "@modelcontextprotocol/sdk/server/auth/handlers/register": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/handlers/register"],
- "@modelcontextprotocol/sdk/server/auth/provider": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/provider"],
- "@modelcontextprotocol/sdk/server/auth/clients": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/clients"],
- "@modelcontextprotocol/sdk/server/auth/errors": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/errors"],
- "@modelcontextprotocol/sdk/server/auth/types": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/types"],
- "@modelcontextprotocol/sdk/shared/auth": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/shared/auth"]
+ "@trek/shared": ["../shared/src/index.ts"],
+ "@trek/shared/*": ["../shared/src/*"],
+ "@modelcontextprotocol/sdk/server/mcp": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/mcp.js"],
+ "@modelcontextprotocol/sdk/server/streamableHttp": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/streamableHttp.js"],
+ "@modelcontextprotocol/sdk/server/auth/router": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/router.js"],
+ "@modelcontextprotocol/sdk/server/auth/handlers/authorize": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/handlers/authorize.js"],
+ "@modelcontextprotocol/sdk/server/auth/handlers/register": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/handlers/register.js"],
+ "@modelcontextprotocol/sdk/server/auth/provider": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/provider.js"],
+ "@modelcontextprotocol/sdk/server/auth/clients": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/clients.js"],
+ "@modelcontextprotocol/sdk/server/auth/errors": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/errors.js"],
+ "@modelcontextprotocol/sdk/server/auth/types": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/types.js"],
+ "@modelcontextprotocol/sdk/shared/auth": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/shared/auth.js"]
}
},
"include": ["src"],
diff --git a/server/vitest.config.ts b/server/vitest.config.ts
index f70827c4..51ca41c2 100644
--- a/server/vitest.config.ts
+++ b/server/vitest.config.ts
@@ -1,6 +1,18 @@
import { defineConfig } from 'vitest/config';
+import swc from 'unplugin-swc';
export default defineConfig({
+ // SWC transform so NestJS decorator metadata is emitted in tests
+ // (vitest's default esbuild does not emit it -> type-based DI would break).
+ plugins: [
+ swc.vite({
+ jsc: {
+ parser: { syntax: 'typescript', decorators: true },
+ transform: { legacyDecorator: true, decoratorMetadata: true },
+ keepClassNames: true,
+ },
+ }),
+ ],
test: {
root: '.',
include: ['tests/**/*.test.ts'],
@@ -16,10 +28,19 @@ export default defineConfig({
reporter: ['lcov', 'text'],
reportsDirectory: './coverage',
include: ['src/**/*.ts'],
+ // Coverage gate scoped to the new NestJS code only — the legacy codebase
+ // is intentionally ungated. Raised to the DoD's >=80% bar once the first
+ // module (weather) landed; ratchet further as more modules are migrated.
+ thresholds: {
+ 'src/nest/**/*.ts': { statements: 80, branches: 80, functions: 80, lines: 80 },
+ },
},
},
resolve: {
alias: {
+ // @trek/shared — Zod contract package (tests resolve it to TS source,
+ // mirroring the tsconfig `paths` the tsx runtime uses).
+ '@trek/shared': new URL('../shared/src/index.ts', import.meta.url).pathname,
'@modelcontextprotocol/sdk/server/mcp': new URL(
'./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/mcp.js',
import.meta.url
@@ -37,5 +58,12 @@ export default defineConfig({
import.meta.url
).pathname,
},
+ // The server build emits @trek/shared next to its source (shared/src/*.js,
+ // needed by the prod dist via tsc-alias). Vite's default extension order
+ // prefers .js over .ts, so after a build the tests would load that compiled
+ // CJS instead of the source — and its `require('zod')` is unresolvable from
+ // the shared/ dir on CI (only server deps are installed there). Resolve .ts
+ // first so tests always run the source, whose zod import resolves via Vite.
+ extensions: ['.ts', '.mts', '.mjs', '.js', '.cts', '.cjs', '.tsx', '.jsx', '.json'],
},
});
\ No newline at end of file
diff --git a/shared/README.md b/shared/README.md
new file mode 100644
index 00000000..dc8cde6a
--- /dev/null
+++ b/shared/README.md
@@ -0,0 +1,32 @@
+# @trek/shared
+
+Single source of truth for TREK's API contracts, expressed as [Zod](https://zod.dev) schemas
+and consumed by **both** the server (request validation + inferred DTO types) and the client
+(typed requests/responses).
+
+This package is part of the incremental NestJS + React 19 migration
+(see the "Brownfield Rewrite" board). It is intentionally **dormant** until modules start
+importing it — adding it changes nothing for users.
+
+## Rules
+
+- **One folder per domain**: `src//.schema.ts` (+ `.spec.ts`).
+- Domain-agnostic building blocks live in `src/common/`.
+- A route is only considered **migrated** once its contract lives here.
+- Schemas are the source of truth; server DTOs and client types are *inferred* from them
+ (`z.infer`), never hand-duplicated.
+
+## Consumption (dev)
+
+Both apps resolve `@trek/shared` to this package's TypeScript source:
+
+- **Server** (`tsx`): via `paths` in `server/tsconfig.json`.
+- **Client** (`vite`): via `resolve.alias` in `client/vite.config.ts` (+ `paths` for the type-checker).
+
+> Production packaging (Docker / workspace wiring) is introduced in card **F2**, when the
+> server first depends on this package at runtime. Until then prod builds are untouched.
+
+## Not yet here
+
+The canonical **error envelope** is finalised in card **F5** (it must match TREK's current
+Express error responses byte-for-byte), so it is deliberately not invented in F1.
diff --git a/shared/package-lock.json b/shared/package-lock.json
new file mode 100644
index 00000000..e41777db
--- /dev/null
+++ b/shared/package-lock.json
@@ -0,0 +1,1619 @@
+{
+ "name": "@trek/shared",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@trek/shared",
+ "version": "0.0.0",
+ "dependencies": {
+ "zod": "^4.3.6"
+ },
+ "devDependencies": {
+ "typescript": "^6.0.2",
+ "vitest": "^3.2.4"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
+ "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
+ "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
+ "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
+ "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
+ "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
+ "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
+ "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
+ "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
+ "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
+ "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
+ "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
+ "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
+ "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
+ "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
+ "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
+ "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
+ "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
+ "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
+ "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
+ "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
+ "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz",
+ "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz",
+ "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz",
+ "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz",
+ "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz",
+ "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz",
+ "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz",
+ "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz",
+ "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz",
+ "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz",
+ "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz",
+ "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz",
+ "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz",
+ "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz",
+ "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz",
+ "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz",
+ "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz",
+ "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz",
+ "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz",
+ "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz",
+ "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz",
+ "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz",
+ "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz",
+ "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz",
+ "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz",
+ "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitest/expect": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
+ "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "3.2.4",
+ "@vitest/utils": "3.2.4",
+ "chai": "^5.2.0",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
+ "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "3.2.4",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.17"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
+ "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
+ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "3.2.4",
+ "pathe": "^2.0.3",
+ "strip-literal": "^3.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
+ "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.4",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
+ "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^4.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
+ "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.4",
+ "loupe": "^3.1.4",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/chai": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
+ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^2.0.1",
+ "check-error": "^2.1.1",
+ "deep-eql": "^5.0.1",
+ "loupe": "^3.1.0",
+ "pathval": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/check-error": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
+ "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-eql": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
+ "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.7",
+ "@esbuild/android-arm": "0.27.7",
+ "@esbuild/android-arm64": "0.27.7",
+ "@esbuild/android-x64": "0.27.7",
+ "@esbuild/darwin-arm64": "0.27.7",
+ "@esbuild/darwin-x64": "0.27.7",
+ "@esbuild/freebsd-arm64": "0.27.7",
+ "@esbuild/freebsd-x64": "0.27.7",
+ "@esbuild/linux-arm": "0.27.7",
+ "@esbuild/linux-arm64": "0.27.7",
+ "@esbuild/linux-ia32": "0.27.7",
+ "@esbuild/linux-loong64": "0.27.7",
+ "@esbuild/linux-mips64el": "0.27.7",
+ "@esbuild/linux-ppc64": "0.27.7",
+ "@esbuild/linux-riscv64": "0.27.7",
+ "@esbuild/linux-s390x": "0.27.7",
+ "@esbuild/linux-x64": "0.27.7",
+ "@esbuild/netbsd-arm64": "0.27.7",
+ "@esbuild/netbsd-x64": "0.27.7",
+ "@esbuild/openbsd-arm64": "0.27.7",
+ "@esbuild/openbsd-x64": "0.27.7",
+ "@esbuild/openharmony-arm64": "0.27.7",
+ "@esbuild/sunos-x64": "0.27.7",
+ "@esbuild/win32-arm64": "0.27.7",
+ "@esbuild/win32-ia32": "0.27.7",
+ "@esbuild/win32-x64": "0.27.7"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/loupe": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
+ "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathval": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.16"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.15",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
+ "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
+ "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.60.4",
+ "@rollup/rollup-android-arm64": "4.60.4",
+ "@rollup/rollup-darwin-arm64": "4.60.4",
+ "@rollup/rollup-darwin-x64": "4.60.4",
+ "@rollup/rollup-freebsd-arm64": "4.60.4",
+ "@rollup/rollup-freebsd-x64": "4.60.4",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.4",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.4",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.4",
+ "@rollup/rollup-linux-arm64-musl": "4.60.4",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.4",
+ "@rollup/rollup-linux-loong64-musl": "4.60.4",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.4",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.4",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.4",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.4",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.4",
+ "@rollup/rollup-linux-x64-gnu": "4.60.4",
+ "@rollup/rollup-linux-x64-musl": "4.60.4",
+ "@rollup/rollup-openbsd-x64": "4.60.4",
+ "@rollup/rollup-openharmony-arm64": "4.60.4",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.4",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.4",
+ "@rollup/rollup-win32-x64-gnu": "4.60.4",
+ "@rollup/rollup-win32-x64-msvc": "4.60.4",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/rollup/node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/strip-literal": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
+ "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^9.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.16",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinypool": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+ "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+ "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
+ "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/vite": {
+ "version": "7.3.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz",
+ "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-node": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
+ "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.4.1",
+ "es-module-lexer": "^1.7.0",
+ "pathe": "^2.0.3",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
+ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/expect": "3.2.4",
+ "@vitest/mocker": "3.2.4",
+ "@vitest/pretty-format": "^3.2.4",
+ "@vitest/runner": "3.2.4",
+ "@vitest/snapshot": "3.2.4",
+ "@vitest/spy": "3.2.4",
+ "@vitest/utils": "3.2.4",
+ "chai": "^5.2.0",
+ "debug": "^4.4.1",
+ "expect-type": "^1.2.1",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.2",
+ "std-env": "^3.9.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.2",
+ "tinyglobby": "^0.2.14",
+ "tinypool": "^1.1.1",
+ "tinyrainbow": "^2.0.0",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
+ "vite-node": "3.2.4",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/debug": "^4.1.12",
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "@vitest/browser": "3.2.4",
+ "@vitest/ui": "3.2.4",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/debug": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/shared/package.json b/shared/package.json
new file mode 100644
index 00000000..9670bb5f
--- /dev/null
+++ b/shared/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "@trek/shared",
+ "version": "0.0.0",
+ "private": true,
+ "description": "Shared API contracts (Zod schemas) — single source of truth for TREK server and client.",
+ "type": "module",
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "exports": {
+ ".": "./src/index.ts",
+ "./*": "./src/*.ts"
+ },
+ "scripts": {
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "zod": "^4.3.6"
+ },
+ "devDependencies": {
+ "typescript": "^6.0.2",
+ "vitest": "^3.2.4"
+ }
+}
diff --git a/shared/src/common/pagination.schema.ts b/shared/src/common/pagination.schema.ts
new file mode 100644
index 00000000..014bae4b
--- /dev/null
+++ b/shared/src/common/pagination.schema.ts
@@ -0,0 +1,12 @@
+import { z } from 'zod';
+
+/**
+ * Generic pagination query helper. Individual endpoints opt in by extending
+ * this; it is NOT applied globally (many TREK list endpoints return full sets).
+ * Defaults are conservative and only used where a route already paginates.
+ */
+export const paginationQuerySchema = z.object({
+ page: z.coerce.number().int().min(1).default(1),
+ perPage: z.coerce.number().int().min(1).max(200).default(50),
+});
+export type PaginationQuery = z.infer;
diff --git a/shared/src/common/primitives.schema.spec.ts b/shared/src/common/primitives.schema.spec.ts
new file mode 100644
index 00000000..fb819ddf
--- /dev/null
+++ b/shared/src/common/primitives.schema.spec.ts
@@ -0,0 +1,39 @@
+import { describe, it, expect } from 'vitest';
+import { idSchema, idParamSchema, nonEmptyString, isoDateTime } from './primitives.schema';
+import { paginationQuerySchema } from './pagination.schema';
+
+describe('@trek/shared primitives', () => {
+ it('idSchema accepts positive integers, rejects others', () => {
+ expect(idSchema.parse(1)).toBe(1);
+ expect(idSchema.safeParse(0).success).toBe(false);
+ expect(idSchema.safeParse(-3).success).toBe(false);
+ expect(idSchema.safeParse(1.5).success).toBe(false);
+ });
+
+ it('idParamSchema coerces string params to a positive int', () => {
+ expect(idParamSchema.parse('42')).toBe(42);
+ expect(idParamSchema.safeParse('abc').success).toBe(false);
+ });
+
+ it('nonEmptyString trims and rejects empty', () => {
+ expect(nonEmptyString.parse(' hi ')).toBe('hi');
+ expect(nonEmptyString.safeParse(' ').success).toBe(false);
+ });
+
+ it('isoDateTime accepts an ISO timestamp', () => {
+ expect(isoDateTime.safeParse('2026-05-25T08:38:14Z').success).toBe(true);
+ expect(isoDateTime.safeParse('not-a-date').success).toBe(false);
+ });
+});
+
+describe('@trek/shared pagination', () => {
+ it('applies defaults and coerces', () => {
+ expect(paginationQuerySchema.parse({})).toEqual({ page: 1, perPage: 50 });
+ expect(paginationQuerySchema.parse({ page: '2', perPage: '10' })).toEqual({ page: 2, perPage: 10 });
+ });
+
+ it('enforces bounds', () => {
+ expect(paginationQuerySchema.safeParse({ perPage: 0 }).success).toBe(false);
+ expect(paginationQuerySchema.safeParse({ perPage: 999 }).success).toBe(false);
+ });
+});
diff --git a/shared/src/common/primitives.schema.ts b/shared/src/common/primitives.schema.ts
new file mode 100644
index 00000000..6fddfd29
--- /dev/null
+++ b/shared/src/common/primitives.schema.ts
@@ -0,0 +1,22 @@
+import { z } from 'zod';
+
+/**
+ * Primitive, domain-agnostic building blocks shared by every contract.
+ * Domain schemas (trips, places, ...) live in their own folders and reuse these.
+ */
+
+/** TREK uses auto-increment integer primary keys. */
+export const idSchema = z.number().int().positive();
+export type Id = z.infer;
+
+/**
+ * Numeric id coming from a URL param / query string. Express hands these over
+ * as strings, so we coerce, then enforce a positive integer.
+ */
+export const idParamSchema = z.coerce.number().int().positive();
+
+/** Non-empty, trimmed string. */
+export const nonEmptyString = z.string().trim().min(1);
+
+/** ISO-8601 timestamp string (the shape TREK serialises dates as in JSON). */
+export const isoDateTime = z.string().datetime({ offset: true });
diff --git a/shared/src/index.ts b/shared/src/index.ts
new file mode 100644
index 00000000..9769fe55
--- /dev/null
+++ b/shared/src/index.ts
@@ -0,0 +1,15 @@
+/**
+ * @trek/shared — single source of truth for TREK's API contracts.
+ *
+ * Zod schemas defined here are consumed by BOTH the server (validation +
+ * inferred DTO types) and the client (typed requests/responses). A route is
+ * only considered "migrated" once its contract lives in this package.
+ *
+ * Layout: one folder per domain (e.g. src/trip/trip.schema.ts), plus the
+ * domain-agnostic primitives below. See the board card "Module blueprint".
+ */
+export * from './common/primitives.schema';
+export * from './common/pagination.schema';
+
+// Domain contracts
+export * from './weather/weather.schema';
diff --git a/shared/src/weather/weather.schema.spec.ts b/shared/src/weather/weather.schema.spec.ts
new file mode 100644
index 00000000..77aad873
--- /dev/null
+++ b/shared/src/weather/weather.schema.spec.ts
@@ -0,0 +1,53 @@
+import { describe, it, expect } from 'vitest';
+import {
+ weatherQuerySchema,
+ detailedWeatherQuerySchema,
+ weatherResultSchema,
+} from './weather.schema';
+
+describe('weatherQuerySchema', () => {
+ it('accepts lat/lng and defaults lang to "de"', () => {
+ const parsed = weatherQuerySchema.parse({ lat: '52.5', lng: '13.4' });
+ expect(parsed).toEqual({ lat: '52.5', lng: '13.4', lang: 'de' });
+ });
+
+ it('keeps an explicit lang and optional date', () => {
+ const parsed = weatherQuerySchema.parse({ lat: '1', lng: '2', date: '2026-07-01', lang: 'en' });
+ expect(parsed.lang).toBe('en');
+ expect(parsed.date).toBe('2026-07-01');
+ });
+
+ it('rejects missing lat/lng', () => {
+ expect(weatherQuerySchema.safeParse({ lng: '13.4' }).success).toBe(false);
+ expect(weatherQuerySchema.safeParse({ lat: '', lng: '13.4' }).success).toBe(false);
+ });
+});
+
+describe('detailedWeatherQuerySchema', () => {
+ it('requires a date', () => {
+ expect(detailedWeatherQuerySchema.safeParse({ lat: '1', lng: '2' }).success).toBe(false);
+ expect(detailedWeatherQuerySchema.safeParse({ lat: '1', lng: '2', date: '2026-07-01' }).success).toBe(true);
+ });
+});
+
+describe('weatherResultSchema', () => {
+ it('accepts a minimal current-weather result', () => {
+ const r = weatherResultSchema.parse({ temp: 21, main: 'Clear', description: 'Klar', type: 'current' });
+ expect(r.temp).toBe(21);
+ });
+
+ it('accepts a detailed result with hourly entries and a no_forecast error', () => {
+ expect(
+ weatherResultSchema.safeParse({
+ temp: 0, main: '', description: '', type: '', error: 'no_forecast',
+ }).success,
+ ).toBe(true);
+ expect(
+ weatherResultSchema.safeParse({
+ temp: 18, main: 'Rain', description: 'Regen', type: 'forecast',
+ sunrise: '05:30', sunset: '21:10', precipitation_sum: 2.4,
+ hourly: [{ hour: 9, temp: 17, precipitation: 0.1, precipitation_probability: 20, main: 'Clouds', wind: 12, humidity: 80 }],
+ }).success,
+ ).toBe(true);
+ });
+});
diff --git a/shared/src/weather/weather.schema.ts b/shared/src/weather/weather.schema.ts
new file mode 100644
index 00000000..9a79738a
--- /dev/null
+++ b/shared/src/weather/weather.schema.ts
@@ -0,0 +1,60 @@
+import { z } from 'zod';
+
+/**
+ * Weather API contract — single source of truth for the /api/weather endpoints.
+ *
+ * The legacy Express routes treat lat/lng as opaque strings (they are parsed with
+ * parseFloat inside the service) and only check for presence, so the query schemas
+ * mirror that: non-empty strings, not coerced numbers. `lang` defaults to 'de',
+ * matching the Express default.
+ *
+ * The bespoke "X is required" 400 messages are reproduced in the controller, not
+ * derived from these schemas, so the error body stays byte-identical to Express.
+ */
+
+export const weatherQuerySchema = z.object({
+ lat: z.string().min(1),
+ lng: z.string().min(1),
+ date: z.string().min(1).optional(),
+ lang: z.string().min(1).default('de'),
+});
+export type WeatherQuery = z.infer;
+
+/** Detailed weather requires a date (the Express route 400s without it). */
+export const detailedWeatherQuerySchema = weatherQuerySchema.extend({
+ date: z.string().min(1),
+});
+export type DetailedWeatherQuery = z.infer;
+
+export const hourlyEntrySchema = z.object({
+ hour: z.number(),
+ temp: z.number(),
+ precipitation: z.number(),
+ precipitation_probability: z.number(),
+ main: z.string(),
+ wind: z.number(),
+ humidity: z.number(),
+});
+export type HourlyEntry = z.infer;
+
+/**
+ * Weather response DTO. Fields are optional because the Express service emits
+ * different subsets depending on the request type (current / forecast / climate /
+ * detailed) and on error (`{ ..., error: 'no_forecast' }`).
+ */
+export const weatherResultSchema = z.object({
+ temp: z.number(),
+ temp_max: z.number().optional(),
+ temp_min: z.number().optional(),
+ main: z.string(),
+ description: z.string(),
+ type: z.string(),
+ sunrise: z.string().nullable().optional(),
+ sunset: z.string().nullable().optional(),
+ precipitation_sum: z.number().optional(),
+ precipitation_probability_max: z.number().optional(),
+ wind_max: z.number().optional(),
+ hourly: z.array(hourlyEntrySchema).optional(),
+ error: z.string().optional(),
+});
+export type WeatherResult = z.infer;
diff --git a/shared/tsconfig.json b/shared/tsconfig.json
new file mode 100644
index 00000000..f01ea3b7
--- /dev/null
+++ b/shared/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["ES2022"],
+ "declaration": true,
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "noEmit": true
+ },
+ "include": ["src"]
+}
diff --git a/wiki/Journey-Journal.md b/wiki/Journey-Journal.md
index 5dd39145..ceea0ca7 100644
--- a/wiki/Journey-Journal.md
+++ b/wiki/Journey-Journal.md
@@ -37,6 +37,7 @@ Each entry corresponds to a day in your journey. The entry editor provides:
- **Weather** — choose one of six values: Sunny, Partly cloudy, Cloudy, Rainy, Stormy, Cold.
- **Photos** — attach photos to the entry. The first photo becomes the card thumbnail in list views.
+ > **Note on HEIC files:** HEIC is an Apple-only format that many browsers and platforms do not recognise as an image. To ensure broad compatibility, HEIC/HEIF files are automatically converted to JPEG before upload. This conversion may result in the loss of embedded metadata (EXIF data such as GPS coordinates, camera information, etc.).
- **Pros / Cons** — optional verdict cards. Add items to a **Pros** list (thumbs-up) or a **Cons** list (thumbs-down) to summarise what you loved or what could have been better. These are stored in the `pros_cons.pros` and `pros_cons.cons` arrays on the entry.
- **Tags** — free-form labels (e.g. "hidden gem", "best meal").
- **Location** — pin the entry to a map location.
diff --git a/wiki/MCP-Overview.md b/wiki/MCP-Overview.md
index 2f3c2026..79922452 100644
--- a/wiki/MCP-Overview.md
+++ b/wiki/MCP-Overview.md
@@ -18,6 +18,16 @@ Once connected, an AI assistant can work with your TREK data in a single convers
Changes made through MCP are broadcast to all connected clients in real-time — exactly like changes made in the web UI.
+## Authentication options
+
+| Use case | Method |
+|---|---|
+| Interactive client (Claude.ai, Cursor, VS Code…) | OAuth 2.1 with browser consent — TREK issues tokens after you approve scopes in a consent screen |
+| AI agent or script running unattended | Machine client (client_credentials) — token obtained directly via `client_id` + `client_secret`, no browser ever opened |
+| Legacy setups | Static API token — deprecated, full access, no scopes |
+
+See [MCP-Setup](MCP-Setup) for step-by-step instructions for each method.
+
## Requirements
- **MCP addon enabled** — an administrator must enable the MCP addon (`mcp`) from the Admin Panel before the `/mcp` endpoint becomes available and the MCP section appears in user settings.
diff --git a/wiki/MCP-Setup.md b/wiki/MCP-Setup.md
index 40b1e9d7..c26540ac 100644
--- a/wiki/MCP-Setup.md
+++ b/wiki/MCP-Setup.md
@@ -1,6 +1,6 @@
# MCP Setup
-This page explains how to connect an AI assistant to your TREK instance. TREK supports two authentication methods: OAuth 2.1 (recommended) and static API tokens (deprecated).
+This page explains how to connect an AI assistant to your TREK instance. TREK supports three authentication methods: OAuth 2.1 with browser consent (recommended for interactive clients), machine clients with no browser login (recommended for AI agents and scripts), and static API tokens (deprecated).
@@ -23,25 +23,12 @@ Claude.ai (web) supports native MCP connections — no JSON config file required
### Claude Desktop
-Claude Desktop connects via `mcp-remote`. After creating an OAuth client using the **Claude Desktop** preset (redirect URI: `http://localhost`), add the following to your Claude Desktop config:
+Claude Desktop supports native MCP connections — no JSON config file required:
-```json
-{
- "mcpServers": {
- "trek": {
- "command": "npx",
- "args": [
- "mcp-remote",
- "https:///mcp",
- "--static-oauth-client-info",
- "{\"client_id\": \"\", \"client_secret\": \"\"}"
- ]
- }
- }
-}
-```
-
-When the client starts it opens your browser to the TREK consent screen to complete the OAuth flow.
+1. In TREK, go to **Settings → Integrations → MCP → OAuth Clients** and click **Create**.
+2. Select the **Claude Desktop** preset. This fills in the redirect URI and a default scope set.
+3. Give the client a name, adjust scopes if needed, and save. Copy the client ID and client secret — the secret is shown only once.
+4. In Claude Desktop, open Settings → MCP and add a new server using your TREK URL (`https:///mcp`). Claude Desktop will open your browser to complete the OAuth consent flow.
### Cursor, VS Code, Windsurf, and Zed
@@ -99,9 +86,34 @@ Create a client in TREK using the appropriate preset (Cursor, VS Code, Windsurf,
Each user can have up to **10 OAuth clients**.
-## Option B: Static API token (deprecated)
+## Option B: Machine client — no browser login (for AI agents and scripts)
-> **Deprecated:** Static tokens will stop working in a future version of TREK. Migrate to OAuth 2.1.
+Use this when your AI agent or automation script needs to authenticate silently without any browser interaction. Instead of going through an OAuth consent flow, the client exchanges a `client_id` and `client_secret` directly for an access token ([RFC 6749 §4.4 — Client Credentials grant](https://datatracker.ietf.org/doc/html/rfc6749#section-4.4)).
+
+**Why this exists:** browser-based OAuth flows break when an AI agent runs unattended. The agent may fire multiple concurrent token refreshes, causing replay detection to invalidate the session and open browser windows. Machine clients sidestep this entirely — there is no refresh token and no rotation race.
+
+**How it works:** the token acts as its owner (the user who created the client), scoped to the permissions chosen at creation. All TREK permission checks still apply — the AI agent can only access what you can access, narrowed further to the selected scopes.
+
+### Create a machine client
+
+1. Go to **Settings → Integrations → MCP → OAuth Clients** and click **New Client**.
+2. Tick **Machine client (no browser login)**. The redirect URI field disappears — machine clients don't need one.
+3. Give it a name, select scopes, and click **Register Client**.
+4. Copy the `client_id` and `client_secret` shown — the secret is displayed only once.
+
+### How token management works
+
+Your AI client uses the `client_id` and `client_secret` to request a token directly from TREK (`POST /oauth/token` with `grant_type=client_credentials`). Tokens are valid for 1 hour. When one expires, the client requests a new one silently — no browser window, no user action, no consent screen. This is handled entirely by the client.
+
+### Who should use this
+
+Machine clients are designed for **AI agent frameworks and custom MCP client implementations** that can call the token endpoint themselves and handle renewal programmatically. TREK advertises `client_credentials` in its OAuth discovery document (`/.well-known/oauth-authorization-server`), so any compliant client can discover and use it automatically.
+
+> **`mcp-remote` users:** `mcp-remote` implements the browser-based `authorization_code` flow only — it does not support `client_credentials`. If you use `mcp-remote`, stick with Option A and use the preset for your client. The machine client option is not applicable.
+
+## Option C: Static API token (deprecated)
+
+> **Deprecated:** Static tokens will stop working in a future version of TREK. Migrate to OAuth 2.1 or machine clients.
Static tokens grant full access to all tools and resources with no scope restrictions. Sessions using a static token will receive deprecation warnings in the AI client on every tool call.
@@ -129,11 +141,12 @@ Each user can create up to **10 static tokens**.
## Authentication reference
-| Method | Token prefix | Access level | Expiry |
-|---|---|---|---|
-| OAuth 2.1 access token | `trekoa_` | Scoped (per-consent) | 1 hour; auto-refreshed via 30-day rolling refresh token (`trekrf_`) |
-| OAuth client secret | `trekcs_` | Used during OAuth registration | No expiry (revoke via UI) |
-| Static API token | `trek_` | Full access | No expiry — **deprecated** |
+| Method | Grant | Token prefix | Access level | Expiry |
+|---|---|---|---|---|
+| OAuth 2.1 — browser consent | `authorization_code` | `trekoa_` | Scoped (per-consent) | 1 hour; auto-refreshed via 30-day rolling refresh token (`trekrf_`) |
+| Machine client — no browser | `client_credentials` | `trekoa_` | Scoped (per-client), acts as owner | 1 hour; re-request silently, no refresh token |
+| OAuth client secret | — | `trekcs_` | Used to authenticate the client at the token endpoint | No expiry (revoke via UI) |
+| Static API token | — | `trek_` | Full access | No expiry — **deprecated** |
## Related
diff --git a/wiki/Photo-Providers.md b/wiki/Photo-Providers.md
index d6754e00..5be8d8b5 100644
--- a/wiki/Photo-Providers.md
+++ b/wiki/Photo-Providers.md
@@ -44,6 +44,7 @@ When generating the API key in Immich (**Account Settings → API Keys**), grant
| `asset.read` | Read photo metadata and search results |
| `asset.view` | Load thumbnails and preview images |
| `album.read` | List owned + shared albums and their contents |
+| `asset.download` | Download the assets |
| `asset.upload` | *Only if you enable "Mirror journey photos to Immich on upload"* — push TREK uploads back to your library |
TREK never modifies or deletes anything in Immich, so no `update`, `delete`, or admin scopes are needed.
@@ -94,4 +95,4 @@ Once a provider is connected, you can browse and attach photos to your trips. Se
## See also
- [Admin-Addons](Admin-Addons)
-- [Internal-Network-Access](Internal-Network-Access)
\ No newline at end of file
+- [Internal-Network-Access](Internal-Network-Access)
diff --git a/wiki/Places-and-Search.md b/wiki/Places-and-Search.md
index efc49453..8a336294 100644
--- a/wiki/Places-and-Search.md
+++ b/wiki/Places-and-Search.md
@@ -21,6 +21,8 @@ Type in the search box at the top of the form. After 2 or more characters, with
When a key is present, the autocomplete uses the Google Places API, which can return ratings, opening hours, photos, and phone numbers from Google's database.
+> **API key restrictions:** TREK calls the Google Places API from the server, not the browser. If you apply **HTTP referrers** restrictions to your key in Google Cloud Console, you must also set `APP_URL` in your environment — TREK sends it as the `Referer` header on every outbound Google API request. Without it, Google will reject all server-side calls with `REQUEST_DENIED`. For server-side deployments, **IP address** restrictions are simpler and require no extra configuration. See [Troubleshooting](Troubleshooting) if photos are missing after adding a key.
+
### Without a Google Maps API key
TREK falls back to OpenStreetMap (Nominatim) automatically — no API key needed. A notice appears above the search box explaining that OpenStreetMap is in use and that photos, ratings, and opening hours are unavailable. Results include name, address, and coordinates.
diff --git a/wiki/Troubleshooting.md b/wiki/Troubleshooting.md
index 7b7a6d03..dae924c8 100644
--- a/wiki/Troubleshooting.md
+++ b/wiki/Troubleshooting.md
@@ -223,6 +223,45 @@ If `ALLOWED_ORIGINS` is not set, TREK allows all origins (development default).
---
+## Place photos not loading / place thumbnail shows default map pin (Google Maps API key configured)
+
+**Cause:** When a Google Maps API key is set, TREK fetches photo references and image bytes from the Google Places API on the server side. If the server-side call is rejected or returns no photos, the `/place-photo/:id` endpoint returns 404 and the place falls back to the default map-pin thumbnail. The most common causes are:
+
+1. **HTTP referrer restriction on the API key.** Google Cloud Console lets you restrict a key to specific HTTP referrers. Because TREK calls Google from the server (not the browser), it sends a `Referer` header derived from `APP_URL`. If `APP_URL` is not set, the fallback is `http://localhost:`, which will not match any domain whitelist in GCP.
+
+2. **Wrong key restriction type.** API keys restricted by **HTTP referrers** are designed for browser-side JavaScript. For a self-hosted server application, use **IP address** restrictions instead — add the public IP of your TREK server and no `APP_URL` configuration is needed.
+
+3. **Places API (New) not enabled.** The key must have **Places API (New)** enabled in Google Cloud Console under APIs & Services → Enabled APIs. Enabling only the legacy Places API is not sufficient.
+
+4. **Billing not set up.** Google requires a billing account to be linked to the project even within the free tier. Without it, photo and details requests return `REQUEST_DENIED`.
+
+**Fix for HTTP referrer restriction:**
+
+Set `APP_URL` to the public URL of your instance and add that URL (or its domain with a wildcard, e.g. `https://trek.example.com/*`) to the allowed referrers in GCP:
+
+```yaml
+environment:
+ - APP_URL=https://trek.example.com
+```
+
+**Fix for wrong restriction type:**
+
+Switch the key's "Application restrictions" from **HTTP referrers** to **IP addresses** in Google Cloud Console, and add your server's public IP. No `APP_URL` change needed.
+
+**Verifying the issue:**
+
+Run the following curl command using your key to check whether Google returns photo references:
+
+```bash
+curl "https://places.googleapis.com/v1/places/" \
+ -H "X-Goog-Api-Key: YOUR_API_KEY" \
+ -H "X-Goog-FieldMask: photos"
+```
+
+If the response is `{}` or `{"error": {...}}`, the key or its restrictions are blocking the request. If it returns a `photos` array, the key is valid and the issue is elsewhere.
+
+---
+
## MCP OAuth flow does not initiate / "Connect" redirects but authentication never starts
**Cause:** TREK builds the OAuth 2.1 redirect URI from `APP_URL`. If `APP_URL` is not set, the authorization URL is constructed from a localhost fallback that external clients (Claude.ai, Claude Desktop) cannot reach, so the OAuth handshake never completes.