Release 3.1.0 (#1185)

* Phase 0 — NestJS + Zod foundation harness (F1–F8) (#1050)

Co-hosted NestJS app behind the existing Express server via a strangler-fig dispatcher, sharing the same better-sqlite3 connection and JWT httpOnly cookie. Additive and dormant: default routing stays on Express, Nest only serves its own /api/_nest diagnostics until a module opts in.

F1 @trek/shared Zod contract package; F2 Nest bootstrap co-hosted (fall-through, single Dockerfile/port); F3 shared better-sqlite3 provider; F4 JWT cookie auth guard (+ @CurrentUser, admin guard); F5 Zod validation pipe + error-envelope parity; F6 Nest test + coverage gates; F7 per-prefix strangler toggle (env, default Express); F8 CI build/typecheck/test/coverage.

Remaining F4/F6/F8 checklist items (trip-access + permission levels + MFA policy, e2e harness/seed + 80% gate, Nest↔Express parity test, Playwright PR-comment workflow) are tracked on the first consuming module cards (L1/A1/C1).

* feat(weather): migrate /api/weather to the NestJS pilot module (L1) (#1053)

First strangler migration (L1): /api/weather is served by a NestJS module.

- @trek/shared/weather Zod contract; Nest controller byte-identical to the legacy Express route (paths, query params, status codes, { error } bodies, lang default, ApiError/500 passthrough). Service reuses getWeather/getDetailedWeather (+ shared cache; MCP tools unchanged).
- Strangler routes /api/weather to Nest by default; the legacy Express route + its migration-time parity test were decommissioned in this PR.
- Frontend (FE2): weatherApi typed against the @trek/shared WeatherResult contract.
- Harness: reusable Nest-vs-Express parity harness, e2e harness (temp SQLite + seed/cookie helpers, real JwtAuthGuard), src/nest coverage gate raised to >=80%, src/nest test guide.
- Verified end-to-end on a prod mirror (dev1): 401/400/200 via Nest with real Open-Meteo data, Express route gone.

* fix(packing): multiply item weight by quantity in bag/total weight calcs (#898)

Quantity now counts toward bag and total weights. Generalised to an itemWeight() helper used by every weight sum (bag totals + max, unassigned, grand total; sidebar + bag modal) with unit tests.

* feat(i18n): add Korean (ko) translation (#977)

Korean translation by @ppuassi, topped up to full en.ts key parity. Language registration follows separately.

* feat(i18n): add Japanese (ja) translation (#829)

Japanese translation by @soma3978, at full en.ts key parity, registered in supportedLanguages + TranslationContext.

* Add Turkish (tr) translation + language registry (#1029)

Turkish translation by @SkyLostTR, at full en.ts key parity, registered in supportedLanguages + TranslationContext.

* i18n: register Korean + add Ukrainian translation (#1055)

Korean translation by @ppuassi (#977) — now registered. Ukrainian by @JeffyOLOLO (#902) — lifted onto a clean branch. Both at full en.ts key parity (2258 keys).

* chore: fix monorepo build pipeline and migrate shared to built package (#1056)

* chore: fix monorepo build pipeline and migrate shared to built package

- Root package.json: add workspace scripts (dev, build, test, test:cov, test:e2e)
  that delegate to actual scripts in shared/server/client workspaces
- shared: add tsup build step (CJS + ESM dual output, .d.ts); consumers now import
  from the built dist instead of raw TS source via path aliases
- server: replace tsc-alias with tsconfig-paths (tsc-alias mangled node_modules
  paths); fix MCP SDK path aliases to point to root node_modules (../node_modules)
- server/scripts/dev.mjs: delay node --watch until tsc -w signals first-pass done,
  eliminating the spurious restart on every dev startup
- client/vite.config.js + vitest.config.ts: remove @trek/shared path alias (no longer
  needed now that shared is a proper package)
- Consolidate package-lock.json at the workspace root; drop per-workspace lock files

* chore: fix test script to reflect root package.json

* chore: add missing lint and prettier script in root package.json

* fix(ci): build shared before tests; fix vitest MCP SDK alias paths

vitest.config.ts aliases pointed at ./node_modules/ (server-local) but
packages are hoisted to the root node_modules/ in the npm workspace —
changed to ../node_modules/.

CI jobs now install and build shared before running server/client tests
so that @trek/shared's dist/ exists when vitest resolves the package.

* fix(docker): update Dockerfile and CI for monorepo workspace structure

Dockerfile:
- Add shared-builder stage that produces @trek/shared dist before
  client and server stages need it
- Each build stage carries root package.json + package-lock.json so npm
  can resolve @trek/shared as a workspace dependency
- Production stage installs via workspace context (npm ci --workspace=server
  --omit=dev) so node_modules/@trek/shared symlinks to shared/dist correctly
- Copy server/tsconfig.json into the image so tsconfig-paths/register can
  find the MCP SDK path aliases at runtime
- CMD cds into /app/server before starting node so tsconfig-paths baseUrl
  resolves and ../node_modules points to /app/node_modules
- Remove mkdir for /app/server (now a real dir); keep symlinks for uploads/data

docker.yml version-bump:
- Replace manual per-workspace cd+npm-version calls with single:
  npm version --workspaces --include-workspace-root --no-git-tag-version
  (mirrors the version:* scripts in root package.json)
- git add now references root package-lock.json; adds shared/package.json

.dockerignore: add shared/dist
package.json: fix version:prerelease preid (alpha → pre)

* fix(tests): use in-memory SQLite per worker in test mode

vitest pool:forks spawns parallel worker processes that all called
initDb() on the same data/travel.db, causing SQLite "database is locked"
and "duplicate column name" races.

When NODE_ENV=test each fork now gets an isolated :memory: DB so migrations
run independently with no file contention.

* chore(ci): add ACT guards to skip DockerHub steps in local act runs

act sets ACT=true automatically. Guards added:
- docker login: if: ${{ !env.ACT }}
- build outputs: type=docker (local load) when ACT, push-by-digest when CI
- digest export/upload: if: ${{ !env.ACT }}
- merge job: if: ${{ !env.ACT }}
- release-helm job (docker.yml): if: ${{ !env.ACT }}
- version-bump git push (docker.yml): wrapped in [ -z "$ACT" ] shell guard

Run locally with:
  ./bin/act -j build -W .github/workflows/docker.yml \
    -P ubuntu-latest=catthehacker/ubuntu:act-latest

* fix(ci): move ACT guards to step level; add guards to security.yml

env context is invalid in job-level if conditions — moved all ACT
guards down to individual steps. Also guards docker login + scout
in security.yml so act can run the build-only part of that workflow.

* fix(ci): skip git fetch and tag logic in act (no remote access in local containers)

* Revert "fix(ci): skip git fetch and tag logic in act (no remote access in local containers)"

This reverts commit 67cf290cda.

* Revert "fix(ci): move ACT guards to step level; add guards to security.yml"

This reverts commit f92b95e054.

* Revert "chore(ci): add ACT guards to skip DockerHub steps in local act runs"

This reverts commit 797183de08.

* fix(docker): add musl optional deps so alpine builds find native rollup/sharp binaries

npm prunes libc-constrained optional deps to the host libc (glibc) when
generating the lockfile, leaving no musl entry for Alpine containers.
Declaring the x64/arm64 musl variants as explicit root optionalDependencies
forces them into the lockfile so npm ci on Alpine can install them.

Covers shared-builder (tsup/rollup) and client-builder (vite/rollup + sharp
icon generation) for both linux/amd64 and linux/arm64 CI targets.

* fix(docker): copy client dist into server/public so the server resolves static files correctly

The server runs from /app/server and serves static files relative to that
directory, so the client build output must land at /app/server/public, not /app/public.

* feat(planner): real road routes (OSRM) with travel-time connectors (#1060)

* feat(planner): real road routes (OSRM) with travel-time connectors

Replace the straight-line "as the crow flies" route with real OSRM road
geometry (FOSSGIS routed-car/-foot) and an Apple-Maps style render
(blue casing under a lighter core) on both the Leaflet and Mapbox GL
maps. Routes are off by default and toggled per session, with a
driving/walking mode switch in the day footer.

Each day shows per-segment travel time/distance connectors between
places, computed from the OSRM legs and split at transport bookings.

Also redesigns the day header for visual consistency: vertical
number+weather capsule, name with a divider before the date, subtle
hotel/rental pills that stay on one line, and a hover-revealed 2x2
action square (edit / add transport / add note / collapse). Drops the
Google Maps button.

* test(planner): update route hook tests for calculateRouteWithLegs

* remove route_calculation setting, always use OSRM routing (#1064)

The per-user route_calculation toggle was a second, hidden on/off layer
on top of the day footer's show-route button, and made it easy to end up
with straight-line routes for no obvious reason. Drop the setting
entirely: routing is always on, the footer toggle stays the single
switch. Old stored values are simply ignored (settings are key-value, no
migration needed).

* chore: move i18n to shared package (#1066)

* chore: move i18n to shared package

* chore: move server translations to shared package and apply linter and prettier on entire shared package

* feat(dashboard): upcoming reservations endpoint + travel-stats country/distance

Adds GET /api/reservations/upcoming for the dashboard widget, switches travel-stats to the same country source as Atlas (manual + place-derived, ISO codes), and a distance service for flown km.

* i18n(dashboard): dashboard keys across locales

* feat(dashboard): boarding-pass hero, atlas row, live widgets + modal portal fix

Reworked dashboard layout: boarding-pass hero with hover + days-left countdown, atlas stats row with real flags, searchable currency widget, editable timezone widget, new-trip FAB. Modals now portal to document.body to avoid inheriting dashboard-scoped button/font styles.

* i18n(dashboard): sync all locales to one key set + German copy-dialog strings

Brings every locale's dashboard namespace to the same 149-key set (missing keys backfilled from English) and translates the previously English-only copy-trip dialog into German.

* refactor(dashboard): replace hardcoded strings with i18n keys

Hero, atlas row, trip cards, filters, currency and timezone widgets now resolve all visible copy through t() instead of hardcoded English/German.

* feat(i18n): add Greek translation (#1061)

* i18n: complete Turkish (tr) translation (#1075)

Fill in the remaining ~2100 UI strings in shared/src/i18n/tr so Turkish
matches the English catalog. Brand names, URLs, and technical placeholders
are left untranslated by design.

* chore: prettier + lint

* chore: enforce prettier & lint on shared package

* feat: Updated border of map markers to reflect category color. (#1062)

* feat(dashboard): mobile layout, glass UI, context bottom nav + OIDC PKCE (#1079)

* feat(dashboard): mobile layout, glass tiles, plain-text countdown, place photos

- Rework the mobile dashboard: cover hero, separate boarding-pass card,
  trimmed atlas (trips + days only), stacked widgets
- New floating bottom tab bar with a centred context-aware + button
  (new trip / place / journey / entry depending on the page)
- Move profile + notifications into a small top strip on the dashboard
- Desktop: glassmorphic tiles (light + dark), neutral dark palette,
  plain-text countdown module, real place photos in the boarding pass

* i18n(dashboard): translate new dashboard keys across all locales

Fill the dashboard-rework keys (hero, atlas, fx, tz, upcoming, copy
dialog, aria labels, countdown) that were left as English placeholders,
plus the new startsIn/aria keys, for all 19 languages.

* feat(oidc): send PKCE (S256) in the OIDC login flow

The OIDC client now generates a code_verifier per login, sends the
S256 code_challenge on the authorize request and the code_verifier on
the token exchange. Works whether the provider has PKCE optional or
required (fixes login against providers that require PKCE, e.g. Pocket ID).

* Migrate TREK 3 to NestJS + React 19 (shared Zod contracts) (#1087)

* Migrate TREK 3 to NestJS + React 19 with a shared Zod contract layer

Brownfield strangler migration of the backend onto NestJS modules
(auth, trips, days, places, assignments, packing, todo, budget,
reservations, collab, files, photos, journey, share, settings, backup,
oidc, oauth, admin, atlas, vacay, weather, airports, maps, categories,
tags, notifications, system-notices) served through a per-prefix
dispatcher, keeping the existing SQLite/better-sqlite3 DB and JWT
httpOnly cookie auth, with behavioural parity for every route.

Client: React 19 upgrade, "page = wiring container + data hook"
pattern across all pages, per-domain Zustand stores bound to
@trek/shared contracts, and decomposition of the large components
(DayPlanSidebar, PackingListPanel, CollabNotes, FileManager,
MemoriesPanel, PlacesSidebar, CollabChat, SystemNoticeModal,
BudgetPanel, PlaceFormModal, ...) into focused render units backed by
in-file hooks.

Apply the shared global request pipeline (helmet/CSP, CORS, HSTS,
forced HTTPS, the global MFA policy and request logging) to the NestJS
instance as well, so a migrated route is protected identically to the
legacy fallback rather than bypassing it.

* Finish the NestJS migration — drop the legacy Express app

NestJS now serves the whole surface: every /api domain plus the platform
routes (uploads, /mcp, the OAuth/MCP SDK + /.well-known metadata and the
production SPA fallback). Removed server/src/app.ts, all of
server/src/routes/* and the strangler dispatcher; index.ts and the
integration suite share a single buildApp() bootstrap so prod and tests
can't drift.

- Platform/transport routes extracted to nest/platform/platform.routes.ts
  and mounted before app.init() — Nest's router answers an unmatched
  request with a 404, so a route registered after init is never reached.
  The SPA fallback is a NotFoundException filter and the catch-all uses a
  RegExp (Express 5's path-to-regexp rejects a bare '*').
- New modules: memories (/api/integrations/memories — the Journey
  gallery's Immich/Synology proxy), addons (GET /api/addons) and the
  cross-trip GET /api/reservations/upcoming.
- TrekExceptionFilter reproduces the old multer / err.statusCode handling
  so upload rejections keep their 400/413 { error } body and non-ASCII
  filenames survive (defParamCharset).
- addTripToJourney and the MCP get_journey_share_link tool gained the
  trip-access check they were missing.
- Re-pointed the 34 integration tests + the websocket test onto the Nest
  app; removed the now-meaningless Express-vs-Nest parity tests and a few
  orphaned client components.

* Restore the reset-password rate limit and fix copyTrip reservation links

Two correctness/security gaps the NestJS migration introduced:

- POST /api/auth/reset-password lost its per-IP rate limiter. Restore it
  (5 attempts / 15 min on a dedicated bucket, same as the old resetLimiter)
  so reset tokens can't be brute-forced unthrottled. Covered by AUTH-019.
- copyTripById did not copy reservations.end_day_id (a day reference — now
  remapped through dayMap like day_id) or needs_review, so a duplicated trip
  lost multi-day transport end-day links and reset the review flag.

* Clean up dead code, dedupe helpers, fix the reset-password contract

- Remove server exports orphaned by the Express removal: the immich
  album-link helpers, seven route-only service exports, getFileByIdFull;
  de-export internal-only helpers (utcSuffix).
- De-duplicate verifyTripAccess (9 identical copies -> services/tripAccess.ts)
  and avatarUrl (3 -> services/avatarUrl.ts); name the bcrypt cost
  (BCRYPT_COST) and the email regex (EMAIL_REGEX). Public API unchanged.
- resetPasswordRequestSchema declared `password`, but the client sends and
  the service reads `new_password` — rename it so the contract matches and
  the client types resolve.
- Make ATLAS-013 deterministic: stub the admin-1 GeoJSON download instead of
  fetching ~4600 features from GitHub during the test (it hung the suite).

* Make the client typecheck runnable (vitest/vite ambient types)

The client had no `typecheck` script and tsc couldn't even start (the
baseUrl deprecation errored out, same as server/shared already silence).
Add `ignoreDeprecations: "6.0"` to match the other workspaces, a `typecheck`
npm script, and a src/vite-env.d.ts referencing vite/client + vitest/globals
so tsc knows the test globals (describe/it/expect/vi). This turns ~3600
phantom "Cannot find name" errors into a real, measurable count (~590 actual
type errors remain, to be worked down). Type-only; no runtime change.

* Derive client domain types from the shared schema contracts

Add entity/response Zod schemas to @trek/shared (place, trip, assignment, day, budget, packing, reservation), each matched against the producing server service, and re-export them from client types.ts instead of the hand-written duplicates that had drifted (name/title, amount/total_price, owner_id/user_id, cover_url/cover_image, ...). Updates the call sites and test fixtures the corrected types surfaced; type-only, no runtime behaviour change.

* chore(db): log swallowed errors in addon-disable migration + guard against destructive migrations

The migration that disables the legacy "memories" addon swallowed any
error in an empty catch, as did ~30 other catch blocks in the migration
runner (column adds, the journey rebuild, index probes). Replace each
silent catch with the existing console.warn('[migrations] ...') log so
failures are visible. Control flow is unchanged: every step stays
non-fatal, nothing new is thrown.

Add a static guardrail test that scans the migration source and fails
when a new destructive statement (DROP TABLE / DROP COLUMN / TRUNCATE /
DELETE FROM / ALTER ... DROP) appears outside a reviewed allowlist, and
when an empty/silent catch block is reintroduced. The existing
destructive statements are all legitimate table rebuilds or
bounded cleanups and are recorded in the allowlist with a reason.

* Re-check SSRF on every redirect hop when resolving short links

Replace the one-shot checkSsrf + fetch(redirect:'follow') in the maps and place short-link resolvers with safeFetchFollow, which follows redirects manually and re-runs checkSsrf against the DNS-pinned IP of each hop (max 5). A redirect to an internal/loopback address is now blocked even when the initial URL is public, while legitimate cross-host redirects (goo.gl -> maps.google.com) still resolve.

* Reject WebSocket tokens minted before a password change

Stamp the user's password_version onto the ephemeral ws token and verify it on connect, closing the socket (4001) when it no longer matches, so a token issued before a password reset can't be replayed. Tokens minted without a version are treated as version 0, matching the JWT pv-claim semantics.

* fix(i18n): guard locale key parity and finish the OAuth consent page strings

Every non-en locale now exposes the exact same flat key set as en. Keys that
had drifted out of sync are backfilled with the English source value (tagged
en-fallback) so t() resolves a real string instead of relying on the silent
runtime fallback; no existing translation was touched and no key was removed.

Add a parity test that imports each aggregated locale bundle and asserts its
key set matches en, with a diagnostic listing of any missing/extra keys. This
complements the file-level check in shared/scripts by guarding the merged
export the app actually serves.

Finish internationalising OAuthAuthorizePage: the ~15 remaining hardcoded
English chrome strings now go through oauth.authorize.* keys (English source
in en, en-fallback placeholders elsewhere). Markup and behaviour are unchanged.

* Add semantic theme color tokens to Tailwind

Map the CSS theme variables from src/index.css (:root light / .dark dark) to named Tailwind utilities — bg-surface, text-content, border-edge, bg-accent and their variants. This gives components a Tailwind-native target for the theme colors so we can replace inline `style={{ ... 'var(--...)' }}` with utility classes without changing the rendered values.

* Surface silent store failures to the user and validate API responses in dev

Reservation toggle, todo/packing toggle and budget reorder were swallowing API errors after rolling back, so the user saw the change silently snap back with no explanation. Route those failures through the existing toast channel (new store/notify.ts bridges to window.__addToast, the same channel SystemNoticeBanner uses); the reservation toggle re-throws so ReservationsPanel's own translated toast finally fires. Also wire the existing parseInDev/checkInDev response validation into the maps and notification-test endpoints to catch contract drift in dev.

* Migrate static theme inline styles to Tailwind utilities and extract page sub-components

Replace the static, color-only inline `style={{ ... 'var(--bg-primary)' ... }}` props with the new semantic Tailwind utilities (bg-surface, text-content, border-edge, ...) wherever the result is byte-identical; dynamic/conditional theme styles and hardcoded status colors are left inline. Extract the Atlas country-search autocomplete, the Admin update banner, and two Journey dialogs into their own presentational components to shrink the oversized page files, keeping behaviour and markup identical.

* Remove the unrouted photos page and its dead photo components

PhotosPage was never wired into the router and its usePhotos hook read a tripStore photos slice that was never implemented; the Photos gallery, lightbox and upload components were only reachable through it. Per-trip photos now live in the Journey gallery (Immich/Synology). Removed the dead page, hook and components — the live Journey PhotoLightbox is a separate component and stays.

* Resolve the remaining client type errors and the trip.title navbar bug

Drive the client typecheck to zero without any/ts-ignore: convert the tripId route param to a number once at the page boundary so it matches the numeric props and store actions it feeds, fix trip.name -> trip.title (the wire field is title, so the old read rendered blank in the files/offline views), and tighten the scattered handler-arity, DOM-cast and untyped-payload sites. No runtime behaviour change.

* Convert the remaining dynamic and hardcoded inline styles to Tailwind utilities

Second styling pass over the components and pages: move conditional theme colors into className ternaries (bg-accent / bg-surface-hover etc.), turn reused CSSProperties constants into className constants, and express static hardcoded hex/rgba colors as Tailwind arbitrary values so the exact rendered colour is preserved. Truly dynamic styling (computed geometry, gradients, multi-part shadows, data-driven colours, the undefined --sidebar/--nav layout vars) stays inline as it cannot be expressed as a static class. Updated three component tests that asserted the old inline active-state styles to assert the equivalent utility class instead.

Verified: client typecheck 0, full client suite green, and a live light/dark render check in the dev server confirms the semantic theme tokens resolve correctly (the earlier 'transparent popups' were a stale dev server that pre-dated the tailwind.config token addition, not a code issue).

* Add eslint flat-config for client and server and gate typecheck, lint and pages in CI

client and server had lint scripts but no eslint config (only shared was linted in CI). Add flat configs mirroring shared's stack (js + typescript-eslint recommended + eslint-config-prettier) plus the client's react-hooks/react-refresh plugins. Pre-existing patterns in this never-linted code (explicit any, require() in the CommonJS server, empty catches, exhaustive-deps) are set to 'warn' rather than 'error' so the gate passes at 0 errors without a repo-wide reformat — these can be ratcheted to errors over time. Wire blocking typecheck + lint + lint:pages steps into the client and server CI jobs (now that both typechecks are clean) and promote the server typecheck from informational to blocking.

* Decompose the remaining God Components into hooks, helpers and sub-components

FE6: split the oversized page and panel components into thin layout shells plus colocated use<Component> hooks, .constants.ts, .helpers.ts (with tests) and presentational sub-components, following the established 'logic in a hook, render in slices' pattern. Behaviour, markup, classes and effect order are unchanged. Largest reductions: PackingListPanel 1598->42, FileManager 1055->36, AdminPage 1525->167, BudgetPanel 1266->146, JourneyDetailPage 2822->547, PlacesSidebar 945->66, CollabChat 861->106, CollabNotes 1417->532. DayPlanSidebar's drag-and-drop render body was left intact (ref-identity sensitive) and only its toolbar/modals/constants were extracted.

* Fix duplicate React keys in the file-assign place list

When a place is assigned to the same day more than once it appeared twice in a day's list, so the place-button key={p.id} collided and React warned about duplicate keys. Key by place id + render index so siblings stay unique. Pre-existing in the old FileManager; behaviour unchanged.

* Format the shared package and drop an unused import to satisfy the lint gate

The i18n and schema changes added code that wasn't prettier-formatted, and place.schema.ts imported categorySchema without using it. Run prettier over shared and remove the import so 'npm run lint' + 'format:check' pass.

* Install all workspaces in the server CI job so SWC's native binary is present

The server vitest config transforms via unplugin-swc, which needs @swc/core's platform-specific native binary. A workspace-scoped 'npm ci --workspace server' skips that optional dependency, so vitest failed to load the config on the Linux runner. Use a full 'npm ci'.

* Re-resolve dependencies with npm install in the server CI job for SWC

Full 'npm ci' still skipped @swc/core's Linux native binary because the committed lockfile was generated on Windows and lacks the Linux optional-dep install metadata. 'npm install' re-resolves and fetches the platform-matching binary, which the server's unplugin-swc transform needs to load vitest.config.ts.

* Install @swc/core's Linux binary explicitly in the server CI job

Neither npm ci nor npm install fetched @swc/core-linux-x64-gnu on the Linux runner because the lockfile was generated on Windows and lacks the Linux optional-dep metadata. Add a step that installs the matching @swc/core-linux-x64-gnu version (no-save, no-lockfile) so unplugin-swc can load the server's vitest config.

* Use legacy-peer-deps when installing the SWC Linux binary in CI

The explicit @swc/core-linux-x64-gnu install re-resolved the tree and hit the pre-existing lucide-react/react-19 peer conflict that the lockfile was generated around. Add --legacy-peer-deps so the step matches the project's resolution and installs the binary.

* Keep the lockfile when installing the SWC binary so other deps stay pinned

Dropping --no-package-lock made npm re-resolve the whole tree and upgrade eslint, whose newer recommended config flagged no-useless-assignment as an error in the server lint step. Keep the lockfile so only @swc/core-linux-x64-gnu is added and every other dependency (incl. eslint) stays at its locked version.

* Fix a batch of reported bugs: Atlas regions, planner overlays, imports, Safari modals (#1094)

* Start the Journey date picker week on Monday (#1078)

The Journey entry date picker started the week on Sunday (firstDow = getDay(), headers Su-first) while every other picker (CustomDateTimePicker, VacayCalendar) starts on Monday. Align it: Monday-first leading offset ((getDay()+6)%7) and Mo-first weekday headers.

* Fix Taiwan resolving to CN-TW in the Atlas country search (#1049)

natural-earth gives Taiwan ISO_A2='CN-TW' (a subdivision-style value) with ADM0_A3='TWN'. The dynamic A2_TO_A3 augmentation added 'CN-TW'->'TWN', which then overwrote the legitimate TWN->TW entry in the reverse map, so Taiwan's country option resolved to 'CN-TW' — unresolvable by Intl.DisplayNames (no name, broken flag, not searchable). Only augment A2_TO_A3 with real 2-letter codes.

* Drop empty leftover dateless days when a trip gets a shorter dated range (#1083)

generateDays kept all unused dateless placeholder days after switching to an explicit (shorter) date range, so day_count (COUNT(*) FROM days) stayed inflated. Delete the empty leftovers (no assignments/notes/accommodations) like the dateless path already does, while preserving any that still hold content. Adds TRIP-SVC-017.

* Render GPX and route overlays once the Mapbox style has loaded (#1036)

The GPX and route geojson effects ran before the map 'load' event had
attached their sources, so on the first paint they hit the early return
and never re-ran. Add mapReady to their dependencies so they fire again
the moment the sources exist.

* Convert HEIC trip and journey covers to JPEG before upload (#1085)

HEIC/HEIF covers coming straight off an iPhone could not be rendered in
the preview or stored as a usable image. Route both cover pickers through
normalizeImageFile, the same conversion the journal entry editor already
uses, so the file becomes a JPEG before it leaves the browser.

* Name GPX routes and tracks after their source file so multiple imports stick (#1054)

Unnamed routes and tracks all fell back to the same generic 'GPX Route' /
'GPX Track' label, so the name-based import dedup dropped every one after
the first - importing several files (or one file with several tracks) only
kept a single place. Derive the default name from the source filename with
an index suffix when a file holds more than one geometry, thread the
filename down through the controller, and let the import modal take more
than one file at a time. Adds PLACE-SVC-037/038.

* Namespace the modal backdrop class so content blockers stop hiding it (#1027)

Generic class names like .modal-backdrop sit on the cosmetic filter lists
that content blockers (1Blocker, EasyList Annoyances) ship, and get hidden
with display:none. The shared Modal - used by New Trip and Add Place -
carried that class, so Safari users running such a blocker saw the modal
silently fail to open with no error and no network request. Rename it to
.trek-modal-backdrop.

* Highlight GB regions by resolving England/Scotland/Wales/NI to finer admin-1 codes (#1067)

A zoom-8 reverse geocode of a UK place only resolves to the constituent
country (GB-ENG/SCT/WLS/NIR), but Natural Earth's admin-1 polygons for GB
are counties and boroughs (GB-LND, GB-MAN, GB-CON, ...). Those four codes
match no polygon, so places in England never highlighted in the Atlas
while CH/IT/NL/etc. worked. When a GB lookup lands on a constituent
country, re-resolve it at a finer zoom where Nominatim exposes the
county/borough code the polygons actually carry. Other countries keep the
exact zoom-8 behaviour. Adds ATLAS-UNIT-021.

* Surface the real place-search error instead of a generic toast (#1092)

When a place search or detail lookup fails, the backend already forwards the
upstream reason - including descriptive Google Places API messages such as
'Places API (New) has not been used in project ... or it is disabled'. The
planner discarded it and always showed 'Place search failed', so a key that
is mis-enabled, unbilled, or pointed at the legacy API instead of Places API
(New) looked like an unexplained silent failure. Show the server-provided
message when present, and stop the Atlas bucket-list search from swallowing
its error without a trace.

* Await the async cover normalization in the TripFormModal paste test (#1085)

handleCoverSelect now normalizes the pasted file before previewing it, so
URL.createObjectURL is called a microtask later. The assertion moves into
waitFor; a non-HEIC file still passes through unchanged.

* fix(pwa): removed orientation from the manifest (#1058)

* fix(journey): raise PhotoLightbox z-index above MobileEntryView (#1101)

* feat(transport): add bus, taxi, bicycle, ferry and other transport types (#1105)

Closes #718. Adds five new transport reservation types alongside the
existing flight/train/car/cruise: bus, taxi, bicycle, ferry and a generic
'transport_other' catch-all. The new types are treated as first-class
transports everywhere — the transport modal, day plan, route calculation,
map overlays, file grouping and the PDF export — and are translated across
all 20 locales.

A dedicated 'transport_other' value is used for the catch-all so existing
'other' bookings are not reclassified as transport.

* feat(reservations): native booking-confirmation import via KDE KItinerary (#1102)

* feat(reservations): native booking-confirmation import via KDE KItinerary

Adds a two-step preview → confirm flow for importing booking emails,
PDFs, PKPass and HTML confirmations. The server invokes the KDE
kitinerary-extractor binary, maps JSON-LD schema.org output to TREK
reservation shapes, and persists via the existing createReservation
pipeline (accommodations, budget, places, WebSocket broadcasts).

- NestJS BookingImportModule: preview + confirm endpoints under
  /api/trips/:tripId/reservations/import/booking{,/confirm}
- KitineraryExtractorService: spawns the binary, filters stderr noise,
  handles QDateTime (@value) timezone-aware datetimes
- kitinerary-mapper: FlightReservation, TrainReservation, BusReservation,
  BoatReservation, LodgingReservation, FoodEstablishmentReservation,
  RentalCarReservation, EventReservation → typed preview items
- BookingImportService: auto-creates place rows; geocodes venues without
  coordinates via Nominatim (name+address → address → name fallback);
  resolves day IDs for accommodation linking
- BookingImportModal: drag-and-drop multi-file upload, preview cards
  with type icons, per-item exclude toggle, confirm step
- Shared Zod contracts: BookingImportPreviewItem, PreviewResponse,
  ConfirmRequest, ConfirmResponse — consumed by controller, service,
  API client and modal
- Dockerfile: node:24-trixie-slim runtime; amd64 downloads KDE static
  binary + locales; arm64 installs libkitinerary-bin + symlinks to
  fixed path; ENV KITINERARY_EXTRACTOR_PATH set for both arches
- /api/health/features exposes { bookingImport: boolean } so the UI
  hides the Import button when the binary is absent
- i18n keys (English), wiki docs, API.md, README one-liner

* i18n: add booking import translations for all 19 non-English locales

Adds 17 reservations.import.* keys and undo.importBooking to ar, br, cs,
de, es, fr, gr, hu, id, it, ja, ko, nl, pl, ru, tr, uk, zh, zh-TW.

* chore: enforce i18n parity

* docs(wiki): add KItinerary local setup instructions to dev environment guide

* feat(costs): rework Budget into Costs — Splitwise-style, multi-currency, mobile (#1106)

* fix(journey): authorize reads of the journey share link

GET /api/journeys/:id/share-link now requires journey access (canAccessJourney),
matching the create/delete share-link routes and the get_journey_share_link MCP
tool. Returns no link when the caller lacks access to the journey.

* feat(costs): rework Budget into Costs — Splitwise-style, multi-currency, mobile

Renames the Budget addon to "Costs" (UI only) and reworks it into a Tricount/
Splitwise-style cost tracker: multiple payers per expense, equal split across
chosen members, settle-up with persisted history + undo, 12 fixed categories,
per-expense currency with live FX conversion to a user-set display currency
(Settings -> Display), and locale-correct money formatting. Adds a desktop and a
dedicated mobile layout. A migration backfills existing budget items (single
payer, split members, currency). Closes #551 (per-expense currency).

Also switches the app font to self-hosted Poppins (Geist for secondary subtext),
replacing the Google Fonts CDN dependency.

* fix(costs): neutral dashboard dark palette + liquid glass, full page width, entry-count badge

- Dark mode used a warm oklch palette that read brownish; switch to the
  neutral zinc tokens used by the dashboard (#121215 bg, #f4f4f5 ink) and add a
  subtle backdrop-blur glass on cards.
- Costs now uses the full available page width on desktop instead of a 1280px cap.
- Render the expense count next to the Expenses title as a badge.
- Adapt budget/journey unit tests to the new payer-based settlement model and the
  Costs rename (category default 'other', Costs tab/CostsPanel).

* fix(costs): drop the entry-count badge, always show row edit/delete actions

Removes the count badge next to the Expenses title and makes the per-row
edit/delete actions permanently visible (no longer hover-only) on desktop too.

* feat(costs): currency-native money formatting, custom select/date, rename addon to Costs

- Format every amount in its own currency convention (symbol position, grouping
  and decimal separators) regardless of app language, via a currency->locale map
  (EUR -> '12,00 €', USD -> '$12.00', JPY -> '¥12', ...). Previously Intl used the
  app locale, so EUR showed the symbol in front under an English UI.
- Use TREK's CustomSelect (searchable, with symbols) and CustomDatePicker in the
  add/edit expense modal instead of the native <select>/<input type=date>.
- Rename the 'Budget Planner' add-on to 'Costs' in the admin list (display only;
  id/tables/permissions/MCP stay 'budget') via seed + a migration for existing DBs.

* feat(auth): configurable session duration via SESSION_DURATION

Adds a SESSION_DURATION env var (ms-style strings: 1h, 7d, 30d, ...) controlling
how long a session stays valid before re-login. It drives both the trek_session
JWT exp claim and the cookie maxAge from one source, so they never drift. Invalid
values warn at startup and fall back to the default (24h — unchanged). The MFA
challenge token and MCP OAuth tokens keep their own TTL.

Implements the request from discussion #946. Documented in the env-var wiki page,
.env.example and docker-compose.yml.

* feat: Passkey (WebAuthn) login (#1111)

* feat(auth): passkey (WebAuthn) login — server endpoints, schema + admin toggle

Add @simplewebauthn/server registration and primary (discoverable) login ceremonies under /api/auth/passkey, a webauthn_credentials + single-use webauthn_challenges schema (migration), the instance-wide passkey_login toggle (default off) enforced before auth by a guard, and require_mfa satisfaction via a verified passkey. RP ID/origin come only from server config (webauthn_rp_id/origins -> APP_URL), never request headers.

* feat(auth): passkey enrolment, login button + admin settings UI

PasskeysSection in account settings (add/rename/remove with a current-password step-up), a 'Sign in with a passkey' button on the login page, the admin enable + RP-ID/origins controls, and a per-user admin reset action.

* i18n(auth): passkey strings across all locales

Add login/settings/admin passkey keys to en and all 19 translated locales.

* chore: update kitinerary version

* Backend/frontend hardening & consistency cleanups (#1113)

* refactor(auth): session token validation and password-change consistency

* refactor(journey): entry field allow-list and public share-link consistency

* refactor(mcp): align tool authorization with the REST permission checks

* chore: input validation and sanitisation touch-ups (uploads, pdf, maps, backup, csp)

* feat: optimize routes around accommodation, confirm note deletions (#1123)

Optimize day routes around the accommodation

When a day has an accommodation set, the route optimizer now treats it as
the day's home base: it optimizes a loop that leaves the hotel and returns
to it, so the stop nearest the hotel comes first. On a transfer day -
checking out of one hotel and into another - the route runs from the first
hotel to the second instead.

The optimizer also gained a 2-opt pass on top of the nearest-neighbor
ordering, which removes the crossings the greedy pass used to leave behind.
A new display setting ("optimize route from accommodation", on by default)
lets you turn the anchoring off.

Confirm before deleting notes

Deleting a plan note or a collab note now asks for confirmation first. On
phones and tablets the edit and delete icons sit close together and were
easy to mis-tap, which deleted notes with no way back.

* fix: miscellaneous bug fixes (#1139)

* fix(share): serve place thumbnails in shared trip links (#1100)

Google-sourced place photos are stored as image_url pointing at the
JWT-guarded /api/maps/place-photo/:placeId/bytes endpoint, so they 401
for an unauthenticated shared-trip viewer and render as broken images.

Rewrite place image_url values in the shared payload to a public,
token-scoped proxy (/api/shared/:token/place-photo/:placeId/bytes) and
add an unguarded SharedController route that validates the token and that
the place belongs to its trip before streaming the cached bytes. Mirrors
the existing JourneyPublicController precedent. No client changes needed.

* fix(atlas): replace Natural Earth with geoBoundaries for up-to-date regions (#1119)

Atlas sourced country and sub-national boundaries from Natural Earth's GitHub
`master` at runtime. That data is stale (e.g. it still shows Norway's pre-2020
counties such as Oppland/Hordaland) and depicts some contested territory in
unwanted ways (nvkelso/natural-earth-vector#391), so Natural Earth is dropped
entirely.

- Country borders (admin0) now come from the geoBoundaries CGAZ composite;
  sub-national regions (admin1) from per-country gbOpen, which carries ISO 3166-2
  codes. A new script (server/scripts/build-atlas-geo.mjs) normalizes and quantizes
  them into committed gzipped bundles under server/assets/atlas, read server-side at
  runtime (no network at boot, no GitHub CSP allowlist entry).
- New GET /addons/atlas/countries/geo serves the country layer; the client fetches
  it from the API instead of GitHub.
- A migration reconciles manually-marked visited_regions against the new bundle
  (valid code -> keep; region name still matches -> re-code; curated merge crosswalk
  for renamed reforms; else leave intact), with UNIQUE-safe dedup. bucket_list and
  visited_countries hold only invariant alpha-2 country codes, so they are untouched.
- Attribution added (NOTICE.md + README) per geoBoundaries CC BY 4.0.

Closes #1119

* fix(packing): make templates admin-only to create, usable by members

Creating a packing-list template was gated only by trip access, so any
trip member could create one from the Lists feature, while applying a
template silently failed for non-admins because the apply dropdown was
populated from the AdminGuard-protected /api/admin/packing-templates
endpoint.

- save-as-template now returns 403 for non-admins; the Save-as-Template
  button is hidden unless the user is an admin (both the TripPlanner
  toolbar and the inline packing header).
- add member-accessible GET /api/trips/:tripId/packing/templates so the
  apply dropdown lists templates for any trip member; client fetches
  from it instead of the admin endpoint.

Closes #1120
Closes #1121

* fix(packing): show bag tracking to non-admin members

The global Bag Tracking toggle was only readable via the admin-gated
GET /api/admin/bag-tracking, so non-admin trip members got 403 and the
weight fields, bag circles, and BAGS sidebar never rendered (#1124).

Surface the flag through the already-authenticated GET /api/addons
(loaded into the client addon store on app start for every user); the
packing hook reads it from the store instead of the admin endpoint. The
admin write path stays admin-gated and unchanged.

* Fix a batch of reported bugs (#1145)

* fix(maps): fall back to OSM/Wikipedia for place photos and normalize non-standard language codes (#1137)

* fix(auth): refuse password reset for OIDC/SSO-linked accounts (#1129)

* fix(docker): ship server/assets (airports + atlas geo) in the runtime image (#1133, #1119)

* fix(unraid): point the template at a PNG icon Unraid can render (#1073)

* fix(offline): serve cached file blobs when offline or on network failure (#1046, #1069)

* fix(map): centre the selected pin in the visible map area above the bottom panel (#1125)

* fix(pdf): render persisted place-photo proxy URLs as images (#1130)

* fix(planner): show the selected place category in the edit form (#1134)

* fix(dashboard): collapse list-view trip cards to a compact row on mobile (#1132)

* Support multi-leg (layover) flights (#1146)

* feat(transport): support multi-leg (layover) flights in the booking form

A flight booking can now hold an ordered chain of airports (e.g. FRA -> BER ->
HND) instead of a single departure/arrival pair. The route is entered as a list
of waypoints with a '+ add stop' button; each stop carries its own arrival and
departure time plus the airline/flight number of the segment leaving it, while
the whole booking keeps one price.

Stored without a schema change: the existing reservation_endpoints rows carry the
ordered waypoints (from/stop/to by sequence) and a metadata.legs array holds the
per-leg detail. Top-level metadata (departure_airport/arrival_airport/airline/
flight_number) mirrors the first and last leg, so a single-leg flight persists
exactly as before and legacy readers keep working.

* feat(planner): show each flight leg as its own day-plan entry, ordered by time

A multi-leg flight now expands into one entry per leg (BER -> FRA, then FRA ->
HND), each on its own day with its own times, instead of a single span. Each leg
is an addressable slot (reservation id + leg index) so places and notes can be
dropped into the layover gap between legs; the per-leg position is persisted in
metadata.legs[i].day_positions and survives a reload.

Day-plan items are now ordered chronologically: anything with a time (a place's
time, a flight leg, a timed note) sorts by that time, and untimed items inherit
the time of the item before them so they stay where they were placed.

* feat(planner): show the full multi-stop route in the bookings panel

The route row now lists every waypoint (FRA -> BER -> HND) by sequence instead of
just the first and last airport.

* feat(map): draw multi-leg flights as connected legs with a marker per airport

Both the Leaflet and Mapbox overlays now render a flight over all its waypoints:
one great-circle arc per leg and a marker at every airport, with the label
showing the full route and the summed distance. A single-leg flight is unchanged.

Also drops the floating stats badge that was drawn on transport arcs.

* fix(map): centre a clicked place above the bottom inspector panel

Selecting a place panned/flew it to the dead centre of the screen, where it sat
behind the detail card. Both overlays now bias the target into the visible area
above the bottom panel (Leaflet offsets the pan by the inspector inset; Mapbox
passes the padding to flyTo).

* feat: show the full multi-stop flight route in PDF and calendar export

The PDF day list and the ICS export now render the whole route (FRA → BER → HND)
for a multi-leg flight instead of just the first and last airport, falling back to
the flat metadata for single-leg flights. The ICS keeps a single event per booking.

* feat(import): group connecting flight legs into one multi-leg booking

When a booking confirmation contains several flight legs sharing a PNR that
connect at the same airport with a short layover (under 24h), they are now
imported as a single multi-leg booking (from/stop/to endpoints + metadata.legs)
instead of one booking per leg. A round trip (same PNR, multi-day gap) stays two
separate bookings, and a single flight is unchanged.

* i18n: translate the new flight-route strings into all languages

* i18n: translate the Costs page into every language

The Budget → Costs rework left the new costs.* strings untranslated in every
non-English locale (they fell back to English). Translate them across all
supported languages.

* Revert "fix(map): centre a clicked place above the bottom inspector panel"

This reverts commit 0936103f04.

* Explore places on the map, planner route fixes, and instance-wide Mapbox (#1147)

* feat(maps): add an OSM POI search endpoint (category within a viewport)

New /api/maps/pois queries OpenStreetMap via Overpass for places of a category
(restaurants, cafes, hotels, sights, …) inside a bounding box. OSM-only by design
— it never calls Google, even when a Google key is configured.

* feat(map): explore nearby places on the trip map (OSM category pill)

A floating, icon-only pill over the planner map lets you toggle a POI category and
see those OpenStreetMap places in the current view; clicking a marker opens the
add-place form pre-filled (name, address, website, phone). Single-select with a
'search this area' action after the map moves. Renders on both the Leaflet and
Mapbox maps, and can be turned off in settings (discussion #841).

* fix(planner): anchor timed places when optimising and route transports by location

- The day optimiser no longer reshuffles places that have a set time — they stay
  anchored to their time, like locked places.
- The route now uses a transport's departure/arrival location as a waypoint when it
  has one (e.g. a flight's airport), instead of breaking the route at every booking;
  transports without a location are ignored for routing but still show their leg's
  distance/duration under the booking.

* feat(admin): instance-wide Mapbox defaults in default user settings

Admins can set a shared Mapbox token (plus style, 3D and quality) as instance
defaults, so the whole instance can use Mapbox without each user pasting their own
key. Users without their own value inherit it via the existing admin-defaults
merge; the shared token is stored encrypted (discussion #920).

* Reorder whole days and insert a day (#589) (#1148)

* feat(days): reorder whole days and insert a day at a position

Adds reorderDays + insertDay to the day service and a PUT /days/reorder route
(plus an optional position on create). Day rows stay stable so a day's
assignments, notes, bookings and accommodations ride along by id; on a dated
trip the calendar dates stay pinned to their slots while the content moves
across them, and each booking's date is re-stamped onto its day's new date
(time-of-day preserved) so day_id stays consistent. Renumbering uses the
two-phase write to avoid the UNIQUE(trip_id, day_number) collision, and a move
that would invert an accommodation's check-in/out span is rejected.

* feat(planner): reorder days from a toolbar popup, and add days

A new toolbar button opens a popup listing the days; drag a row by its grip or
use the up/down arrows to reorder, and add a day from there. Reorders apply
optimistically with rollback and sync over WebSocket; the day headers are left
untouched, so the existing place drop-targets are unaffected.

* i18n: add day-reorder strings across all languages

* Map/planner/dashboard polish and small community features (#1155)

* feat(planner): reorder days in a modal instead of a dropdown

The day-reorder control opened a small anchored dropdown; move it into the shared Modal (portal, dimmed backdrop, Esc/backdrop close) so it matches the Add activity dialog. Drag handles, up/down arrows and the day badges are unchanged.

* feat(map): explore reliability, Mapbox popups + compass, region-biased search

POI explore: clamp oversized viewports, query the Overpass mirrors in parallel (first valid response wins) with a per-request timeout and a short-lived cache, and surface a retry when every mirror fails - so it returns results at any zoom instead of timing out.

Mapbox renderer: add the place/POI hover popups (name, category, address, photo) the Leaflet map already had, plus a compass pill next to the explore pill that resets the view to north.

/api/maps/search: accept an optional locationBias to fix foreign-region bias and expose Google's place types in the result.

* feat(dashboard): list-view and mobile polish

Use the Archived status label for the filter and show Open dates for trips without dates; drop the unused settings button next to the view toggle. Desktop list view renders the date as a stat-style block separated from the counts.

Mobile list rows are stacked (slim cover banner + centred date), trip actions stay visible (touch has no hover), and the hero card's hover lift is disabled on touch; small spacing fix under the sidebar.

* feat: small community-requested options

Raise the plan-note subtitle limit to 250 characters and add more note icons. Expose is_archived and cover_image on the update_trip MCP tool. Add place coordinates to the PDF export. Allow creating a category from an existing to-do, and add a show/hide toggle on the admin password fields.

* test(shared): bump day-note subtitle limit assertion to 250

* test: align specs with the new search param order and archive label

Keep lang as the 3rd positional arg of the maps search controller so the existing unit test stays valid, and forward locationBias as the 4th. Add the now-used Popup to the MapViewGL mapbox mock, switch the dashboard archive-filter query to the Archived label, and expect the 4-arg search call.

* fix(packing): add more bag colors so sub-bags stop repeating (#1156)

The auto-assigned bag palette only had 8 colors, so the 9th bag reused the first one. Double it to 16 (keeping the existing 8 and their order) and keep the server and client lists in sync - both cycle BAG_COLORS[count % length].

* fix(packing): respect per-item quantity in bulk import (#1157)

* AirTrail integration: import flights & two-way sync (#214) (#1158)

* feat(admin): register AirTrail as an integration addon

Off by default; toggle lives in Admin -> Addons with a Plane icon. The
per-user connection (URL + API key) follows in integration settings.

* feat(integrations): add per-user AirTrail connection

Settings -> Integrations gains an AirTrail section: instance URL + Bearer
API key (encrypted at rest via apiKeyCrypto), a self-signed-TLS opt-in and
a test-connection check. Served by a small Nest controller under
/api/integrations/airtrail, gated on the airtrail addon and SSRF-guarded.
The key is per-user, so it only ever returns that user's own flights.

* feat(transport): import flights from AirTrail

Adds an AirTrail Import button next to Manual Transport that lists the
user's AirTrail flights and highlights the ones inside the trip dates.
Selected flights become reservations linked to their AirTrail origin
(external_* columns), deduped against flights already in the trip, then
broadcast to every member. The mapping resolves airports, airport-local
times and flight metadata; the linkage is what the two-way sync rides on.

* feat(transport): badge AirTrail-linked flights as synced

Linked reservations show an 'AirTrail synced' badge, or 'no longer
synced' once the flight is gone from AirTrail.

* feat(transport): keep TREK and AirTrail flights in sync both ways

A scheduled poll reconciles each connected owner's flights: field edits
(detected by snapshot hash, since AirTrail has no updated_at) flow into
the linked reservation and broadcast live; a flight deleted in AirTrail
keeps the TREK row but stops syncing. Editing a linked flight in TREK
pushes back to AirTrail under the importer's credentials, preserving the
existing seat manifest; if the owner disconnected the link detaches so the
poll can't revert the local edit. Deleting in TREK never touches AirTrail.

* i18n(airtrail): add AirTrail strings across all locales

* test(airtrail): cover flight mapping, timezones and snapshot hashing

* fix(airtrail): reduce airline/aircraft objects to codes

The flight list/get response returns airline and aircraft as joined
objects ({icao, iata, name, ...}), not bare codes. Mapping them straight
through produced '[object Object]' titles and stored objects in metadata,
which crashed reservation rendering. Extract the ICAO/IATA code instead,
and title flights by their flight number.

* fix(airtrail): clear error on non-JSON responses, tolerate /api in URL

A misconfigured instance URL made AirTrail serve its SPA/login HTML, and
the raw JSON.parse failure surfaced as 'Unexpected token <'. Surface an
actionable message instead, and strip a pasted trailing /api so the base
URL still resolves.

* feat(transport): sync AirTrail edits on trip open, not just on the poll

Add a per-user on-demand sync (POST /integrations/airtrail/sync) triggered
when a connected user opens a trip, so AirTrail-side edits appear right away
instead of waiting up to a full poll cycle. Lower the background poll from 15
to 5 minutes as a safety net.

* fix(transport): refresh imported AirTrail flights without a reload

loadTrip doesn't fetch reservations, so a freshly imported flight only
appeared after a full page reload — use loadReservations instead. Also show
flight dates in the user's locale format (e.g. 13.06.2026) rather than the
raw ISO string.

* style(settings): align AirTrail connection with the photo-provider layout

Match the Immich section: stacked URL/key fields, a ToggleSwitch for
self-signed TLS, and a Save / Test-connection row with a status badge.

* feat(transport): add a seat field when editing flights

The transport editor only offered a seat field for trains; flights had
none even though imports store metadata.seat. Show and persist a seat for
flights too.

* style(transport): match the AirTrail button height to Manual Transport

* feat(transport): put the flight seat next to flight number and sync it to AirTrail

Move the seat from a standalone row to the per-leg flight details (beside
the flight number), stored per leg in metadata.legs[].seat with the first
leg mirrored to metadata.seat. On push, set the seat number on the user's
own AirTrail seat (the one with a userId), leaving co-passengers untouched;
import/poll read that same seat back.

* refactor(planner): move the AirTrail trip-open sync into useTripPlanner

Page containers must not own state/effects (lint:pages). Same logic,
relocated from the page into its data hook.

* test(db): pin the region-reconciliation test to its schema version

The test re-ran 'the last migration' assuming the reconciliation is last;
it no longer is once later migrations are appended. Pin to version 135 and
re-run from there (the appended migrations are idempotent).

* Various fixes: 2FA autofocus, viewer-timezone times, duplicate place guard (#1159)

* fix(auth): autofocus the 2FA code input when the MFA step appears (#767)

* fix(notifications): show notification and admin times in the viewer timezone (#1149)

SQLite CURRENT_TIMESTAMP is UTC but the string has no Z, so the client parsed
it as local time. Normalize in-app notification created_at to ISO-UTC, and stop
forcing the admin user table to render in the server timezone.

* fix(places): warn before adding a duplicate place (#1152)

Manually adding a place did not check the existing pool, so the same POI could
land in Unplanned twice. Flag a likely duplicate by Google Place ID, name or
near-identical coordinates and require a confirming second click to add anyway.

* fix(planner): make route tools reachable in mobile day plan sheet (#1142)

* wiki: update dev env

* wiki: small precision in dev env

* fix(planner): make route tools reachable in mobile day plan sheet

On mobile, selecting a day closes the plan sheet immediately, so the
route tools footer (Route toggle / Optimize / routing profile) - gated
on the selected day - was never reachable. Desktop was unaffected.

- Add showRouteToolsWhenExpanded prop to DayPlanSidebar: when set,
  route tools render on any expanded day with 2+ assigned places
- Make handleOptimize accept an explicit dayId (defaulting to
  selectedDayId, preserving desktop behavior)
- Keep the distance/duration pill gated on the selected day, since
  routeInfo belongs to the selected day's calculated route
- Enable the prop on the mobile plan sheet in TripPlannerPage

* fix(planner): correct route-tools prop doc and dev-environment wiki

- Reword the showRouteToolsWhenExpanded JSDoc to list the controls the
  footer actually renders (Route toggle / Optimize / travel profile);
  there is no "Open in Google Maps" action in that block.
- Wiki: drop the non-existent server test:parity script, document the
  real shared i18n:parity checks, and fix the i18n note (the translation
  layer already lives in @trek/shared, it is not "upcoming").

---------

Co-authored-by: jubnl <jgunther021@gmail.com>
Co-authored-by: Maurice <mauriceboe@icloud.com>

* feat(places): enrich list-imported places via the Places API (#886) (#1161)

* feat(places): enrich list-imported places via the Places API (#886)

Google/Naver list imports only carry a name and coordinates, so the places open
as bare pins — the Maps tab jumps to coordinates, with no photo, address or
open/closed. Add an opt-in "Enrich places via Google" toggle to the list-import
dialog, shown only when a Google Maps key is configured.

When enabled, after the (fast, unchanged) import the server runs a background
pass that re-resolves each place by name — biased to and validated against the
imported coordinates so a common-name search cannot overwrite the wrong place —
and fills the empty address/website/phone/photo columns plus the resolved
google_place_id, pushing each row over the live sync. Opening hours and the
proper Maps link then work on demand from the stored id.

Enrichment only fills empty fields, runs detached so a long list never blocks
the import, and no-ops when no key is configured.

* fix(places): use the ToggleSwitch component for the enrich toggle

Match the rest of the app — the import-enrichment opt-in used a raw checkbox;
swap it for the shared ToggleSwitch (text left, switch right) like the settings
toggles.

* fix(maps): bound place-photo cache growth (Wikimedia + Google) (#1174)

The place-photo cache (uploads/photos/google) grew unbounded: a Wikimedia
geosearch path cached full-res originals despite requesting a 400px thumb,
the writer applied no size guard, nothing reclaimed orphaned files, and
backups archived the whole re-derivable cache verbatim.

- Prefer the scaled `thumburl` over the full-res `info.url` in the Commons
  geosearch fallback.
- Downscale any cached image to <=800px JPEG via the existing jimp dep,
  with a safe fallback to the original bytes on decode failure.
- Add sweepOrphans() (orphaned meta rows + stray files) wired into the
  scheduler (startup + nightly), and removeIfUnreferenced() called on
  place delete for prompt reclamation.
- Exclude the re-derivable photo/trek caches from backups; restores
  self-heal as the cache dirs are recreated at startup.

* fix(sync): remap temp ids, prevent id collisions, surface failed mutations (#1175)

Closes three offline BLOCKERs from the PWA audit:

- B1: offline edits/deletes of an offline-created entity were lost. The
  negative temp id was baked into the PUT/DELETE url and never rewritten
  after the CREATE returned a real id, so dependents 404'd and were dropped.
  Dependents now carry a {id} placeholder + tempEntityId; flush builds a
  tempId->realId map and durably rewrites still-queued dependents on CREATE
  success (survives flush boundaries / reloads).
- B2: tempId = -(Date.now()) collided within a millisecond, overwriting an
  optimistic row. Replaced with a monotonic nextTempId() minter.
- B3: any 4xx marked the mutation failed with no rollback and no signal, and
  the badge ignored failed rows. Terminal failures now roll back the phantom
  optimistic CREATE; 401/408/425/429 are treated as retryable; failedCount()
  is surfaced in OfflineBanner (red pill) and OfflineTab.

* fix(maps): make offline tiles cover real trips (cap coherence + zoom-clamp) (#1177)

Closes BLOCKER B5 — the offline map was blank for most real trips:

- The Workbox 'map-tiles' cache held only 1000 entries while the prefetcher
  budgeted ~3413, so prefetched tiles were evicted on arrival. Both caps are
  now a coherent 12288 (~180 MB), kept in sync with cross-referencing comments.
- prefetchTilesForTrip skipped a trip entirely when its all-zooms estimate
  exceeded the cap, so region/road-trip bboxes got no tiles. Removed the
  all-or-nothing guard; prefetchTiles already fills zooms low→high and stops at
  the budget, so large trips now cache the zooms that fit instead of nothing.

* fix(security): stop cross-user offline data leak on shared devices (#1176)

Closes BLOCKER B4 — three reinforcing paths could serve one account's
cached data to the next user on a shared device:

- The Workbox 'api-data' cache keyed trip/user-scoped GETs by URL only
  (cookie-blind). Changed to NetworkOnly; offline reads come from the
  per-user IndexedDB cache via the repo layer instead.
- IndexedDB had no per-user scoping. The Dexie connection is now scoped
  per user (trek-offline-u<id>) behind a Proxy so the ~19 importers keep a
  stable binding; login opens the user DB, logout deletes it and returns
  to the anonymous DB.
- logout() was fire-and-forget and racy: background flush/syncAll could
  re-seed the DB after the wipe. It is now async and ordered — close an
  auth gate, unregister sync triggers, disconnect, clear caches, delete
  the user DB — and flush()/syncAll() bail when the gate is closed.

* fix(db): scope, evict, and cap the offline blob cache (H3) (#1178)

Blob cache previously leaked forever: clearTripData omitted it, entries had
no trip discriminator, and there was no size/count bound, so file blobs
survived trip eviction and could starve the map-tile cache for quota.

- BlobCacheEntry gains tripId + bytes; Dexie v3 adds a tripId index with a
  backfill upgrade (legacy rows -> tripId -1, bytes from blob.size)
- clearTripData purges the trip's blobs in-transaction
- enforceBlobBudget() evicts oldest-by-cachedAt past 200 entries / 100 MB
- tripSyncManager threads tripId/bytes into puts and enforces the budget

* fix(repo): fall back to Dexie when a network read fails (H2) (#1179)

Repos gated reads on raw navigator.onLine and the online branch had no
try/catch, so a captive portal or connected-but-no-internet (navigator.onLine
lying "true") threw a network error instead of serving the good cached copy —
blanking the trip even though Dexie held it.

- new onlineThenCache(onlineFn, cacheFn) helper: reads the cache when offline,
  and on a network-level failure (Axios error with no HTTP response). A genuine
  HTTP error (4xx/5xx — the server responded) is rethrown so callers still set
  error state / navigate, not masked by a stale cache.
- gates only on navigator.onLine, NOT the connectivity probe: the probe is a
  coarse global flag and one failed health check would otherwise divert every
  read to the (possibly empty) cache even when the request would succeed.
- every repo list/get read path routed through it (reads only — writes still
  go through the mutation queue so failures surface)
- tests: captive-portal fallback, HTTP-error rethrow, non-Axios rethrow

* fix(store): reset and uniformly hydrate trip-scoped slices in loadTrip (H4, H5) (#1180)

loadTrip only replaced the first slice group, so budget/reservations/files
from a previous trip stayed visible after switching trips (data exposure on a
shared screen). Those three also loaded via separate tab-gated effects, so they
never hydrated offline for an unopened tab.

- resetTrip() clears every trip-scoped slice (keeps global tags/categories) and
  runs at the top of loadTrip, so a switch can't leak the prior trip's data
- loadTrip now hydrates budget/reservations/files through their repos alongside
  the rest (non-fatal catches), making offline hydration uniform
- useTripPlanner drops the redundant loadFiles + reservations/budget effects;
  tab-gated lazy reloads stay as on-demand refresh
- tests: cross-trip no-leak, uniform hydration, resetTrip

* fix(sync): re-hydrate active trip store on reconnect/online (H1) (#1181)

setRefetchCallback was dead code, so on reconnect the queue flushed and Dexie
re-seeded but the open trip's Zustand store was never refreshed — a
collaborator's edits made while we were offline didn't appear until navigating
away and back.

- new tripStore.hydrateActiveTrip(): silent refresh of the active trip's
  collaborative state (days/places/packing/todo/budget/reservations/files),
  no resetTrip and no isLoading toggle so there's no splash on reconnect
- syncTriggers wires setRefetchCallback to it (WS layer awaits the flush hook
  first) and re-hydrates open trips after the online-event syncAll; cleared on
  unregister
- websocket exposes getActiveTrips() for the online-event path
- tests: refetch wiring + ordering, silent hydrate without reset/splash

* fix(server): lengthen idempotency key TTL to survive multi-day offline (H6) (#1182)

The nightly cleanup deleted idempotency keys older than 24h. The TREK client
replays queued mutations with their X-Idempotency-Key on reconnect, so a device
offline longer than a day had its keys GC'd before it returned — the replayed
POST was then treated as new and created a duplicate.

- raise the TTL to 30 days (DEFAULT_IDEMPOTENCY_TTL_SECONDS), overridable via
  IDEMPOTENCY_TTL_SECONDS
- extract purgeExpiredIdempotencyKeys(now, ttl, db) (mirrors cleanupOldBackups)
  with an injectable db, and have the cron job call it
- tests: 30-day default eviction, 25-day key retained (was dropped at 24h),
  env override

H7 (exactly-once across the lost-response window) is deferred: a correct fix
must store the response in the same DB transaction as the entity write. Doing
it in the generic interceptor (reserve-before-handler) cannot store the real
response body for the crash case, which would break the client's temp->real id
remapping on replay (mutationQueue.flush relies on the entity in the body). It
needs a per-service change and is tracked separately.

* fix(realtime): correct assignment:created echo dedup (H11) (#1183)

When X-Idempotency/X-Socket-Id let an own-echo through, the assignment:created
dedup had two bugs: it keyed on place id, so (1) a legitimate second assignment
of a place already on the day was silently dropped, and (2) the temp-version
reconciliation matched place?.id === placeId, letting undefined === undefined
collapse place-less rows onto each other.

- dedup now keys on assignment id (exact-id duplicate -> no-op)
- temp (negative-id) optimistic rows are reconciled only when a real placeId
  matches, replacing just that row; a sibling temp of another place is untouched
- everything else appends, including a genuine 2nd assignment of the same place
- tests: 2nd-of-same-place kept, correct temp picked among siblings, place-less
  rows don't collapse

Note: the broader own-echo suppression relies on X-Socket-Id being sent; this
fixes the client-side fallback when an echo slips through.

* fix(pwa): persist offline storage + Mapbox offline policy (H8, H9) (#1184)

H8: prefetched tiles and file blobs could be evicted under storage pressure
(worsened by opaque tile responses inflating the quota ~7MB each), blanking the
offline map right when a traveler needs it. Request persistent storage at app
init so the browser exempts our caches from eviction. We deliberately keep tile
requests no-cors (a cors switch would break self-hosted/custom tile providers
without CORS headers), so persistence is the safe mitigation rather than
de-opaquing responses.

H9: Mapbox GL users had no offline map at all — no runtimeCaching matched the
Mapbox hosts. Add a StaleWhileRevalidate rule for api.mapbox.com /
*.tiles.mapbox.com so visited areas are available offline (best-effort; full
pre-download still requires the Leaflet renderer, now documented).

- new sync/persistentStorage.ts requestPersistentStorage(), called from main.tsx
- vite.config: mapbox-tiles SW cache rule
- MapViewAuto / tilePrefetcher comments document the offline-maps policy
- tests for the persist helper (granted / already-persisted / absent / rejects)

* ci(security): only fail Docker Scout on fixable CVEs

Add only-fixed so the scan no longer fails on vulnerabilities with no
upstream fix available (e.g. base-image OS packages), and only flags
actionable, fixable findings.

* build(docker): rebuild gosu with a current Go toolchain

Debian's apt gosu ships an old Go stdlib that the image CVE scan flags
(1 critical + several high, all in golang/stdlib). Build gosu from source
with a current Go toolchain and copy the static binary in instead; the
runtime behaviour is unchanged — gosu still drops root to node at startup.

* build(deps): bump tsx's esbuild to 0.28.1 (GHSA-gv7w-rqvm-qjhr)

The production image's last image-scan finding was esbuild 0.28.0, pulled
in transitively by tsx. Pin tsx's esbuild to 0.28.1 (within tsx's ~0.28.0
range) to clear GHSA-gv7w-rqvm-qjhr. Lockfile-only; no runtime change.

* feat(auth): add "Remember me" checkbox to extend session lifetime (#1189)

Adds a "Remember me" checkbox to the login form (single responsive page,
covers mobile + desktop). Unchecked (default) issues the existing
SESSION_DURATION JWT with a browser-session cookie (no maxAge); checked
issues a longer-lived JWT plus a persistent cookie sized by the new
SESSION_DURATION_REMEMBER env var (default 30d). The choice is threaded
through the MFA verify leg so it survives the step-up.

Register/demo logins keep their current persistent behaviour.

* chore(ssrf): include lookup error code in error message

* fix(backup): restore from Docker, fail-fast on shadowed /app, bundle encryption key (#1193) (#1197)

* fix(backup): restore uploads through symlinked dir and bundle encryption key (#1193)

Restoring a backup inside Docker threw ERR_FS_CP_DIR_TO_NON_DIR because
/app/server/uploads is a symlink to the mounted /app/uploads volume and
cpSync (dereference:false) refuses to overwrite the symlink node with a
directory. The DB was swapped before this failing copy, so users saw
restored data but missing upload files (trip covers). Resolve the symlink
with realpathSync before copying so the merge targets the real directory;
no-op on a plain dir, so non-Docker behavior is unchanged.

Also bundle the at-rest encryption key (data/.encryption_key) into the
backup so a restore onto a different install can decrypt stored secrets
(API keys, MFA, SMTP/OIDC). Skipped when ENCRYPTION_KEY is provided via
env (the file is not the source of truth then). On restore the key is
swapped back if the archive carries one; a restart is required for the
in-memory key to take effect.

* fix(docker): fail fast when a volume shadows /app (#1193)

Mounting an old volume at /app hides the image's node_modules and dist,
so startup crashed with a cryptic "Cannot find module
'tsconfig-paths/register'". Add a CMD preflight that detects the missing
app files and exits with actionable guidance. Document in the README that
only /app/data and /app/uploads should be mounted, never /app.

* fix: ssrf test

* fix(places): fall back to search when autocomplete details lookup fails (#1192) (#1198)

Clicking an auto-suggest dropdown item did a second /maps/details lookup
that could fail (details kill-switch off, an overloaded OSM Overpass mirror
behind a proxy, or any upstream error), dead-ending on "Place search failed"
while the search button stayed reliable.

handleSelectSuggestion now treats a missing or coordinate-less details result
(or a thrown error) as a miss and falls back to the text-search path the search
button uses, applying the first result. The error toast only fires if the
fallback also returns nothing. Adds tests for the previously untested
suggestion-click path.

* fix(planner): scroll long place description/notes on mobile (#1195) (#1199)

The place details card (PlaceInspector) clipped long description/notes
with no way to scroll. The content area is a flex column whose children
(description/notes) had the default flex-shrink: 1, so once the card hit
its maxHeight cap they compressed to fit and their overflow:hidden clipped
the text instead of overflowing into a scroll region.

- Make the content area a bounded scroll region (flex: 1 1 auto,
  minHeight: 0, overflowY: auto, momentum + overscroll containment).
- Pin description/notes with flexShrink: 0 so they keep natural height and
  the card overflows into the scroll instead of clipping.
- Pin header/footer with flexShrink: 0 so they stay fixed while scrolling.
- Add wordBreak/overflowWrap to the description div to fix horizontal clip.

* Day plan: hotel travel times at start/end + login toggle polish (#1206)

* fix(login): use the shared toggle for the stay-signed-in option

* feat(planner): show hotel travel times at the start and end of a day

* fix(login): give the stay-signed-in toggle an accessible name and fix its test

* fix(trips): keep the day-count field empty when cleared and validate it (#1204) (#1207)

* docs(readme): refresh dashboard, costs and trip screenshots (#1208)

* docs(readme): refresh dashboard, costs and trip screenshots

* docs(readme): correct outdated info (React 19, NestJS, 20 languages, Costs rename, passkeys, AirTrail, notifications)

* chore: update all dependencies (#1209)

* chore: update all dependencies

* chore: remove lint errors

* fix(client): restore typecheck after dependency bump

vitest 4 types vi.fn() as Mock<Procedure | Constructable>, which no
longer assigns to the strictly-typed onUpdate prop; type the mock
explicitly. TS6 + the new transitive @types/node 25 stopped auto-
including node builtin module types, so import('node:buffer') failed;
add @types/node as a direct client devDependency and a scoped node
type reference in the one test that needs it.

* test: fix constructor mocks for vitest 4 Reflect.construct semantics

vitest 4 resolves new-invoked mocks via Reflect.construct, which rejects
arrow-function implementations (including mockReturnValue sugar) as
non-constructable. Convert mapbox-gl and better-sqlite3 mocks that the
code instantiates with new to regular function implementations.

* fix(planner): only route to multi-day transport endpoints on their pickup/drop-off days (#1210) (#1212)

* chore: move to Frankfurter API for exchange rate (#1214)

* Restore nest coverage to >=80% after the #1209 dep bump (istanbul provider + branch tests) (#1213)

* fix(server): set oxc:false in vitest so the SWC transform survives the Vite 8 bump

* fix(server): switch coverage to the istanbul provider (v8 under-reports branches on Vite 8 + Vitest 4)

* test(nest): cover controller/service branches to clear the 80% coverage gate

* fix(planner): correct transfer-day hotel legs and connect them to transports (#1215)

When you change hotels on a day, the morning bookend leg showed the hotel
you check into instead of the one you slept in whenever the morning stay
didn't end exactly on that day — both bookends collapsed onto the arriving
hotel. The morning hotel is now picked by "checked in earlier and still in
range" rather than "checks out today", which also fixes the route
optimizer's start anchor for the same case.

The bookend legs now connect to the first/last located waypoint of the day
— a place or a transport endpoint (a car return, a taxi or train arrival) —
so the hotel-to-transport drives are included too.

* feat(transports): add kitinerary import-from-file button to Transports tab

* docs(config): document SESSION_DURATION_REMEMBER across deployment artifacts

Add SESSION_DURATION_REMEMBER to docker-compose, .env.example, README env
table, Helm chart (values + configmap passthrough), the Unraid template, and
the Unraid install guide. Where the base SESSION_DURATION was also absent
(README, charts, Unraid) add the pair so the Remember-me variable has context.

---------

Co-authored-by: gzor <risenbrowser@web.de>
Co-authored-by: ppuassi <34529179+ppuassi@users.noreply.github.com>
Co-authored-by: sss3978 <106522699+soma3978@users.noreply.github.com>
Co-authored-by: SkyLostTR <onurluerin@gmail.com>
Co-authored-by: Julien G. <66769052+jubnl@users.noreply.github.com>
Co-authored-by: Dimitris Kafetzis <39215021+Dkafetzis@users.noreply.github.com>
Co-authored-by: Ahmet Yılmaz <70577707+sharkpaw@users.noreply.github.com>
Co-authored-by: jubnl <jgunther021@gmail.com>
Co-authored-by: jufy111 <40817638+jufy111@users.noreply.github.com>
Co-authored-by: Larinel <bodink7@gmail.com>
Co-authored-by: rossanorbr <48014819+rossanorbr@users.noreply.github.com>
This commit is contained in:
Maurice
2026-06-16 22:22:45 +02:00
committed by GitHub
parent b25eb18ea4
commit ad893eb1cc
1776 changed files with 153913 additions and 84833 deletions
+359
View File
@@ -0,0 +1,359 @@
import type { TranslationStrings } from '../types';
const admin: TranslationStrings = {
'admin.notifications.title': '通知',
'admin.notifications.hint': '選擇一個通知渠道。一次只能啟用一個。',
'admin.notifications.none': '已停用',
'admin.notifications.email': '電子郵件 (SMTP)',
'admin.notifications.webhook': 'Webhook',
'admin.notifications.save': '儲存通知設定',
'admin.notifications.saved': '通知設定已儲存',
'admin.notifications.testWebhook': '傳送測試 Webhook',
'admin.notifications.testWebhookSuccess': '測試 Webhook 傳送成功',
'admin.notifications.testWebhookFailed': '測試 Webhook 傳送失敗',
'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 專用於管理員通知(例如版本提醒)。它與每位使用者的 Webhook 分開,設定後始終會觸發。',
'admin.notifications.adminWebhookPanel.saved': '管理員 Webhook URL 已儲存',
'admin.notifications.adminWebhookPanel.testSuccess': '測試 Webhook 傳送成功',
'admin.notifications.adminWebhookPanel.testFailed': '測試 Webhook 傳送失敗',
'admin.notifications.adminWebhookPanel.alwaysOnHint':
'配置 URL 後,管理員 Webhook 始終觸發',
'admin.notifications.ntfy': 'Ntfy',
'admin.ntfy.hint':
'允許使用者設定自己的 ntfy 主題以接收推播通知。在下方設定預設伺服器以預先填入使用者設定。',
'admin.notifications.testNtfy': '傳送測試 Ntfy',
'admin.notifications.testNtfySuccess': '測試 Ntfy 傳送成功',
'admin.notifications.testNtfyFailed': '測試 Ntfy 失敗',
'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':
'允許使用者配置自己的 Webhook URL 以接收通知(Discord、Slack 等)。',
'admin.smtp.testSuccess': '測試郵件傳送成功',
'admin.smtp.testFailed': '測試郵件傳送失敗',
'admin.title': '管理後臺',
'admin.subtitle': '使用者管理和系統設定',
'admin.tabs.users': '使用者',
'admin.tabs.categories': '分類',
'admin.tabs.backup': '備份',
'admin.tabs.audit': '審計日誌',
'admin.tabs.notifications': '通知',
'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': 'Authentication Methods',
'admin.passwordLogin': 'Password Login',
'admin.passwordLoginHint': 'Allow users to sign in with email and password',
'admin.passwordRegistration': 'Password Registration',
'admin.passwordRegistrationHint':
'Allow new users to register with email and password',
'admin.oidcLogin': 'SSO Login',
'admin.oidcLoginHint': 'Allow users to sign in with SSO',
'admin.oidcRegistration': 'SSO Auto-Provisioning',
'admin.oidcRegistrationHint':
'Automatically create accounts for new SSO users',
'admin.envOverrideHint':
'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
'admin.lockoutWarning': 'At least one login method must remain enabled',
'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 金鑰,還可以載入照片、評分和營業時間。在 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': '單點登入 (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 登入。密碼登入和註冊將被停用。',
'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 配額。',
'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': '儲存失敗',
'admin.tabs.addons': '擴充套件',
'admin.addons.title': '擴充套件',
'admin.addons.subtitle': '啟用或停用功能以自定義你的 TREK 體驗。',
'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.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.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': '暫無可用擴充套件',
'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':
'天氣基於每天中第一個有座標的地點。如果當天沒有分配地點,則使用地點列表中的任意地點作為參考。',
'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.support': '幫助我繼續開發 TREK',
'admin.github.error': '載入版本失敗',
'admin.github.by': '作者',
'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': '請在幾秒後重新整理頁面。',
'admin.tabs.permissions': '許可權',
'admin.addons.catalog.journey.name': '旅程',
'admin.addons.catalog.journey.description':
'旅行追蹤與旅行日誌,包含打卡、照片和每日故事',
'admin.passkey.title': 'Passkey 登入',
'admin.passkey.cardHint': '讓使用者使用 PasskeyWebAuthn)登入。預設為關閉。',
'admin.passkey.login': '啟用 Passkey 登入',
'admin.passkey.loginHint':
'顯示「使用 Passkey 登入」選項,並讓使用者在設定中註冊 Passkey。',
'admin.passkey.notConfigured':
'此部署尚未解析出任何 WebAuthn 網域。請設定下方的 APP_URL 或 Relying Party ID——在此之前 Passkey 將保持隱藏。',
'admin.passkey.rpId': 'Relying Party ID(網域)',
'admin.passkey.rpIdHint':
'Passkey 綁定的純網域,例如 trek.example.org。留空則從 APP_URL 推導。日後變更將使現有 Passkey 失效。',
'admin.passkey.origins': '允許的來源',
'admin.passkey.originsHint':
'以逗號分隔的完整來源,例如 https://trek.example.org。留空則使用 APP_URL。',
'admin.passkey.reset': '重設 Passkey',
'admin.passkey.resetHint':
'移除此使用者的所有 Passkey(例如裝置遺失時)。他們仍可使用密碼登入。',
'admin.passkey.resetConfirm': '要移除 {name} 的所有 Passkey 嗎?',
'admin.passkey.resetDone': '已移除 {count} 個 Passkey',
'admin.defaultSettings.mapProvider': '地圖引擎',
'admin.defaultSettings.mapProviderHint': '此執行個體上所有人的預設地圖。每位使用者仍可在自己的設定中覆寫此項。',
'admin.defaultSettings.providerLeaflet': '標準(免費)',
'admin.defaultSettings.providerMapbox': 'Mapbox3D',
'admin.defaultSettings.mapboxToken': '共用的 Mapbox 權杖',
'admin.defaultSettings.mapboxTokenHint': '用於每一位尚未輸入自己權杖的使用者 — 如此整個執行個體都能使用 Mapbox,而無需個別共享金鑰。以加密方式儲存。',
'admin.defaultSettings.mapboxStyle': '地圖樣式',
'admin.defaultSettings.mapboxStylePlaceholder': '選擇樣式…',
'admin.defaultSettings.mapbox3d': '3D 建築物與地形',
'admin.defaultSettings.mapboxQuality': '高品質模式',
};
export default admin;
+6
View File
@@ -0,0 +1,6 @@
import type { TranslationStrings } from '../types';
const airport: TranslationStrings = {
'airport.searchPlaceholder': '機場代碼或城市(例如 FRA',
};
export default airport;
+58
View File
@@ -0,0 +1,58 @@
import type { TranslationStrings } from '../types';
const atlas: TranslationStrings = {
'atlas.subtitle': '你的全球旅行足跡',
'atlas.countries': '國家',
'atlas.trips': '旅行',
'atlas.places': '地點',
'atlas.days': '天',
'atlas.visitedCountries': '已訪問國家',
'atlas.cities': '城市',
'atlas.noData': '暫無旅行資料',
'atlas.noDataHint': '建立旅行並新增地點以檢視世界地圖',
'atlas.lastTrip': '上次旅行',
'atlas.nextTrip': '下次旅行',
'atlas.daysLeft': '天后出發',
'atlas.streak': '連續',
'atlas.year': '年',
'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': '個地點已訪問',
'atlas.statsTab': '統計',
'atlas.bucketTab': '心願單',
'atlas.addBucket': '新增到心願單',
'atlas.bucketNamePlaceholder': '地點或目的地...',
'atlas.bucketNotesPlaceholder': '備註(可選)',
'atlas.bucketEmpty': '你的心願單是空的',
'atlas.bucketEmptyHint': '新增你夢想去的地方',
'atlas.unmark': '移除',
'atlas.confirmMark': '將此國家標記為已訪問?',
'atlas.confirmUnmark': '從已訪問列表中移除此國家?',
'atlas.confirmUnmarkRegion': '從已訪問列表中移除此地區?',
'atlas.markVisited': '標記為已訪問',
'atlas.markVisitedHint': '將此國家新增到已訪問列表',
'atlas.markRegionVisitedHint': '將此地區新增到已訪問列表',
'atlas.addToBucket': '新增到心願單',
'atlas.addPoi': '新增地點',
'atlas.searchCountry': '搜尋國家...',
'atlas.month': '月份',
'atlas.addToBucketHint': '儲存為想去的地方',
'atlas.bucketWhen': '你計劃什麼時候去?',
};
export default atlas;
+74
View File
@@ -0,0 +1,74 @@
import type { TranslationStrings } from '../types';
const backup: TranslationStrings = {
'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': '限 128 以相容所有月份',
'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': '永久保留',
'backup.restoreConfirmTitle': '恢復備份?',
'backup.restoreWarning':
'所有當前資料(旅行、地點、使用者、上傳檔案)將被備份資料永久替換。此操作無法撤銷。',
'backup.restoreTip': '提示:恢復前建議先備份當前狀態。',
'backup.restoreConfirm': '確認恢復',
};
export default backup;
+115
View File
@@ -0,0 +1,115 @@
import type { TranslationStrings } from '../types';
const budget: TranslationStrings = {
'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': '人均',
'budget.table.perDay': '日均',
'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':
'確定刪除分類「{name}」及其 {count} 個條目?',
'budget.deleteCategory': '刪除分類',
'budget.perPerson': '人均',
'budget.paid': '已支付',
'budget.open': '未支付',
'budget.noMembers': '未分配成員',
'budget.settlement': '結算',
'budget.settlementInfo':
'點選預算專案上的成員頭像將其標記為綠色——表示該成員已付款。結算會顯示誰欠誰多少。',
'budget.netBalances': '淨餘額',
'budget.categoriesLabel': '類別',
"costs.you": "你",
"costs.youShort": "Y",
"costs.youLower": "你",
"costs.youOwe": "你欠款",
"costs.youOweSub": "你需付款給他人",
"costs.youreOwed": "他人欠你",
"costs.youreOwedSub": "他人需付款給你",
"costs.totalSpend": "旅程總支出",
"costs.totalSpendSub": "所有旅伴合計",
"costs.to": "給",
"costs.from": "來自",
"costs.allSettled": "你已全部結清",
"costs.nothingOwed": "沒有人欠你",
"costs.yourShare": "你的分攤",
"costs.youPaid": "你支付了",
"costs.expenses": "支出",
"costs.entries": "{count} 筆",
"costs.searchPlaceholder": "搜尋支出…",
"costs.filter.all": "全部",
"costs.filter.mine": "我支付的",
"costs.filter.owed": "他人欠我",
"costs.addExpense": "新增支出",
"costs.editExpense": "編輯支出",
"costs.noMatch": "沒有符合搜尋的支出。",
"costs.emptyText": "尚無支出,新增第一筆吧。",
"costs.spent": "支出 {amount}",
"costs.noDate": "無日期",
"costs.noOnePaid": "尚無人付款",
"costs.youLent": "你借出 {amount}",
"costs.youBorrowed": "你借入 {amount}",
"costs.settleUp": "結清",
"costs.history": "歷史紀錄",
"costs.everyoneSquare": "大家都已結清",
"costs.nothingOutstanding": "目前沒有待付款項。",
"costs.pay": "支付",
"costs.pays": "支付",
"costs.settle": "結算",
"costs.balances": "餘額",
"costs.byCategory": "按分類",
"costs.noCategories": "尚無支出。",
"costs.settleHistory": "結算紀錄",
"costs.noSettlements": "尚無已結清的款項。",
"costs.paymentsSettled": "已結清 {count} 筆款項",
"costs.paid": "已付",
"costs.undo": "復原",
"costs.whatFor": "這筆是什麼支出?",
"costs.namePlaceholder": "例如:晚餐、紀念品、油費…",
"costs.totalAmount": "總金額",
"costs.currency": "貨幣",
"costs.day": "日期",
"costs.rateLabel": "1 {from} 兌 {to}",
"costs.category": "分類",
"costs.whoPaid": "誰付的款?",
"costs.splitBetween": "平均分攤給",
"costs.pickSomeone": "至少選擇一人來分攤。",
"costs.splitSummary": "分 {count} 份 · 每份 {amount}",
"costs.cat.accommodation": "住宿",
"costs.cat.food": "餐飲",
"costs.cat.groceries": "雜貨",
"costs.cat.transport": "交通",
"costs.cat.flights": "機票",
"costs.cat.activities": "活動",
"costs.cat.sightseeing": "觀光",
"costs.cat.shopping": "購物",
"costs.cat.fees": "費用與票券",
"costs.cat.health": "健康",
"costs.cat.tips": "小費",
"costs.cat.other": "其他",
"costs.daysCount": "{count} 天",
"costs.travelers": "{count} 位旅伴",
"costs.liveRate": "即時匯率",
"costs.settleAll": "全部結清",
};
export default budget;
+25
View File
@@ -0,0 +1,25 @@
import type { TranslationStrings } from '../types';
const categories: TranslationStrings = {
'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': '刪除失敗',
};
export default categories;
+75
View File
@@ -0,0 +1,75 @@
import type { TranslationStrings } from '../types';
const collab: TranslationStrings = {
'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.confirmDeleteTitle': '刪除筆記?',
'collab.notes.confirmDeleteBody': '此筆記將被永久刪除。',
'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': '已關閉',
};
export default collab;
+54
View File
@@ -0,0 +1,54 @@
import type { TranslationStrings } from '../types';
const common: TranslationStrings = {
'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.saved': '已儲存',
'common.expand': '展開',
'common.collapse': '折疊',
'common.update': '更新',
'common.change': '修改',
'common.uploading': '上傳中…',
'common.backToPlanning': '返回規劃',
'common.reset': '重置',
'common.copy': '複製',
'common.copied': '已複製',
'common.justNow': '剛剛',
'common.hoursAgo': '{count}小時前',
'common.daysAgo': '{count}天前',
};
export default common;
+166
View File
@@ -0,0 +1,166 @@
import type { TranslationStrings } from '../types';
const dashboard: TranslationStrings = {
'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.newTripSub': '從零開始規劃新旅行',
'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.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': '結束日期必須晚於開始日期',
'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': '匯率轉換',
'dashboard.filter.planned': '已規劃',
'dashboard.hero.badgeLive': '進行中',
'dashboard.hero.badgeToday': '今天開始',
'dashboard.hero.badgeTomorrow': '明天',
'dashboard.hero.badgeNext': '即將開始',
'dashboard.hero.badgeRecent': '最近',
'dashboard.hero.tripDates': '旅行日期',
'dashboard.hero.noDates': '未設定日期',
'dashboard.hero.travelerOne': '{count} 位旅客',
'dashboard.hero.travelerMany': '{count} 位旅客',
'dashboard.hero.destinationOne': '{count} 個目的地',
'dashboard.hero.destinationMany': '{count} 個目的地',
'dashboard.hero.dayUnitOne': '天',
'dashboard.hero.dayUnitMany': '天',
'dashboard.hero.dayLeft': '剩 1 天',
'dashboard.hero.daysLeft': '剩餘天數',
'dashboard.hero.lastDay': '最後一天',
'dashboard.hero.untilStart': '距開始',
'dashboard.hero.startsIn': '距出發',
'dashboard.atlas.countriesVisited': '地圖集 · 已造訪國家',
'dashboard.atlas.ofTotal': '/ {total}',
'dashboard.atlas.tripsTotal': '旅行總數',
'dashboard.atlas.placesMapped': '已標記 {count} 個地點',
'dashboard.atlas.daysTraveled': '旅行天數',
'dashboard.atlas.daysUnit': '天',
'dashboard.atlas.acrossAllTrips': '所有旅行累計',
'dashboard.atlas.distanceFlown': '飛行距離',
'dashboard.atlas.kmUnit': 'km',
'dashboard.atlas.aroundEquator': '≈ 繞赤道 {count} 圈',
'dashboard.card.idea': '想法',
'dashboard.card.buddyOne': '旅伴',
'dashboard.fx.from': '從',
'dashboard.fx.to': '到',
'dashboard.fx.unavailable': '無法取得匯率',
'dashboard.tz.searchPlaceholder': '搜尋時區…',
'dashboard.tz.empty': '還沒有其他時區 — 用 + 新增一個',
'dashboard.upcoming.title': '即將到來的預訂',
'dashboard.upcoming.empty': '尚未預訂任何內容。',
'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.aria.toggleView': '切換檢視',
'dashboard.aria.filter': '篩選',
'dashboard.aria.duplicate': '複製',
'dashboard.aria.refreshRates': '重新整理匯率',
'dashboard.aria.swapCurrencies': '交換貨幣',
'dashboard.aria.addTimezone': '新增時區',
'dashboard.aria.removeTimezone': '移除 {city}',
'dashboard.dayCountRequired': '天數為必填項',
};
export default dashboard;
+25
View File
@@ -0,0 +1,25 @@
import type { TranslationStrings } from '../types';
const day: TranslationStrings = {
'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': '預訂',
};
export default day;
+55
View File
@@ -0,0 +1,55 @@
import type { TranslationStrings } from '../types';
const dayplan: TranslationStrings = {
'dayplan.icsTooltip': '匯出日曆 (ICS)',
'dayplan.emptyDay': '當天暫無計劃',
'dayplan.addNote': '新增備註',
'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.routeOptimizedFromHotel': '已從你的住宿地點最佳化路線',
'dayplan.toast.noGeoPlaces': '未找到有座標的地點用於路線計算',
'dayplan.confirmed': '已確認',
'dayplan.pendingRes': '待確認',
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': '匯出當天計劃為 PDF',
'dayplan.pdfError': 'PDF 匯出失敗',
'dayplan.cannotReorderTransport': '有固定時間的預訂無法重新排序',
'dayplan.confirmRemoveTimeTitle': '移除時間?',
'dayplan.confirmRemoveTimeBody':
'此地點有固定時間({time})。移動後將移除時間並允許自由排序。',
'dayplan.confirmRemoveTimeAction': '移除時間並移動',
'dayplan.confirmDeleteNoteTitle': '刪除筆記?',
'dayplan.confirmDeleteNoteBody': '此筆記將被永久刪除。',
'dayplan.cannotDropOnTimed': '無法將專案放置在有固定時間的條目之間',
'dayplan.cannotBreakChronology': '這將打亂已計劃專案和預訂的時間順序',
'dayplan.mobile.addPlace': '新增地點',
'dayplan.mobile.searchPlaces': '搜尋地點...',
'dayplan.mobile.allAssigned': '所有地點已分配',
'dayplan.mobile.noMatch': '無匹配',
'dayplan.mobile.createNew': '建立新地點',
'dayplan.expandAll': 'Expand all days', // en-fallback
'dayplan.collapseAll': 'Collapse all days', // en-fallback
'dayplan.reorderDays': '重新排序日期',
'dayplan.reorderTitle': '重新排序日期',
'dayplan.reorderHint': '該日的地點、筆記和預訂都會一併移動。',
'dayplan.addDay': '新增日期',
'dayplan.moveUp': '上移',
'dayplan.moveDown': '下移',
'dayplan.reorderUndo': '重新排序日期',
'dayplan.reorderError': '重新排序日期失敗',
'dayplan.addDayError': '新增日期失敗',
};
export default dayplan;
@@ -0,0 +1,62 @@
import type { NotificationLocale } from '../externalNotifications/types';
const zhTW: NotificationLocale = {
email: {
footer: '您收到這封郵件是因為您在 TREK 中啟用了通知。',
manage: '管理偏好設定',
madeWith: 'Made with',
openTrek: '開啟 TREK',
},
events: {
trip_invite: (p) => ({
title: `邀請加入「${p.trip}`,
body: `${p.actor} 邀請了 ${p.invitee || '成員'} 加入行程「${p.trip}」。`,
}),
booking_change: (p) => ({
title: `新預訂:${p.booking}`,
body: `${p.actor} 在「${p.trip}」中新增了預訂「${p.booking}」(${p.type})。`,
}),
trip_reminder: (p) => ({
title: `行程提醒:${p.trip}`,
body: `您的行程「${p.trip}」即將開始!`,
}),
todo_due: (p) => ({
title: `待辦事項即將到期:${p.todo}`,
body: `${p.trip}」中的「${p.todo}」將於 ${p.due} 到期。`,
}),
vacay_invite: (p) => ({
title: 'Vacay 融合邀請',
body: `${p.actor} 邀請您合併假期計畫。開啟 TREK 以接受或拒絕。`,
}),
photos_shared: (p) => ({
title: `已分享 ${p.count} 張照片`,
body: `${p.actor} 在「${p.trip}」中分享了 ${p.count} 張照片。`,
}),
collab_message: (p) => ({
title: `${p.trip}」中的新訊息`,
body: `${p.actor}${p.preview}`,
}),
packing_tagged: (p) => ({
title: `打包清單:${p.category}`,
body: `${p.actor} 已將您指派到「${p.trip}」中的「${p.category}」分類。`,
}),
version_available: (p) => ({
title: '新版 TREK 可用',
body: `TREK ${p.version} 現已可用。請前往管理面板進行更新。`,
}),
synology_session_cleared: () => ({
title: 'Synology 工作階段已清除',
body: '您的 Synology 帳戶或 URL 已變更,您已登出 Synology Photos。',
}),
},
passwordReset: {
subject: '重設您的密碼',
greeting: '您好',
body: '我們收到了重設您 TREK 帳號密碼的請求。點擊下方按鈕以設定新密碼。',
ctaIntro: '重設密碼',
expiry: '此連結將於 60 分鐘後失效。',
ignore: '若非您本人發起的請求,請忽略此郵件 — 您的密碼不會變更。',
},
};
export default zhTW;
+60
View File
@@ -0,0 +1,60 @@
import type { TranslationStrings } from '../types';
const files: TranslationStrings = {
'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': '協作筆記',
'files.sourceCollab': '來自協作筆記',
'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': '新增備註...',
};
export default files;
+86
View File
@@ -0,0 +1,86 @@
import admin from './admin';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
import budget from './budget';
import categories from './categories';
import collab from './collab';
import common from './common';
import dashboard from './dashboard';
import day from './day';
import dayplan from './dayplan';
import files from './files';
import inspector from './inspector';
import journey from './journey';
import login from './login';
import map from './map';
import members from './members';
import memories from './memories';
import nav from './nav';
import notif from './notif';
import notifications from './notifications';
import oauth from './oauth';
import packing from './packing';
import pdf from './pdf';
import perm from './perm';
import photos from './photos';
import places from './places';
import planner from './planner';
import register from './register';
import reservations from './reservations';
import settings from './settings';
import share from './share';
import shared from './shared';
import stats from './stats';
import system_notice from './system_notice';
import todo from './todo';
import transport from './transport';
import trip from './trip';
import trips from './trips';
import undo from './undo';
import vacay from './vacay';
const locale = {
...common,
...trips,
...nav,
...dashboard,
...settings,
...admin,
...dayplan,
...share,
...shared,
...login,
...register,
...vacay,
...atlas,
...trip,
...places,
...inspector,
...reservations,
...airport,
...map,
...budget,
...files,
...packing,
...members,
...categories,
...backup,
...photos,
...pdf,
...planner,
...stats,
...day,
...memories,
...collab,
...perm,
...undo,
...todo,
...notifications,
...journey,
...notif,
...oauth,
...system_notice,
...transport,
};
export default locale;
+22
View File
@@ -0,0 +1,22 @@
import type { TranslationStrings } from '../types';
const inspector: TranslationStrings = {
'inspector.opened': '營業中',
'inspector.closed': '已關閉',
'inspector.openingHours': '營業時間',
'inspector.showHours': '顯示營業時間',
'inspector.files': '檔案',
'inspector.filesCount': '{count} 個檔案',
'inspector.removeFromDay': '從當天移除',
'inspector.remove': '刪除',
'inspector.addToDay': '新增到當天',
'inspector.confirmedRes': '已確認預訂',
'inspector.pendingRes': '待確認預訂',
'inspector.google': '在 Google Maps 中開啟',
'inspector.website': '開啟網站',
'inspector.addRes': '預訂',
'inspector.editRes': '編輯預訂',
'inspector.participants': '參與者',
'inspector.trackStats': '軌跡資料',
};
export default inspector;
+234
View File
@@ -0,0 +1,234 @@
import type { TranslationStrings } from '../types';
const journey: TranslationStrings = {
'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.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':
'將 <strong>{title}</strong> 變成一段旅程',
'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.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.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.stats.days': '天',
'journey.stats.cities': '城市',
'journey.stats.entries': '條目',
'journey.stats.photos': '照片',
'journey.stats.places': '地點',
'journey.skeletons.show': '顯示建議',
'journey.skeletons.hide': '隱藏建議',
'journey.verdict.lovedIt': '非常喜歡',
'journey.verdict.couldBeBetter': '有待改進',
'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': '寫下你的故事...',
'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.mood.amazing': '太棒了',
'journey.mood.good': '不錯',
'journey.mood.neutral': '一般',
'journey.mood.rough': '糟糕',
'journey.weather.sunny': '晴天',
'journey.weather.partly': '多雲',
'journey.weather.cloudy': '陰天',
'journey.weather.rainy': '雨天',
'journey.weather.stormy': '暴風雨',
'journey.weather.cold': '雪天',
'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.invite': '邀請貢獻者',
'journey.contributors.searchUser': '搜尋使用者',
'journey.contributors.searchPlaceholder': '使用者名稱或郵箱...',
'journey.contributors.noUsers': '未找到使用者',
'journey.contributors.role': '角色',
'journey.contributors.added': '貢獻者已新增',
'journey.contributors.addFailed': '新增貢獻者失敗',
'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.role': '角色',
'journey.invite.viewer': '檢視者',
'journey.invite.editor': '編輯者',
'journey.invite.invite': '邀請',
'journey.invite.inviting': '邀請中...',
'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': '隱藏直播標記。您可以隨時重新開啟。',
'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.photosUploadFailed': '部分照片上傳失敗',
'journey.photosAdded': '{count} 張照片已新增',
'journey.public.notFound': '未找到',
'journey.public.notFoundMessage': '此旅程不存在或連結已過期。',
'journey.public.readOnly': '唯讀 · 公開旅程',
'journey.public.tagline': '旅行資源與探索工具包',
'journey.public.sharedVia': '分享自',
'journey.public.madeWith': '由',
'journey.pdf.journeyBook': '旅程手冊',
'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': '搜尋',
'journey.detail.journeyTab': 'Journey', // en-fallback
'journey.contributors.remove': 'Remove contributor', // en-fallback
'journey.contributors.removeConfirm': 'Remove {username} from this journey?', // en-fallback
'journey.contributors.removed': 'Contributor removed', // en-fallback
'journey.contributors.removeFailed': 'Failed to remove contributor', // en-fallback
};
export default journey;
+91
View File
@@ -0,0 +1,91 @@
import type { TranslationStrings } from '../types';
const login: TranslationStrings = {
'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.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.rememberMe': '記住我',
'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': '重設失敗。連結可能已過期。',
'login.oidc.tokenFailed': '認證失敗。',
'login.oidc.invalidState': '會話無效,請重試。',
'login.demoFailed': '演示登入失敗',
'login.oidcSignIn': '透過 {name} 登入',
'login.oidcOnly': '密碼登入已關閉。請透過 SSO 提供商登入。',
'login.oidcLoggedOut': '您已登出。請重新透過 SSO 提供商登入。',
'login.demoHint': '試用演示——無需註冊',
'login.passkey.signIn': '使用 Passkey 登入',
'login.passkey.failed': 'Passkey 登入失敗,請重試。',
};
export default login;
+17
View File
@@ -0,0 +1,17 @@
import type { TranslationStrings } from '../types';
const map: TranslationStrings = {
'map.connections': '連接',
'map.showConnections': '顯示預訂路線',
'map.hideConnections': '隱藏預訂路線',
'poi.searchThisArea': '搜尋此區域',
'poi.cat.restaurants': '餐廳',
'poi.cat.cafes': '咖啡廳',
'poi.cat.bars': '酒吧與夜生活',
'poi.cat.hotels': '住宿',
'poi.cat.sights': '景點',
'poi.cat.museums': '博物館與文化',
'poi.cat.nature': '自然與公園',
'poi.cat.activities': '活動',
};
export default map;
+24
View File
@@ -0,0 +1,24 @@
import type { TranslationStrings } from '../types';
const members: TranslationStrings = {
'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': '移除失敗',
};
export default members;
+77
View File
@@ -0,0 +1,77 @@
import type { TranslationStrings } from '../types';
const memories: TranslationStrings = {
'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':
'在網址中包含照片應用程式路徑,例如 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.oldest': '最早優先',
'memories.newest': '最新優先',
'memories.allLocations': '所有地點',
'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.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': '請填寫所有必填欄位',
};
export default memories;
+20
View File
@@ -0,0 +1,20 @@
import type { TranslationStrings } from '../types';
const nav: TranslationStrings = {
'nav.trip': '旅行',
'nav.share': '分享',
'nav.settings': '設定',
'nav.admin': '管理',
'nav.logout': '退出登入',
'nav.lightMode': '淺色模式',
'nav.darkMode': '深色模式',
'nav.autoMode': '自動模式',
'nav.administrator': '管理員',
'nav.myTrips': '我的旅行',
'nav.profile': '個人資料',
'nav.bottomSettings': '設定',
'nav.bottomAdmin': '管理設定',
'nav.bottomLogout': '退出登入',
'nav.bottomAdminBadge': '管理員',
};
export default nav;
+41
View File
@@ -0,0 +1,41 @@
import type { TranslationStrings } from '../types';
const notif: TranslationStrings = {
'notif.test.title': '[測試] 通知',
'notif.test.simple.text': '這是一則簡單的測試通知。',
'notif.test.boolean.text': '你是否接受這則測試通知?',
'notif.test.navigate.text': '點擊下方前往儀表板。',
'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 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 中註冊',
};
export default notif;
+36
View File
@@ -0,0 +1,36 @@
import type { TranslationStrings } from '../types';
const notifications: TranslationStrings = {
'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':
'您的伺服器或帳號已更改 — 請前往設定重新測試連線。',
'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}"的測試通知。',
'notifications.versionAvailable.title': '有可用更新',
'notifications.versionAvailable.text': 'TREK {version} 現已推出。',
'notifications.versionAvailable.button': '查看詳情',
};
export default notifications;
+101
View File
@@ -0,0 +1,101 @@
import type { TranslationStrings } from '../types';
const oauth: TranslationStrings = {
'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': 'Geo',
'oauth.scope.group.weather': '天氣',
'oauth.scope.group.journey': '旅程',
'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': '檢視旅程',
'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': '建立、更新及撤銷旅程的公開分享連結',
'oauth.authorize.authorizing': 'Authorizing…', // en-fallback
'oauth.authorize.loading': 'Loading…', // en-fallback
'oauth.authorize.errorTitle': 'Authorization Error', // en-fallback
'oauth.authorize.loginTitle': 'Sign in to continue', // en-fallback
'oauth.authorize.loginDescription':
'{client} wants access to your TREK account. Please sign in first.', // en-fallback
'oauth.authorize.loginButton': 'Sign in to TREK', // en-fallback
'oauth.authorize.requestLabel': 'Authorization Request', // en-fallback
'oauth.authorize.requestDescription':
'This application is requesting access to your TREK account.', // en-fallback
'oauth.authorize.trustNote':
'Only grant access to applications you trust. Your data stays on your server.', // en-fallback
'oauth.authorize.selectScope': 'Select at least one scope', // en-fallback
'oauth.authorize.approveOneScope': 'Approve ({count} scope)', // en-fallback
'oauth.authorize.approveManyScopes': 'Approve ({count} scopes)', // en-fallback
'oauth.authorize.approveAccess': 'Approve Access', // en-fallback
'oauth.authorize.deny': 'Deny', // en-fallback
'oauth.authorize.choosePermissions': 'Choose which permissions to grant', // en-fallback
'oauth.authorize.permissionsRequested': 'Permissions requested', // en-fallback
'oauth.authorize.alwaysIncluded': 'Always included', // en-fallback
'oauth.authorize.alwaysTool.listTrips':
'List your trips so the AI can discover trip IDs', // en-fallback
'oauth.authorize.alwaysTool.getTripSummary':
'Read a trip overview needed to use any other tool', // en-fallback
};
export default oauth;
+183
View File
@@ -0,0 +1,183 @@
import type { TranslationStrings } from '../types';
const packing: TranslationStrings = {
'packing.title': '行李清單',
'packing.empty': '行李清單為空',
'packing.import': '匯入',
'packing.importTitle': '匯入裝箱清單',
'packing.importHint':
'每行一個物品。可選用逗號、分號或製表符分隔類別和數量:名稱, 類別, 數量',
'packing.importPlaceholder': '牙刷\n防曬霜, 衛生\nT恤, 衣物, 5\n護照, 證件',
'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.addItem': '新增物品',
'packing.addItemPlaceholder': '物品名稱...',
'packing.addCategory': '新增分類',
'packing.newCategoryPlaceholder': '分類名稱(如:衣物)',
'packing.applyTemplate': '應用模板',
'packing.template': '模板',
'packing.templateApplied': '已從模板新增 {count} 個物品',
'packing.templateError': '應用模板失敗',
'packing.saveAsTemplate': '儲存為範本',
'packing.templateName': '範本名稱',
'packing.templateSaved': '行李清單已儲存為範本',
'packing.noMembers': '無成員',
'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.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: '健康',
},
],
};
export default packing;
+10
View File
@@ -0,0 +1,10 @@
import type { TranslationStrings } from '../types';
const pdf: TranslationStrings = {
'pdf.travelPlan': '旅行計劃',
'pdf.planned': '已規劃',
'pdf.costLabel': '費用 EUR',
'pdf.preview': 'PDF 預覽',
'pdf.saveAsPdf': '儲存為 PDF',
};
export default pdf;
+51
View File
@@ -0,0 +1,51 @@
import type { TranslationStrings } from '../types';
const perm: TranslationStrings = {
'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': '誰可以建立或刪除公開分享連結',
};
export default perm;
+25
View File
@@ -0,0 +1,25 @@
import type { TranslationStrings } from '../types';
const photos: TranslationStrings = {
'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 張照片',
};
export default photos;
+92
View File
@@ -0,0 +1,92 @@
import type { TranslationStrings } from '../types';
const places: TranslationStrings = {
'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': 'Naver 列表',
'places.googleListHint': '貼上共享的 Google Maps 列表連結以匯入所有地點。',
'places.googleListImported': '已從"{list}"匯入 {count} 個地點',
'places.googleListError': 'Google Maps 列表匯入失敗',
'places.naverListHint': '貼上共享的 Naver Maps 列表連結以匯入所有地點。',
'places.naverListImported': '已從"{list}"匯入 {count} 個地點',
'places.naverListError': 'Naver Maps 列表匯入失敗',
'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': '儲存失敗',
'places.duplicateExists': "'{name}' 已在此行程中。",
'places.addAnyway': '仍要新增',
'places.enrichOnImport': '透過 Google 豐富地點資訊',
'places.enrichOnImportHint':
'查詢每個匯入的地點以補上照片、地址與聯絡資訊。需要 Google Maps 金鑰。',
};
export default places;
+67
View File
@@ -0,0 +1,67 @@
import type { TranslationStrings } from '../types';
const planner: TranslationStrings = {
'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} 天',
};
export default planner;
+25
View File
@@ -0,0 +1,25 @@
import type { TranslationStrings } from '../types';
const register: TranslationStrings = {
'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': '登入',
};
export default register;
+159
View File
@@ -0,0 +1,159 @@
import type { TranslationStrings } from '../types';
const reservations: TranslationStrings = {
'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.layover.route': '航線',
'reservations.layover.stop': '中轉站',
'reservations.layover.addStop': '新增中轉站',
'reservations.layover.connection': '轉乘航班',
'reservations.layover.layover': '轉機等候',
'reservations.needsReview': '待確認',
'reservations.needsReviewHint': '無法自動匹配機場 — 請確認位置。',
'reservations.searchLocation': '搜尋車站、港口、地址...',
'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.type.bus': '公車',
'reservations.type.ferry': '渡輪',
'reservations.type.bicycle': '自行車',
'reservations.type.taxi': '計程車',
'reservations.type.transport_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': '如:漢莎 LH123、阿德隆酒店...',
'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': '新增預訂',
'reservations.import.title': '匯入訂位確認',
'reservations.import.cta': '從檔案匯入',
'reservations.import.dropHere': '將訂位確認檔案拖放到此處,或點擊選擇',
'reservations.import.dropActive': '放開檔案以匯入',
'reservations.import.acceptedFormats': '支援格式:EML、PDF、PKPass、HTML、TXT(每個最大 10 MB,最多 5 個檔案)',
'reservations.import.parsing': '正在解析檔案…',
'reservations.import.previewHeading': '找到 {count} 筆預訂',
'reservations.import.previewEmpty': '無法從上傳的檔案中提取任何預訂資訊。',
'reservations.import.removeItem': '移除',
'reservations.import.confirm': '匯入 {count} 筆預訂',
'reservations.import.back': '返回',
'reservations.import.success': '已匯入 {count} 筆預訂',
'reservations.import.partialFailure': '已匯入 {created} 筆,{failed} 筆失敗',
'reservations.import.error': '解析失敗。請確保檔案是有效的訂位確認。',
'reservations.import.unavailable': '此伺服器上的預訂匯入功能不可用。',
'reservations.import.unsupportedFormat': '不支援的檔案格式。請使用 EML、PDF、PKPass、HTML 或 TXT。',
'reservations.import.fileTooLarge': '檔案「{name}」超過 10 MB 限制。',
'reservations.airtrail.title': '從 AirTrail 匯入',
'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': '已從 AirTrail 同步——編輯會雙向保持同步。',
'reservations.airtrail.notSynced': '未同步',
'reservations.airtrail.notSyncedHint': '此航班已在 AirTrail 中移除,不再同步。',
'reservations.airtrail.loadError': '無法載入你的 AirTrail 航班。',
'reservations.airtrail.imported': '已匯入 {count} 筆航班',
'reservations.airtrail.skippedDuplicate': '{count} 筆已在此行程中,已略過',
'reservations.airtrail.nothingImported': '沒有可匯入的項目。',
'reservations.airtrail.importError': '匯入失敗。請再試一次。',
'reservations.airtrail.undo': '從 AirTrail 匯入',
'reservations.airtrail.alreadyImported': '已匯入',
'reservations.airtrail.duringTrip': '行程期間',
'reservations.airtrail.otherFlights': '其他航班',
'reservations.airtrail.empty': '在你的 AirTrail 帳戶中找不到任何航班。',
'reservations.airtrail.importCta': '匯入 {count}',
};
export default reservations;
+331
View File
@@ -0,0 +1,331 @@
import type { TranslationStrings } from '../types';
const settings: TranslationStrings = {
'settings.title': '設定',
'settings.subtitle': '配置你的個人設定',
'settings.tabs.display': '顯示',
'settings.tabs.map': '地圖',
'settings.tabs.notifications': '通知',
'settings.tabs.integrations': '整合',
'settings.tabs.account': '帳戶',
'settings.tabs.offline': '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': '公開權杖 (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.blurBookingCodes': '模糊預訂程式碼',
'settings.optimizeFromAccommodation': '從住宿地點最佳化路線',
'settings.optimizeFromAccommodationHint':
'最佳化某一天的行程時,路線從你早上起床的飯店出發,並在你當晚入住的飯店結束。',
'settings.notifications': '通知',
'settings.notifyTripInvite': '旅行邀請',
'settings.notifyBookingChange': '預訂變更',
'settings.notifyTripReminder': '旅行提醒',
'settings.notifyTodoDue': '待辦事項即將到期',
'settings.notifyVacayInvite': 'Vacay 融合邀請',
'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':
'未配置通知渠道。請聯絡管理員設定電子郵件或 Webhook 通知。',
'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': '測試 Ntfy 通知傳送成功',
'settings.ntfyUrl.testFailed': '測試 Ntfy 通知失敗',
'settings.ntfyUrl.tokenCleared': '存取權杖已清除',
'settings.notificationsDisabled':
'通知尚未配置。請聯絡管理員啟用電子郵件或 Webhook 通知。',
'settings.notificationsActive': '活躍頻道',
'settings.notificationsManagedByAdmin': '通知事件由管理員配置。',
'settings.on': '開',
'settings.off': '關',
'settings.mcp.title': 'MCP 配置',
'settings.mcp.endpoint': 'MCP 端點',
'settings.mcp.clientConfig': '客戶端配置',
'settings.mcp.clientConfigHint':
'將 <your_token> 替換為下方列表中的 API 令牌。npx 的路徑可能需要根據您的系統進行調整(例如 Windows 上為 C:\\PROGRA~1\\nodejs\\npx.cmd)。',
'settings.mcp.clientConfigHintOAuth':
'將 <your_client_id> 和 <your_client_secret> 替換為上方建立的 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、我的 MCP 應用程式',
'settings.oauth.modal.redirectUris': '重新導向 URI',
'settings.oauth.modal.redirectUrisPlaceholder':
'https://your-app.com/callback\nhttps://your-app.com/auth',
'settings.oauth.modal.redirectUrisHint':
'每行一個 URI。需要 HTTPSlocalhost 除外)。需要完全符合。',
'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.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': '回報錯誤',
'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': 'No Return Ticket',
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
'settings.about.supporter.tier.businessClassDreamer':
'Business Class Dreamer',
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
'settings.about.description':
'TREK 是一款自架旅遊規劃器,幫助您從最初構想到最後回憶,整理每次旅行。日程規劃、預算、行李清單、照片及更多功能——全部集中在您自己的伺服器上。',
'settings.about.madeWith': '以',
'settings.about.madeBy': '由 Maurice 及不斷成長的開源社群製作。',
'settings.username': '使用者名稱',
'settings.email': '郵箱',
'settings.role': '角色',
'settings.roleAdmin': '管理員',
'settings.oidcLinked': '已關聯',
'settings.changePassword': '修改密碼',
'settings.mustChangePassword': '您必須更改密碼才能繼續。請在下方設定新密碼。',
'settings.currentPassword': '當前密碼',
'settings.currentPasswordRequired': '請輸入當前密碼',
'settings.newPassword': '新密碼',
'settings.confirmPassword': '確認新密碼',
'settings.updatePassword': '更新密碼',
'settings.passwordRequired': '請輸入當前密碼和新密碼',
'settings.passwordTooShort': '密碼至少需要 8 個字元',
'settings.passwordMismatch': '兩次輸入的密碼不一致',
'settings.passwordWeak': '密碼必須包含大寫字母、小寫字母、數字和特殊字元',
'settings.passwordChanged': '密碼修改成功',
'settings.deleteAccount': '刪除賬戶',
'settings.deleteAccountTitle': '確定刪除賬戶?',
'settings.deleteAccountWarning':
'你的賬戶以及所有旅行、地點和檔案將被永久刪除。此操作無法撤銷。',
'settings.deleteAccountConfirm': '永久刪除',
'settings.deleteBlockedTitle': '無法刪除',
'settings.deleteBlockedMessage':
'你是唯一的管理員。請先將其他使用者提升為管理員,然後再刪除賬戶。',
'settings.roleUser': '使用者',
'settings.saveProfile': '儲存資料',
'settings.mfa.title': '雙因素認證 (2FA)',
'settings.mfa.description':
'登入時新增第二步驗證。使用身份驗證器應用(Google Authenticator、Authy 等)。',
'settings.mfa.requiredByPolicy':
'管理員要求雙因素身份驗證。請先完成下方的身份驗證器設定後再繼續。',
'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': '使用應用掃描此二維碼,或手動輸入金鑰。',
'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': '雙因素認證已啟用',
'settings.mfa.toastDisabled': '雙因素認證已停用',
'settings.mfa.demoBlocked': '演示模式下不可用',
'settings.toast.mapSaved': '地圖設定已儲存',
'settings.toast.keysSaved': 'API 金鑰已儲存',
'settings.toast.displaySaved': '顯示設定已儲存',
'settings.toast.profileSaved': '資料已儲存',
'settings.uploadAvatar': '上傳頭像',
'settings.removeAvatar': '移除頭像',
'settings.avatarUploaded': '頭像已更新',
'settings.avatarRemoved': '頭像已移除',
'settings.avatarError': '上傳失敗',
'settings.bookingLabels': '預訂路線標籤',
'settings.bookingLabelsHint':
'在地圖上顯示車站 / 機場名稱。關閉時僅顯示圖示。',
"settings.currency": "Currency",
"settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.",
'settings.passkey.title': 'Passkey',
'settings.passkey.description':
'使用 Passkey 更快登入,並可抵禦網路釣魚——透過你的指紋、臉部、PIN 碼或硬體金鑰。你的密碼仍會保留作為備援。',
'settings.passkey.notConfigured':
'Passkey 已啟用,但此伺服器尚未完成設定。請聯絡管理員設定 WebAuthn 網域。',
'settings.passkey.add': '新增 Passkey',
'settings.passkey.addTitle': '新增 Passkey',
'settings.passkey.passwordPrompt': '請確認你目前的密碼,然後依照裝置提示操作。',
'settings.passkey.passwordRequired': '請輸入你目前的密碼。',
'settings.passkey.namePlaceholder': '名稱(選填,例如 "iPhone"',
'settings.passkey.addedToast': 'Passkey 已新增',
'settings.passkey.added': '已新增',
'settings.passkey.addError': '無法新增 Passkey',
'settings.passkey.cancelled': 'Passkey 設定已取消',
'settings.passkey.deleted': 'Passkey 已移除',
'settings.passkey.deleteConfirm': '要移除此 Passkey 嗎?請以密碼確認。',
'settings.passkey.rename': '重新命名',
'settings.passkey.defaultName': 'Passkey',
'settings.passkey.synced': '已同步',
'settings.passkey.deviceBound': '此裝置',
'settings.passkey.lastUsed': '上次使用',
'settings.passkey.neverUsed': '從未使用',
'settings.mapPoiPill': '在地圖上探索地點',
'settings.mapPoiPillHint': '在行程地圖上顯示分類標籤,透過 OpenStreetMap 尋找附近的餐廳、住宿等地點。',
'settings.airtrail.title': 'AirTrail',
'settings.airtrail.hint': '連接你自架的 AirTrail 以匯入及同步航班。在 AirTrail 的「設定 → 安全性」中建立 API 金鑰。',
'settings.airtrail.url': '執行個體網址',
'settings.airtrail.apiKey': 'API 金鑰',
'settings.airtrail.apiKeyPlaceholder': 'Bearer API 金鑰',
'settings.airtrail.apiKeyHint': '在 AirTrail 的「設定 → 安全性」中產生。以加密方式儲存。',
'settings.airtrail.allowInsecureTls': '允許自簽憑證',
'settings.airtrail.allowInsecureTlsHint': '僅在你自己網路上受信任的執行個體啟用。',
'settings.airtrail.connected': '已連接',
'settings.airtrail.notConnected': '未連接',
'settings.airtrail.toast.saved': '已儲存 AirTrail 連接',
'settings.airtrail.toast.saveError': '無法儲存連接',
'settings.airtrail.test.button': '測試連接',
'settings.airtrail.test.success': '已連接——找到 {count} 筆航班',
'settings.airtrail.test.failed': '連接失敗',
};
export default settings;
+16
View File
@@ -0,0 +1,16 @@
import type { TranslationStrings } from '../types';
const share: TranslationStrings = {
'share.linkTitle': '公開連結',
'share.linkHint':
'建立一個連結,任何人無需登入即可檢視此旅行。僅可檢視,無法編輯。',
'share.createLink': '建立連結',
'share.deleteLink': '刪除連結',
'share.createError': '無法建立連結',
'share.permMap': '地圖與計劃',
'share.permBookings': '預訂',
'share.permPacking': '行李',
'share.permBudget': '預算',
'share.permCollab': '聊天',
};
export default share;
+21
View File
@@ -0,0 +1,21 @@
import type { TranslationStrings } from '../types';
const shared: TranslationStrings = {
'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': '待確認',
};
export default shared;
+13
View File
@@ -0,0 +1,13 @@
import type { TranslationStrings } from '../types';
const stats: TranslationStrings = {
'stats.countries': '國家',
'stats.cities': '城市',
'stats.trips': '旅行',
'stats.places': '地點',
'stats.worldProgress': '全球進度',
'stats.visited': '已訪問',
'stats.remaining': '未訪問',
'stats.visitedCountries': '已訪問國家',
};
export default stats;
+50
View File
@@ -0,0 +1,50 @@
import type { TranslationStrings } from '../types';
const system_notice: TranslationStrings = {
'system_notice.welcome_v1.title': '歡迎使用 TREK',
'system_notice.welcome_v1.body':
'您的全方位旅遊規劃器。建立行程、與朋友分享旅遊,隨時保持條理分明——無論線上或離線皆可。',
'system_notice.welcome_v1.cta_label': '規劃行程',
'system_notice.welcome_v1.hero_alt': '風景優美的旅遊目的地與 TREK 介面',
'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] Test notice',
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
'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}',
'system_notice.v3_photos.title': '3.0 版相片已移至',
'system_notice.v3_photos.body':
'行程規劃器中的​**相片**標籤已被移除。您的相片安全— TREK 從未修改您的 Immich 或 Synology 相簿。\n\n相片現在位於 **Journey** 附加元件中。Journey 為選用 — 若尚未啟用,請聯絡管理員於 Admin → 附加元件 中開啟。',
'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_notice.v3_mcp.title': 'MCPOAuth 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_notice.v3_thankyou.title': '來自我的一封私人信',
'system_notice.v3_thankyou.body':
'在你繼續之前——我想停下來說幾句。\n\nTREK 最初只是我為自己的旅行而做的一個業餘專案。我從未想過它會成長為 4,000 人信賴的冒險規劃工具。每一顆星標、每一個 issue、每一個功能請求——我都會讀,它們在全職工作和大學學業之間的深夜裡支撐著我繼續前行。\n\n我想讓你們知道:TREK 將永遠開源,永遠可自託管,永遠屬於你們。沒有追蹤,沒有訂閱,沒有任何附加條件。只是一個熱愛旅行的人為同樣熱愛旅行的你們打造的工具。\n\n特別感謝 [jubnl](https://github.com/jubnl)——你已經成為一位不可思議的合作者。3.0 版本中許多精彩之處都留下了你的印記。感謝你在這個專案還很粗糙的時候就選擇了相信它。\n\n也感謝你們每一位——回報了 bug、翻譯了文字、向朋友分享了 TREK,或者只是用它規劃了一次旅行——**謝謝你們**。你們是這一切存在的原因。\n\n願我們一起踏上更多的冒險旅程。\n\n— Maurice\n\n---\n\n[加入 Discord 社群](https://discord.gg/7Q6M6jDwzf)\n\n如果 TREK 讓你的旅行更美好,一杯[小小的咖啡](https://ko-fi.com/mauriceboe)能讓這盞燈一直亮著。',
'system_notice.v3014_whitespace_collision.title': '需要操作:使用者帳戶衝突',
'system_notice.v3014_whitespace_collision.body':
'3.0.14 版本升級偵測到一個或多個由儲存帳戶中前後空白字元引發的使用者名稱或電子郵件衝突。受影響的帳戶已自動重新命名。請檢查伺服器日誌中以 **[migration] WHITESPACE COLLISION** 開頭的行,以確認哪些帳戶需要審查。',
};
export default system_notice;
+40
View File
@@ -0,0 +1,40 @@
import type { TranslationStrings } from '../types';
const todo: TranslationStrings = {
'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': '新增',
'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': '建立任務',
};
export default todo;
+10
View File
@@ -0,0 +1,10 @@
import type { TranslationStrings } from '../types';
const transport: TranslationStrings = {
'transport.addTransport': '新增交通',
'transport.modalTitle.create': '新增交通',
'transport.modalTitle.edit': '編輯交通',
'transport.title': '交通',
'transport.addManual': '手動新增交通',
};
export default transport;
+31
View File
@@ -0,0 +1,31 @@
import type { TranslationStrings } from '../types';
const trip: TranslationStrings = {
'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': "Costs",
'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} 個地點',
};
export default trip;
+17
View File
@@ -0,0 +1,17 @@
import type { TranslationStrings } from '../types';
const trips: TranslationStrings = {
'trips.memberRemoved': '{username} 已移除',
'trips.memberRemoveError': '移除失敗',
'trips.memberAdded': '{username} 已新增',
'trips.memberAddError': '新增失敗',
'trips.reminder': '提醒',
'trips.reminderNone': '無',
'trips.reminderDay': '天',
'trips.reminderDays': '天',
'trips.reminderCustom': '自定義',
'trips.reminderDaysBefore': '天前提醒',
'trips.reminderDisabledHint':
'旅行提醒已停用。請在管理 > 設定 > 通知中啟用。',
};
export default trips;
+22
View File
@@ -0,0 +1,22 @@
import type { TranslationStrings } from '../types';
const undo: TranslationStrings = {
'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}',
'undo.importBooking': '匯入訂位確認',
};
export default undo;
+93
View File
@@ -0,0 +1,93 @@
import type { TranslationStrings } from '../types';
const vacay: TranslationStrings = {
'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.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': '任何一方都可以隨時解除合併。你的記錄將被保留。',
'vacay.addCalendar': '新增日曆',
'vacay.calendarColor': '顏色',
'vacay.calendarLabel': '標籤',
'vacay.noCalendars': '無日曆',
};
export default vacay;