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
+67
View File
@@ -0,0 +1,67 @@
import {
adminUserCreateRequestSchema,
adminPermissionsRequestSchema,
adminInviteCreateRequestSchema,
adminFeatureToggleRequestSchema,
} from './admin.schema';
import { describe, it, expect } from 'vitest';
describe('adminUserCreateRequestSchema', () => {
it('requires an email; role limited to user/admin', () => {
expect(
adminUserCreateRequestSchema.safeParse({
email: 'a@b.c',
password: 'p',
role: 'admin',
}).success,
).toBe(true);
expect(
adminUserCreateRequestSchema.safeParse({ email: 'a@b.c' }).success,
).toBe(true);
expect(
adminUserCreateRequestSchema.safeParse({ password: 'p' }).success,
).toBe(false);
expect(
adminUserCreateRequestSchema.safeParse({ email: 'a@b.c', role: 'root' })
.success,
).toBe(false);
});
});
describe('adminPermissionsRequestSchema', () => {
it('requires a permissions record', () => {
expect(
adminPermissionsRequestSchema.safeParse({
permissions: { trip_edit: { user: true } },
}).success,
).toBe(true);
expect(adminPermissionsRequestSchema.safeParse({}).success).toBe(false);
});
});
describe('adminInviteCreateRequestSchema', () => {
it('accepts optional uses/expiry/role', () => {
expect(
adminInviteCreateRequestSchema.safeParse({
max_uses: 5,
expires_in_days: 7,
}).success,
).toBe(true);
expect(adminInviteCreateRequestSchema.safeParse({}).success).toBe(true);
expect(
adminInviteCreateRequestSchema.safeParse({ role: 'root' }).success,
).toBe(false);
});
});
describe('adminFeatureToggleRequestSchema', () => {
it('requires a boolean enabled', () => {
expect(
adminFeatureToggleRequestSchema.safeParse({ enabled: true }).success,
).toBe(true);
expect(
adminFeatureToggleRequestSchema.safeParse({ enabled: 'yes' }).success,
).toBe(false);
});
});
+42
View File
@@ -0,0 +1,42 @@
import { z } from 'zod';
/**
* Admin API contract for /api/admin (admin-only).
*
* The admin service validates most bodies itself (returning {error,status}), so
* these schemas pin the well-defined ones: user create/update, the permission
* matrix, invites and the boolean feature toggles. Free-form bodies (OIDC
* settings, addon config, default user settings) stay with the service.
*/
export const adminUserCreateRequestSchema = z.object({
email: z.string(),
password: z.string().optional(),
username: z.string().optional(),
role: z.enum(['user', 'admin']).optional(),
});
export type AdminUserCreateRequest = z.infer<
typeof adminUserCreateRequestSchema
>;
export const adminPermissionsRequestSchema = z.object({
permissions: z.record(z.string(), z.unknown()),
});
export type AdminPermissionsRequest = z.infer<
typeof adminPermissionsRequestSchema
>;
export const adminInviteCreateRequestSchema = z.object({
max_uses: z.number().optional(),
expires_in_days: z.number().optional(),
role: z.enum(['user', 'admin']).optional(),
});
export type AdminInviteCreateRequest = z.infer<
typeof adminInviteCreateRequestSchema
>;
export const adminFeatureToggleRequestSchema = z.object({
enabled: z.boolean(),
});
export type AdminFeatureToggleRequest = z.infer<
typeof adminFeatureToggleRequestSchema
>;
+41
View File
@@ -0,0 +1,41 @@
import { airportSchema, airportSearchQuerySchema } from './airport.schema';
import { describe, it, expect } from 'vitest';
describe('airportSchema', () => {
it('accepts a full airport record', () => {
const parsed = airportSchema.parse({
iata: 'BER',
icao: 'EDDB',
name: 'Berlin Brandenburg',
city: 'Berlin',
country: 'DE',
lat: 52.36,
lng: 13.5,
tz: 'Europe/Berlin',
});
expect(parsed.iata).toBe('BER');
});
it('allows a null icao (smaller fields can be missing one)', () => {
expect(
airportSchema.safeParse({
iata: 'XXX',
icao: null,
name: 'Test',
city: 'Test',
country: 'DE',
lat: 0,
lng: 0,
tz: 'UTC',
}).success,
).toBe(true);
});
});
describe('airportSearchQuerySchema', () => {
it('treats the query as optional (the route answers [] when absent)', () => {
expect(airportSearchQuerySchema.parse({})).toEqual({});
expect(airportSearchQuerySchema.parse({ q: 'ber' })).toEqual({ q: 'ber' });
});
});
+37
View File
@@ -0,0 +1,37 @@
import { z } from 'zod';
/**
* Airport API contract — single source of truth for the /api/airports endpoints.
*
* The legacy Express route (server/src/routes/airports.ts) exposes a typeahead
* search and a single-airport lookup by IATA code, both backed by an in-memory
* dataset (server/src/services/airportService.ts). The route treats the query as
* an opaque string and returns an empty array when it is absent, so the search
* query mirrors that: an optional string, no coercion.
*
* The bespoke 404 `{ error: 'Airport not found' }` body is reproduced in the
* controller, not derived from this schema, so the response stays byte-identical
* to Express.
*/
/** A single airport record as served by the dataset (matches Airport in airportService). */
export const airportSchema = z.object({
iata: z.string(),
icao: z.string().nullable(),
name: z.string(),
city: z.string(),
country: z.string(),
lat: z.number(),
lng: z.number(),
tz: z.string(),
});
export type Airport = z.infer<typeof airportSchema>;
/**
* Search query. `q` is optional — the route answers with `[]` when it is missing
* or empty rather than 400ing, so presence is handled in the controller.
*/
export const airportSearchQuerySchema = z.object({
q: z.string().optional(),
});
export type AirportSearchQuery = z.infer<typeof airportSearchQuerySchema>;
+79
View File
@@ -0,0 +1,79 @@
import { z } from 'zod';
/**
* AirTrail integration contracts (#214).
*
* AirTrail is a self-hosted flight tracker (github.com/johanohly/AirTrail).
* The connection is per-user (Settings → Integrations); the global on/off is the
* `airtrail` addon. Each user stores their instance URL + a personal Bearer API
* key, which only ever exposes that user's own flights.
*/
// ── Per-user connection ──────────────────────────────────────────────────────
/** Placeholder the server returns instead of the real key once one is stored. */
export const AIRTRAIL_KEY_MASK = '••••••••';
export const airtrailSettingsSchema = z.object({
/** Instance origin, e.g. https://flights.example.com — TREK appends /api itself. */
url: z.string().trim().max(2048),
/** Bearer API key. Omitted / blank / the mask keeps the stored key unchanged. */
apiKey: z.string().max(512).optional(),
/** Allow self-signed TLS certs (common on LAN instances). */
allowInsecureTls: z.boolean().optional().default(false),
});
export type AirtrailSettings = z.infer<typeof airtrailSettingsSchema>;
export const airtrailConnectionSchema = z.object({
url: z.string(),
apiKeyMasked: z.string(),
allowInsecureTls: z.boolean(),
connected: z.boolean(),
});
export type AirtrailConnection = z.infer<typeof airtrailConnectionSchema>;
export const airtrailStatusSchema = z.object({
connected: z.boolean(),
flightCount: z.number().optional(),
error: z.string().optional(),
});
export type AirtrailStatus = z.infer<typeof airtrailStatusSchema>;
// ── Flight list (picker) ─────────────────────────────────────────────────────
/** A normalized AirTrail flight as surfaced to the import picker. */
export const airtrailFlightSchema = z.object({
id: z.string(),
fromCode: z.string().nullable(),
fromName: z.string().nullable(),
toCode: z.string().nullable(),
toName: z.string().nullable(),
date: z.string().nullable(),
departure: z.string().nullable(),
arrival: z.string().nullable(),
airline: z.string().nullable(),
flightNumber: z.string().nullable(),
aircraft: z.string().nullable(),
seatClass: z.string().nullable(),
});
export type AirtrailFlight = z.infer<typeof airtrailFlightSchema>;
// ── Import ───────────────────────────────────────────────────────────────────
export const airtrailImportSchema = z.object({
flightIds: z.array(z.string()).min(1, 'Select at least one flight'),
});
export type AirtrailImport = z.infer<typeof airtrailImportSchema>;
/** Per-flight outcome of an import (so the picker can show what was skipped). */
export const airtrailImportResultSchema = z.object({
imported: z.array(z.string()),
skipped: z.array(
z.object({
flightId: z.string(),
reason: z.enum(['already-imported', 'already-in-trip', 'invalid']),
detail: z.string().optional(),
}),
),
});
export type AirtrailImportResult = z.infer<typeof airtrailImportResultSchema>;
@@ -0,0 +1,45 @@
import {
assignmentCreateRequestSchema,
assignmentMoveRequestSchema,
assignmentParticipantsRequestSchema,
} from './assignment.schema';
import { describe, it, expect } from 'vitest';
describe('assignmentCreateRequestSchema', () => {
it('requires a place_id; notes optional/nullable', () => {
expect(
assignmentCreateRequestSchema.safeParse({ place_id: 2 }).success,
).toBe(true);
expect(
assignmentCreateRequestSchema.safeParse({ place_id: '2', notes: null })
.success,
).toBe(true);
expect(assignmentCreateRequestSchema.safeParse({}).success).toBe(false);
});
});
describe('assignmentMoveRequestSchema', () => {
it('requires new_day_id; order_index optional', () => {
expect(
assignmentMoveRequestSchema.safeParse({ new_day_id: 4 }).success,
).toBe(true);
expect(
assignmentMoveRequestSchema.safeParse({ new_day_id: 4, order_index: 0 })
.success,
).toBe(true);
expect(assignmentMoveRequestSchema.safeParse({}).success).toBe(false);
});
});
describe('assignmentParticipantsRequestSchema', () => {
it('requires a numeric user_ids array', () => {
expect(
assignmentParticipantsRequestSchema.safeParse({ user_ids: [1, 2] })
.success,
).toBe(true);
expect(
assignmentParticipantsRequestSchema.safeParse({ user_ids: 'no' }).success,
).toBe(false);
});
});
@@ -0,0 +1,80 @@
import { assignmentPlaceSchema } from '../place/place.schema';
import { z } from 'zod';
/**
* Assignment API contract — single source of truth for the place↔day itinerary
* endpoints under /api/trips/:tripId/days/:dayId/assignments and
* /api/trips/:tripId/assignments/:id/*.
*
* Trip-scoped; mutations use the 'day_edit' permission. The legacy route
* (server/src/routes/assignments.ts, mounted on /api) wraps assignmentService.
* Assignment rows carry joined place data and are kept open in responses; the
* request schemas + the bespoke 404/400 controller messages pin the rest.
*/
/**
* Assignment participant embedded on an assignment
* (server/src/services/queryHelpers.ts -> loadParticipantsByAssignmentIds).
*/
export const assignmentParticipantSchema = z.object({
user_id: z.number(),
username: z.string(),
avatar: z.string().nullable().optional(),
});
export type AssignmentParticipant = z.infer<typeof assignmentParticipantSchema>;
/**
* Assignment entity as returned by the day/assignment endpoints
* (server/src/services/queryHelpers.ts -> formatAssignmentWithPlace, and
* assignmentService.getAssignmentWithPlace). The embedded `place` is the trimmed
* assignment-place projection, NOT the full place pool entity. `assignment_time`
* /`assignment_end_time` carry the per-assignment override times.
*/
export const assignmentSchema = z.object({
id: z.number(),
day_id: z.number(),
place_id: z.number(),
order_index: z.number(),
notes: z.string().nullable().optional(),
assignment_time: z.string().nullable().optional(),
assignment_end_time: z.string().nullable().optional(),
participants: z.array(assignmentParticipantSchema).optional(),
created_at: z.string().optional(),
place: assignmentPlaceSchema,
});
export type Assignment = z.infer<typeof assignmentSchema>;
export const assignmentCreateRequestSchema = z.object({
place_id: z.union([z.number(), z.string()]),
notes: z.string().nullable().optional(),
});
export type AssignmentCreateRequest = z.infer<
typeof assignmentCreateRequestSchema
>;
export const assignmentReorderRequestSchema = z.object({
orderedIds: z.array(z.number()),
});
export type AssignmentReorderRequest = z.infer<
typeof assignmentReorderRequestSchema
>;
export const assignmentMoveRequestSchema = z.object({
new_day_id: z.union([z.number(), z.string()]),
order_index: z.number().optional(),
});
export type AssignmentMoveRequest = z.infer<typeof assignmentMoveRequestSchema>;
export const assignmentTimeRequestSchema = z.object({
place_time: z.string().nullable().optional(),
end_time: z.string().nullable().optional(),
});
export type AssignmentTimeRequest = z.infer<typeof assignmentTimeRequestSchema>;
export const assignmentParticipantsRequestSchema = z.object({
user_ids: z.array(z.number()),
});
export type AssignmentParticipantsRequest = z.infer<
typeof assignmentParticipantsRequestSchema
>;
+54
View File
@@ -0,0 +1,54 @@
import {
markRegionRequestSchema,
createBucketItemRequestSchema,
regionGeoSchema,
} from './atlas.schema';
import { describe, it, expect } from 'vitest';
describe('markRegionRequestSchema', () => {
it('requires both name and country_code', () => {
expect(
markRegionRequestSchema.safeParse({ name: 'Bavaria', country_code: 'DE' })
.success,
).toBe(true);
expect(markRegionRequestSchema.safeParse({ name: 'Bavaria' }).success).toBe(
false,
);
});
});
describe('createBucketItemRequestSchema', () => {
it('requires a name; coordinates and metadata optional/nullable', () => {
expect(
createBucketItemRequestSchema.safeParse({ name: 'Tokyo' }).success,
).toBe(true);
expect(
createBucketItemRequestSchema.safeParse({
name: 'Tokyo',
lat: 35,
lng: 139,
country_code: null,
}).success,
).toBe(true);
expect(createBucketItemRequestSchema.safeParse({}).success).toBe(false);
});
});
describe('regionGeoSchema', () => {
it('accepts a FeatureCollection with opaque features', () => {
expect(
regionGeoSchema.safeParse({ type: 'FeatureCollection', features: [] })
.success,
).toBe(true);
expect(
regionGeoSchema.safeParse({
type: 'FeatureCollection',
features: [{ anything: true }],
}).success,
).toBe(true);
expect(
regionGeoSchema.safeParse({ type: 'Other', features: [] }).success,
).toBe(false);
});
});
+61
View File
@@ -0,0 +1,61 @@
import { z } from 'zod';
/**
* Atlas API contract — single source of truth for the /api/addons/atlas endpoints
* (visited countries/regions, region GeoJSON, and the travel bucket list).
*
* Parity note: unlike the journey addon, the legacy atlas route is NOT gated by
* an addon-enabled check (app.ts mounts it without one), so the migration does
* not add a gate either — adding one would be a breaking 404.
*
* Stats, visited-regions and GeoJSON are wide, externally-derived shapes kept as
* open records; the request schemas and the bespoke 400/404 controller messages
* pin the parts the client depends on.
*/
const open = z.record(z.string(), z.unknown());
export const markRegionRequestSchema = z.object({
name: z.string().min(1),
country_code: z.string().min(1),
});
export type MarkRegionRequest = z.infer<typeof markRegionRequestSchema>;
export const createBucketItemRequestSchema = z.object({
name: z.string().min(1),
lat: z.number().nullable().optional(),
lng: z.number().nullable().optional(),
country_code: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
target_date: z.string().nullable().optional(),
});
export type CreateBucketItemRequest = z.infer<
typeof createBucketItemRequestSchema
>;
export const updateBucketItemRequestSchema = z.object({
name: z.string().optional(),
notes: z.string().optional(),
lat: z.number().nullable().optional(),
lng: z.number().nullable().optional(),
country_code: z.string().nullable().optional(),
target_date: z.string().nullable().optional(),
});
export type UpdateBucketItemRequest = z.infer<
typeof updateBucketItemRequestSchema
>;
/** A bucket-list item row (DB-shaped; kept open). */
export const bucketItemSchema = open;
export const bucketListResponseSchema = z.object({
items: z.array(bucketItemSchema),
});
export type BucketListResponse = z.infer<typeof bucketListResponseSchema>;
/** GeoJSON FeatureCollection (kept open — provider-derived geometry). */
export const regionGeoSchema = z.object({
type: z.literal('FeatureCollection'),
features: z.array(z.unknown()),
});
export type RegionGeo = z.infer<typeof regionGeoSchema>;
+92
View File
@@ -0,0 +1,92 @@
import {
registerRequestSchema,
loginRequestSchema,
forgotPasswordRequestSchema,
resetPasswordRequestSchema,
changePasswordRequestSchema,
mfaVerifyLoginRequestSchema,
mfaEnableRequestSchema,
mcpTokenCreateRequestSchema,
} from './auth.schema';
import { describe, it, expect } from 'vitest';
describe('registerRequestSchema', () => {
it('requires email + password; username/invite optional', () => {
expect(
registerRequestSchema.safeParse({ email: 'a@b.c', password: 'pw' })
.success,
).toBe(true);
expect(
registerRequestSchema.safeParse({
email: 'a@b.c',
password: 'pw',
invite_token: 't',
}).success,
).toBe(true);
expect(registerRequestSchema.safeParse({ email: 'a@b.c' }).success).toBe(
false,
);
});
});
describe('loginRequestSchema', () => {
it('requires email + password', () => {
expect(
loginRequestSchema.safeParse({ email: 'a@b.c', password: 'pw' }).success,
).toBe(true);
expect(loginRequestSchema.safeParse({ email: 'a@b.c' }).success).toBe(
false,
);
});
});
describe('forgot/reset/change password schemas', () => {
it('validate their required fields', () => {
expect(
forgotPasswordRequestSchema.safeParse({ email: 'a@b.c' }).success,
).toBe(true);
expect(
resetPasswordRequestSchema.safeParse({ token: 't', new_password: 'pw' })
.success,
).toBe(true);
expect(
resetPasswordRequestSchema.safeParse({
token: 't',
new_password: 'pw',
mfa_code: '123456',
}).success,
).toBe(true);
expect(
resetPasswordRequestSchema.safeParse({ new_password: 'pw' }).success,
).toBe(false);
expect(
changePasswordRequestSchema.safeParse({
current_password: 'a',
new_password: 'b',
}).success,
).toBe(true);
expect(
changePasswordRequestSchema.safeParse({ new_password: 'b' }).success,
).toBe(false);
});
});
describe('mfa + mcp-token schemas', () => {
it('validate their fields', () => {
expect(
mfaVerifyLoginRequestSchema.safeParse({ mfa_token: 't', code: '123456' })
.success,
).toBe(true);
expect(
mfaVerifyLoginRequestSchema.safeParse({ mfa_token: 't' }).success,
).toBe(false);
expect(mfaEnableRequestSchema.safeParse({ code: '123456' }).success).toBe(
true,
);
expect(mcpTokenCreateRequestSchema.safeParse({ name: 'CLI' }).success).toBe(
true,
);
expect(mcpTokenCreateRequestSchema.safeParse({}).success).toBe(true);
});
});
+66
View File
@@ -0,0 +1,66 @@
import { z } from 'zod';
/**
* Auth API contract for /api/auth.
*
* The auth service does the heavy credential/MFA validation internally (and
* returns its own {error,status}); these schemas pin the well-defined request
* bodies the public + account endpoints accept. Login/reset can branch to an
* MFA step, so password fields stay permissive where the service owns the rules.
*/
export const registerRequestSchema = z.object({
email: z.string(),
password: z.string(),
username: z.string().optional(),
invite_token: z.string().optional(),
});
export type RegisterRequest = z.infer<typeof registerRequestSchema>;
export const loginRequestSchema = z.object({
email: z.string(),
password: z.string(),
// "Remember me" — when true the server issues a longer-lived
// (SESSION_DURATION_REMEMBER) JWT + persistent cookie; when false/absent the
// session lasts SESSION_DURATION and the cookie is a browser-session cookie.
remember_me: z.boolean().optional(),
});
export type LoginRequest = z.infer<typeof loginRequestSchema>;
export const forgotPasswordRequestSchema = z.object({
email: z.string(),
});
export type ForgotPasswordRequest = z.infer<typeof forgotPasswordRequestSchema>;
export const resetPasswordRequestSchema = z.object({
token: z.string(),
// The client sends `new_password` and the service reads `body.new_password`;
// the field was misnamed `password` here, which broke the client's typing.
new_password: z.string(),
mfa_code: z.string().optional(),
});
export type ResetPasswordRequest = z.infer<typeof resetPasswordRequestSchema>;
export const changePasswordRequestSchema = z.object({
current_password: z.string(),
new_password: z.string(),
});
export type ChangePasswordRequest = z.infer<typeof changePasswordRequestSchema>;
export const mfaVerifyLoginRequestSchema = z.object({
mfa_token: z.string(),
code: z.string(),
// Carries the login-form "Remember me" choice through the second (MFA) leg,
// since the session token is only minted once the MFA code is verified.
remember_me: z.boolean().optional(),
});
export type MfaVerifyLoginRequest = z.infer<typeof mfaVerifyLoginRequestSchema>;
export const mfaEnableRequestSchema = z.object({
code: z.string(),
});
export type MfaEnableRequest = z.infer<typeof mfaEnableRequestSchema>;
export const mcpTokenCreateRequestSchema = z.object({
name: z.string().optional(),
});
export type McpTokenCreateRequest = z.infer<typeof mcpTokenCreateRequestSchema>;
+26
View File
@@ -0,0 +1,26 @@
import { autoBackupSettingsRequestSchema } from './backup.schema';
import { describe, it, expect } from 'vitest';
describe('autoBackupSettingsRequestSchema', () => {
it('accepts the known toggles and stays permissive for extras', () => {
expect(
autoBackupSettingsRequestSchema.safeParse({
enabled: true,
interval: 'daily',
keep_days: 7,
}).success,
).toBe(true);
expect(
autoBackupSettingsRequestSchema.safeParse({ enabled: false, foo: 'bar' })
.success,
).toBe(true);
expect(autoBackupSettingsRequestSchema.safeParse({}).success).toBe(true);
});
it('rejects a non-boolean enabled', () => {
expect(
autoBackupSettingsRequestSchema.safeParse({ enabled: 'yes' }).success,
).toBe(false);
});
});
+21
View File
@@ -0,0 +1,21 @@
import { z } from 'zod';
/**
* Backup API contract (admin-only) for /api/backup.
*
* The auto-backup settings body is normalised server-side by the backup
* service (parseAutoBackupBody), so this schema only pins the well-known toggle
* fields and stays permissive (passthrough) for the rest. Create/restore/delete
* carry no JSON body; their inputs are the :filename path param + the upload.
*/
export const autoBackupSettingsRequestSchema = z
.object({
enabled: z.boolean().optional(),
interval: z.string().optional(),
keep_days: z.union([z.string(), z.number()]).optional(),
time: z.string().optional(),
})
.passthrough();
export type AutoBackupSettingsRequest = z.infer<
typeof autoBackupSettingsRequestSchema
>;
+58
View File
@@ -0,0 +1,58 @@
import {
budgetCreateItemRequestSchema,
budgetUpdateMembersRequestSchema,
budgetToggleMemberPaidRequestSchema,
budgetReorderItemsRequestSchema,
} from './budget.schema';
import { describe, it, expect } from 'vitest';
describe('budgetCreateItemRequestSchema', () => {
it('requires a name; money/meta fields optional + nullable', () => {
expect(
budgetCreateItemRequestSchema.safeParse({ name: 'Hotel' }).success,
).toBe(true);
expect(
budgetCreateItemRequestSchema.safeParse({
name: 'Hotel',
total_price: 200,
persons: null,
}).success,
).toBe(true);
expect(budgetCreateItemRequestSchema.safeParse({}).success).toBe(false);
});
});
describe('budgetUpdateMembersRequestSchema', () => {
it('requires a numeric user_ids array', () => {
expect(
budgetUpdateMembersRequestSchema.safeParse({ user_ids: [1, 2] }).success,
).toBe(true);
expect(
budgetUpdateMembersRequestSchema.safeParse({ user_ids: 'no' }).success,
).toBe(false);
});
});
describe('budgetToggleMemberPaidRequestSchema', () => {
it('requires a boolean paid', () => {
expect(
budgetToggleMemberPaidRequestSchema.safeParse({ paid: true }).success,
).toBe(true);
expect(
budgetToggleMemberPaidRequestSchema.safeParse({ paid: 'yes' }).success,
).toBe(false);
});
});
describe('budgetReorderItemsRequestSchema', () => {
it('requires numeric ids', () => {
expect(
budgetReorderItemsRequestSchema.safeParse({ orderedIds: [3, 1, 2] })
.success,
).toBe(true);
expect(
budgetReorderItemsRequestSchema.safeParse({ orderedIds: ['a'] }).success,
).toBe(false);
});
});
+201
View File
@@ -0,0 +1,201 @@
import { z } from 'zod';
/**
* Budget API contract — single source of truth for the /api/trips/:tripId/budget
* endpoints (expense items, per-member splits, paid toggles, settlement).
*
* Trip-scoped: every endpoint verifies trip access (404 "Trip not found") and
* mutations check the 'budget_edit' permission (403 "No permission"). The legacy
* route (server/src/routes/budget.ts) wraps services/budgetService.ts; rows are
* DB-shaped and kept open. Mutations broadcast over WebSocket with the forwarded
* X-Socket-Id. Updating a linked item's total_price also syncs the price into the
* linked reservation's metadata (and broadcasts reservation:updated).
*/
/**
* Budget item member as embedded on a budget item
* (server/src/services/budgetService.ts -> loadItemMembers). `paid` is the raw
* SQLite INTEGER (0/1); `avatar_url` is the resolved avatar (avatarUrl()).
*/
export const budgetItemMemberSchema = z.object({
user_id: z.number(),
paid: z.number(),
username: z.string(),
avatar_url: z.string().nullable().optional(),
avatar: z.string().nullable().optional(),
budget_item_id: z.number().optional(),
});
export type BudgetItemMember = z.infer<typeof budgetItemMemberSchema>;
/**
* The fixed "Costs" expense categories. Unlike the old budget, users cannot
* create their own categories — every expense maps to one of these keys. The
* label/icon/colour per key live in the client; the server only stores the key.
* Pre-rework rows used free-text categories; those are shown as `other`.
*/
export const COST_CATEGORIES = [
'accommodation',
'food',
'groceries',
'transport',
'flights',
'activities',
'sightseeing',
'shopping',
'fees',
'health',
'tips',
'other',
] as const;
export type CostCategory = (typeof COST_CATEGORIES)[number];
/**
* One payer of an expense — a row of budget_item_payers. `amount` is in the
* expense's own currency (budget_items.currency). Several payers can split who
* actually paid one bill. Username/avatar are joined for display.
*/
export const budgetItemPayerSchema = z.object({
user_id: z.number(),
amount: z.number(),
username: z.string().optional(),
avatar_url: z.string().nullable().optional(),
avatar: z.string().nullable().optional(),
budget_item_id: z.number().optional(),
});
export type BudgetItemPayer = z.infer<typeof budgetItemPayerSchema>;
/**
* Budget item entity as returned by the budget list/create/update endpoints
* (server/src/services/budgetService.ts). Columns of the `budget_items` table
* plus the embedded `members` (equal-split participants) and `payers` arrays.
* total_price is the sum of payer amounts in `currency`; `exchange_rate` converts
* that to the trip base currency (NULL currency + rate 1 = base currency).
*/
export const budgetItemSchema = z.object({
id: z.number(),
trip_id: z.number(),
category: z.string(),
name: z.string(),
total_price: z.number(),
currency: z.string().nullable().optional(),
exchange_rate: z.number().optional(),
persons: z.number().nullable().optional(),
days: z.number().nullable().optional(),
note: z.string().nullable().optional(),
reservation_id: z.number().nullable().optional(),
paid_by_user_id: z.number().nullable().optional(),
expense_date: z.string().nullable().optional(),
sort_order: z.number().optional(),
created_at: z.string().optional(),
members: z.array(budgetItemMemberSchema).optional(),
payers: z.array(budgetItemPayerSchema).optional(),
});
export type BudgetItem = z.infer<typeof budgetItemSchema>;
const payerInputSchema = z.object({
user_id: z.number(),
amount: z.number(),
});
export const budgetCreateItemRequestSchema = z.object({
name: z.string().min(1),
category: z.string().optional(),
total_price: z.number().optional(),
currency: z.string().nullable().optional(),
exchange_rate: z.number().optional(),
// Multi-payer: who paid how much (in the expense currency). When omitted, the
// server falls back to total_price with no explicit payer.
payers: z.array(payerInputSchema).optional(),
// Equal-split participants. When omitted, the item has no split (planning-only).
member_ids: z.array(z.number()).optional(),
persons: z.number().nullable().optional(),
days: z.number().nullable().optional(),
note: z.string().nullable().optional(),
expense_date: z.string().nullable().optional(),
});
export type BudgetCreateItemRequest = z.infer<
typeof budgetCreateItemRequestSchema
>;
/** Update accepts the same fields plus total_price changes; all optional. */
export const budgetUpdateItemRequestSchema = z.object({
name: z.string().optional(),
category: z.string().optional(),
total_price: z.number().optional(),
currency: z.string().nullable().optional(),
exchange_rate: z.number().optional(),
payers: z.array(payerInputSchema).optional(),
member_ids: z.array(z.number()).optional(),
persons: z.number().nullable().optional(),
days: z.number().nullable().optional(),
note: z.string().nullable().optional(),
expense_date: z.string().nullable().optional(),
});
export type BudgetUpdateItemRequest = z.infer<
typeof budgetUpdateItemRequestSchema
>;
/** Replace the explicit payers of an expense (amounts in expense currency). */
export const budgetUpdatePayersRequestSchema = z.object({
payers: z.array(payerInputSchema),
});
export type BudgetUpdatePayersRequest = z.infer<
typeof budgetUpdatePayersRequestSchema
>;
/**
* A persisted settle-up transfer (budget_settlements row): "from paid to" a
* given amount in the trip base currency. Creating one marks a suggested flow as
* paid; deleting it (undo) brings the flow back. Names joined for display.
*/
export const budgetSettlementSchema = z.object({
id: z.number(),
trip_id: z.number(),
from_user_id: z.number(),
to_user_id: z.number(),
amount: z.number(),
created_at: z.string().optional(),
created_by_user_id: z.number().nullable().optional(),
from_username: z.string().optional(),
from_avatar_url: z.string().nullable().optional(),
to_username: z.string().optional(),
to_avatar_url: z.string().nullable().optional(),
});
export type BudgetSettlement = z.infer<typeof budgetSettlementSchema>;
export const budgetCreateSettlementRequestSchema = z.object({
from_user_id: z.number(),
to_user_id: z.number(),
amount: z.number(),
});
export type BudgetCreateSettlementRequest = z.infer<
typeof budgetCreateSettlementRequestSchema
>;
export const budgetUpdateMembersRequestSchema = z.object({
user_ids: z.array(z.number()),
});
export type BudgetUpdateMembersRequest = z.infer<
typeof budgetUpdateMembersRequestSchema
>;
export const budgetToggleMemberPaidRequestSchema = z.object({
paid: z.boolean(),
});
export type BudgetToggleMemberPaidRequest = z.infer<
typeof budgetToggleMemberPaidRequestSchema
>;
export const budgetReorderItemsRequestSchema = z.object({
orderedIds: z.array(z.number()),
});
export type BudgetReorderItemsRequest = z.infer<
typeof budgetReorderItemsRequestSchema
>;
export const budgetReorderCategoriesRequestSchema = z.object({
orderedCategories: z.array(z.string()),
});
export type BudgetReorderCategoriesRequest = z.infer<
typeof budgetReorderCategoriesRequestSchema
>;
@@ -0,0 +1,41 @@
import {
categorySchema,
createCategoryRequestSchema,
updateCategoryRequestSchema,
} from './category.schema';
import { describe, it, expect } from 'vitest';
describe('categorySchema', () => {
it('accepts a full category', () => {
expect(
categorySchema.safeParse({
id: 1,
name: 'Food',
color: '#fff',
icon: '🍔',
}).success,
).toBe(true);
});
});
describe('createCategoryRequestSchema', () => {
it('requires a non-empty name; colour and icon are optional', () => {
expect(
createCategoryRequestSchema.safeParse({ name: 'Food' }).success,
).toBe(true);
expect(createCategoryRequestSchema.safeParse({ name: '' }).success).toBe(
false,
);
expect(createCategoryRequestSchema.safeParse({}).success).toBe(false);
});
});
describe('updateCategoryRequestSchema', () => {
it('allows every field to be omitted (the service COALESCEs)', () => {
expect(updateCategoryRequestSchema.safeParse({}).success).toBe(true);
expect(
updateCategoryRequestSchema.safeParse({ color: '#000' }).success,
).toBe(true);
});
});
+43
View File
@@ -0,0 +1,43 @@
import { z } from 'zod';
/**
* Category API contract — single source of truth for the /api/categories endpoints.
*
* Categories are the place-category palette (also the admin "Personalization"
* surface). Reading is open to any authenticated user; create/update/delete are
* admin-only. The legacy route (server/src/routes/categories.ts) wraps
* services/categoryService.ts 1:1.
*
* The bespoke 400 ("Category name is required") and 404 ("Category not found")
* messages are reproduced in the controller so the bodies stay byte-identical.
*/
export const categorySchema = z.object({
id: z.number(),
name: z.string(),
color: z.string(),
icon: z.string(),
user_id: z.number().nullable().optional(),
created_at: z.string().optional(),
});
export type Category = z.infer<typeof categorySchema>;
export const createCategoryRequestSchema = z.object({
name: z.string().min(1),
color: z.string().optional(),
icon: z.string().optional(),
});
export type CreateCategoryRequest = z.infer<typeof createCategoryRequestSchema>;
/** All fields optional — the service COALESCEs each against the stored value. */
export const updateCategoryRequestSchema = z.object({
name: z.string().optional(),
color: z.string().optional(),
icon: z.string().optional(),
});
export type UpdateCategoryRequest = z.infer<typeof updateCategoryRequestSchema>;
export const categoryListResponseSchema = z.object({
categories: z.array(categorySchema),
});
export type CategoryListResponse = z.infer<typeof categoryListResponseSchema>;
+83
View File
@@ -0,0 +1,83 @@
import {
collabNoteCreateRequestSchema,
collabPollCreateRequestSchema,
collabPollVoteRequestSchema,
collabMessageCreateRequestSchema,
collabReactionRequestSchema,
} from './collab.schema';
import { describe, it, expect } from 'vitest';
describe('collabNoteCreateRequestSchema', () => {
it('requires a non-empty title; the rest is optional', () => {
expect(
collabNoteCreateRequestSchema.safeParse({ title: 'Idea' }).success,
).toBe(true);
expect(collabNoteCreateRequestSchema.safeParse({ title: '' }).success).toBe(
false,
);
expect(collabNoteCreateRequestSchema.safeParse({}).success).toBe(false);
});
});
describe('collabPollCreateRequestSchema', () => {
it('requires a question and at least two options', () => {
expect(
collabPollCreateRequestSchema.safeParse({
question: 'Where?',
options: ['A', 'B'],
}).success,
).toBe(true);
expect(
collabPollCreateRequestSchema.safeParse({
question: 'Where?',
options: ['A'],
}).success,
).toBe(false);
expect(
collabPollCreateRequestSchema.safeParse({ options: ['A', 'B'] }).success,
).toBe(false);
});
});
describe('collabPollVoteRequestSchema', () => {
it('requires a numeric option_index', () => {
expect(
collabPollVoteRequestSchema.safeParse({ option_index: 0 }).success,
).toBe(true);
expect(
collabPollVoteRequestSchema.safeParse({ option_index: 'a' }).success,
).toBe(false);
});
});
describe('collabMessageCreateRequestSchema', () => {
it('requires text, caps it at 5000, allows a nullable reply_to', () => {
expect(
collabMessageCreateRequestSchema.safeParse({ text: 'hi', reply_to: null })
.success,
).toBe(true);
expect(
collabMessageCreateRequestSchema.safeParse({ text: 'hi', reply_to: 4 })
.success,
).toBe(true);
expect(
collabMessageCreateRequestSchema.safeParse({ text: '' }).success,
).toBe(false);
expect(
collabMessageCreateRequestSchema.safeParse({ text: 'x'.repeat(5001) })
.success,
).toBe(false);
});
});
describe('collabReactionRequestSchema', () => {
it('requires a non-empty emoji', () => {
expect(collabReactionRequestSchema.safeParse({ emoji: '👍' }).success).toBe(
true,
);
expect(collabReactionRequestSchema.safeParse({ emoji: '' }).success).toBe(
false,
);
});
});
+64
View File
@@ -0,0 +1,64 @@
import { z } from 'zod';
/**
* Collab API contract — single source of truth for the /api/trips/:tripId/collab
* endpoints (shared notes + file attachments, decision polls, group chat with
* reactions, link previews).
*
* Trip-scoped; mutations use 'collab_edit' (file uploads use 'file_upload'). The
* legacy route (server/src/routes/collab.ts) wraps collabService and broadcasts
* over WebSocket + fires chat/note notifications. Rows are wide and kept open;
* the request schemas + the bespoke 400/403/404 controller messages pin the rest.
*/
export const collabNoteCreateRequestSchema = z.object({
title: z.string().min(1),
content: z.string().optional(),
category: z.string().optional(),
color: z.string().optional(),
website: z.string().optional(),
});
export type CollabNoteCreateRequest = z.infer<
typeof collabNoteCreateRequestSchema
>;
export const collabNoteUpdateRequestSchema = z.object({
title: z.string().optional(),
content: z.string().optional(),
category: z.string().optional(),
color: z.string().optional(),
pinned: z.union([z.boolean(), z.number()]).optional(),
website: z.string().optional(),
});
export type CollabNoteUpdateRequest = z.infer<
typeof collabNoteUpdateRequestSchema
>;
export const collabPollCreateRequestSchema = z.object({
question: z.string().min(1),
options: z.array(z.unknown()).min(2),
multiple: z.boolean().optional(),
multiple_choice: z.boolean().optional(),
deadline: z.string().optional(),
});
export type CollabPollCreateRequest = z.infer<
typeof collabPollCreateRequestSchema
>;
export const collabPollVoteRequestSchema = z.object({
option_index: z.number(),
});
export type CollabPollVoteRequest = z.infer<typeof collabPollVoteRequestSchema>;
export const collabMessageCreateRequestSchema = z.object({
text: z.string().min(1).max(5000),
reply_to: z.number().nullable().optional(),
});
export type CollabMessageCreateRequest = z.infer<
typeof collabMessageCreateRequestSchema
>;
export const collabReactionRequestSchema = z.object({
emoji: z.string().min(1),
});
export type CollabReactionRequest = z.infer<typeof collabReactionRequestSchema>;
+12
View File
@@ -0,0 +1,12 @@
import { z } from 'zod';
/**
* Generic pagination query helper. Individual endpoints opt in by extending
* this; it is NOT applied globally (many TREK list endpoints return full sets).
* Defaults are conservative and only used where a route already paginates.
*/
export const paginationQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
perPage: z.coerce.number().int().min(1).max(200).default(50),
});
export type PaginationQuery = z.infer<typeof paginationQuerySchema>;
@@ -0,0 +1,50 @@
import { paginationQuerySchema } from './pagination.schema';
import {
idSchema,
idParamSchema,
nonEmptyString,
isoDateTime,
} from './primitives.schema';
import { describe, it, expect } from 'vitest';
describe('@trek/shared primitives', () => {
it('idSchema accepts positive integers, rejects others', () => {
expect(idSchema.parse(1)).toBe(1);
expect(idSchema.safeParse(0).success).toBe(false);
expect(idSchema.safeParse(-3).success).toBe(false);
expect(idSchema.safeParse(1.5).success).toBe(false);
});
it('idParamSchema coerces string params to a positive int', () => {
expect(idParamSchema.parse('42')).toBe(42);
expect(idParamSchema.safeParse('abc').success).toBe(false);
});
it('nonEmptyString trims and rejects empty', () => {
expect(nonEmptyString.parse(' hi ')).toBe('hi');
expect(nonEmptyString.safeParse(' ').success).toBe(false);
});
it('isoDateTime accepts an ISO timestamp', () => {
expect(isoDateTime.safeParse('2026-05-25T08:38:14Z').success).toBe(true);
expect(isoDateTime.safeParse('not-a-date').success).toBe(false);
});
});
describe('@trek/shared pagination', () => {
it('applies defaults and coerces', () => {
expect(paginationQuerySchema.parse({})).toEqual({ page: 1, perPage: 50 });
expect(paginationQuerySchema.parse({ page: '2', perPage: '10' })).toEqual({
page: 2,
perPage: 10,
});
});
it('enforces bounds', () => {
expect(paginationQuerySchema.safeParse({ perPage: 0 }).success).toBe(false);
expect(paginationQuerySchema.safeParse({ perPage: 999 }).success).toBe(
false,
);
});
});
+22
View File
@@ -0,0 +1,22 @@
import { z } from 'zod';
/**
* Primitive, domain-agnostic building blocks shared by every contract.
* Domain schemas (trips, places, ...) live in their own folders and reuse these.
*/
/** TREK uses auto-increment integer primary keys. */
export const idSchema = z.number().int().positive();
export type Id = z.infer<typeof idSchema>;
/**
* Numeric id coming from a URL param / query string. Express hands these over
* as strings, so we coerce, then enforce a positive integer.
*/
export const idParamSchema = z.coerce.number().int().positive();
/** Non-empty, trimmed string. */
export const nonEmptyString = z.string().trim().min(1);
/** ISO-8601 timestamp string (the shape TREK serialises dates as in JSON). */
export const isoDateTime = z.string().datetime({ offset: true });
+14
View File
@@ -0,0 +1,14 @@
import { z } from 'zod';
/**
* Public config contract — the unauthenticated /api/config endpoint.
*
* This is the only public (non-authenticated) endpoint in the L2 bundle: the
* login page reads it before a user signs in to pick the initial language. The
* legacy route (server/src/routes/publicConfig.ts) returns just the server's
* configured default language, so the response is intentionally minimal.
*/
export const publicConfigSchema = z.object({
defaultLanguage: z.string(),
});
export type PublicConfig = z.infer<typeof publicConfigSchema>;
+49
View File
@@ -0,0 +1,49 @@
import {
dayCreateRequestSchema,
dayNoteCreateRequestSchema,
dayNoteUpdateRequestSchema,
} from './day.schema';
import { describe, it, expect } from 'vitest';
describe('dayCreateRequestSchema', () => {
it('accepts an optional date + notes', () => {
expect(dayCreateRequestSchema.safeParse({}).success).toBe(true);
expect(
dayCreateRequestSchema.safeParse({ date: '2026-07-01', notes: 'n' })
.success,
).toBe(true);
});
});
describe('dayNoteCreateRequestSchema', () => {
it('requires non-empty text capped at 500, time capped at 250', () => {
expect(
dayNoteCreateRequestSchema.safeParse({ text: 'Lunch' }).success,
).toBe(true);
expect(dayNoteCreateRequestSchema.safeParse({ text: '' }).success).toBe(
false,
);
expect(
dayNoteCreateRequestSchema.safeParse({ text: 'x'.repeat(501) }).success,
).toBe(false);
expect(
dayNoteCreateRequestSchema.safeParse({
text: 'ok',
time: 'y'.repeat(251),
}).success,
).toBe(false);
});
});
describe('dayNoteUpdateRequestSchema', () => {
it('allows omitting text and caps the lengths', () => {
expect(dayNoteUpdateRequestSchema.safeParse({}).success).toBe(true);
expect(dayNoteUpdateRequestSchema.safeParse({ icon: '🍽️' }).success).toBe(
true,
);
expect(
dayNoteUpdateRequestSchema.safeParse({ text: 'x'.repeat(501) }).success,
).toBe(false);
});
});
+83
View File
@@ -0,0 +1,83 @@
import { assignmentSchema } from '../assignment/assignment.schema';
import { z } from 'zod';
/**
* Day + day-note API contract — single source of truth for the
* /api/trips/:tripId/days and /api/trips/:tripId/days/:dayId/notes endpoints.
*
* Trip-scoped, both gated by the 'day_edit' permission. The legacy routes
* (server/src/routes/days.ts + routes/dayNotes.ts) wrap dayService /
* dayNoteService. Day rows (with their assignments) are wide and DB-derived, so
* list responses stay open. Day notes cap text at 500 and time at 150 chars
* (the legacy validateStringLengths middleware) — reproduced in the controller.
*/
/**
* Day note entity (server day_notes table / dayNoteService). `sort_order` is
* SQLite REAL; `icon` defaults to a note emoji.
*/
export const dayNoteSchema = z.object({
id: z.number(),
day_id: z.number(),
trip_id: z.number().optional(),
text: z.string(),
time: z.string().nullable().optional(),
icon: z.string().nullable().optional(),
sort_order: z.number().optional(),
created_at: z.string().optional(),
});
export type DayNote = z.infer<typeof dayNoteSchema>;
/**
* Day entity as returned by the day list/get endpoints
* (server/src/services/dayService.ts -> listDays). Columns of the `days` table
* plus the embedded `assignments` and `notes_items` arrays.
*/
export const daySchema = z.object({
id: z.number(),
trip_id: z.number(),
day_number: z.number().optional(),
date: z.string().nullable().optional(),
title: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
assignments: z.array(assignmentSchema).optional(),
notes_items: z.array(dayNoteSchema).optional(),
});
export type Day = z.infer<typeof daySchema>;
export const dayCreateRequestSchema = z.object({
date: z.string().optional(),
notes: z.string().optional(),
// 1-based slot to insert a new empty day at (omit to append at the end).
position: z.number().int().positive().optional(),
});
export type DayCreateRequest = z.infer<typeof dayCreateRequestSchema>;
/** Reorder whole days: the desired full sequence of this trip's day ids. */
export const dayReorderRequestSchema = z.object({
orderedIds: z.array(z.number()),
});
export type DayReorderRequest = z.infer<typeof dayReorderRequestSchema>;
export const dayUpdateRequestSchema = z.object({
notes: z.string().optional(),
title: z.string().nullable().optional(),
});
export type DayUpdateRequest = z.infer<typeof dayUpdateRequestSchema>;
export const dayNoteCreateRequestSchema = z.object({
text: z.string().min(1).max(500),
time: z.string().max(250).optional(),
icon: z.string().optional(),
sort_order: z.number().optional(),
});
export type DayNoteCreateRequest = z.infer<typeof dayNoteCreateRequestSchema>;
export const dayNoteUpdateRequestSchema = z.object({
text: z.string().max(500).optional(),
time: z.string().max(250).optional(),
icon: z.string().optional(),
sort_order: z.number().optional(),
});
export type DayNoteUpdateRequest = z.infer<typeof dayNoteUpdateRequestSchema>;
+42
View File
@@ -0,0 +1,42 @@
import {
fileUpdateRequestSchema,
fileLinkRequestSchema,
photoVariantSchema,
} from './file.schema';
import { describe, it, expect } from 'vitest';
describe('fileUpdateRequestSchema', () => {
it('accepts optional metadata, nullable ids, an empty body', () => {
expect(
fileUpdateRequestSchema.safeParse({ description: 'doc', place_id: 3 })
.success,
).toBe(true);
expect(
fileUpdateRequestSchema.safeParse({ place_id: null, reservation_id: '7' })
.success,
).toBe(true);
expect(fileUpdateRequestSchema.safeParse({}).success).toBe(true);
});
});
describe('fileLinkRequestSchema', () => {
it('accepts any subset of reservation/assignment/place ids', () => {
expect(fileLinkRequestSchema.safeParse({ reservation_id: 1 }).success).toBe(
true,
);
expect(
fileLinkRequestSchema.safeParse({ assignment_id: '2', place_id: null })
.success,
).toBe(true);
expect(fileLinkRequestSchema.safeParse({}).success).toBe(true);
});
});
describe('photoVariantSchema', () => {
it('only allows thumbnail or original', () => {
expect(photoVariantSchema.safeParse('thumbnail').success).toBe(true);
expect(photoVariantSchema.safeParse('original').success).toBe(true);
expect(photoVariantSchema.safeParse('full').success).toBe(false);
});
});
+33
View File
@@ -0,0 +1,33 @@
import { z } from 'zod';
/**
* File + photo API contract.
*
* Files live under /api/trips/:tripId/files (upload, metadata, star, trash,
* reservation links, authenticated download). Photos live under /api/photos
* (thumbnail/original streaming + info) and are global, not trip-scoped.
*
* Uploads are multipart/form-data so the file itself isn't modelled here; these
* schemas pin the JSON-ish metadata fields that ride along or come as request
* bodies. The bespoke 400/403/404 controller messages pin the rest.
*/
const nullableIdField = z.union([z.string(), z.number()]).nullable().optional();
export const fileUpdateRequestSchema = z.object({
description: z.string().optional(),
place_id: nullableIdField,
reservation_id: nullableIdField,
});
export type FileUpdateRequest = z.infer<typeof fileUpdateRequestSchema>;
export const fileLinkRequestSchema = z.object({
reservation_id: nullableIdField,
assignment_id: nullableIdField,
place_id: nullableIdField,
});
export type FileLinkRequest = z.infer<typeof fileLinkRequestSchema>;
/** Variants the photo streaming endpoints accept. */
export const photoVariantSchema = z.enum(['thumbnail', 'original']);
export type PhotoVariant = z.infer<typeof photoVariantSchema>;
+374
View File
@@ -0,0 +1,374 @@
import type { TranslationStrings } from '../types';
const admin: TranslationStrings = {
'admin.notifications.title': 'الإشعارات',
'admin.notifications.hint':
'اختر قناة إشعارات واحدة. يمكن تفعيل واحدة فقط في كل مرة.',
'admin.notifications.none': 'معطّل',
'admin.notifications.email': 'البريد الإلكتروني (SMTP)',
'admin.ntfy.hint':
'تسمح للمستخدمين بإعداد موضوعات ntfy الخاصة لتلقي إشعارات الدفع. قم بتعيين الخادم الافتراضي أدناه لملء إعدادات المستخدم مسبقًا.',
'admin.notifications.save': 'حفظ إعدادات الإشعارات',
'admin.notifications.saved': 'تم حفظ إعدادات الإشعارات',
'admin.notifications.testWebhook': 'إرسال webhook تجريبي',
'admin.notifications.testWebhookSuccess': 'تم إرسال webhook التجريبي بنجاح',
'admin.notifications.testWebhookFailed': 'فشل إرسال webhook التجريبي',
'admin.notifications.testNtfy': 'إرسال Ntfy تجريبي',
'admin.notifications.testNtfySuccess': 'تم إرسال Ntfy التجريبي بنجاح',
'admin.notifications.testNtfyFailed': 'فشل إرسال Ntfy التجريبي',
'admin.notifications.inappPanel.hint':
'الإشعارات داخل التطبيق نشطة دائمًا ولا يمكن تعطيلها بشكل عام.',
'admin.notifications.adminWebhookPanel.title': 'Webhook المسؤول',
'admin.notifications.adminWebhookPanel.hint':
'يُستخدم هذا الـ Webhook حصريًا لإشعارات المسؤول (مثل تنبيهات الإصدارات). وهو مستقل عن Webhooks المستخدمين ويُرسل تلقائيًا عند تعيين رابط URL.',
'admin.notifications.adminWebhookPanel.saved': 'تم حفظ رابط Webhook المسؤول',
'admin.notifications.adminWebhookPanel.testSuccess':
'تم إرسال Webhook الاختباري بنجاح',
'admin.notifications.adminWebhookPanel.testFailed':
'فشل إرسال Webhook الاختباري',
'admin.notifications.adminWebhookPanel.alwaysOnHint':
'يُرسل Webhook المسؤول تلقائيًا عند تعيين رابط URL',
'admin.notifications.adminNtfyPanel.title': 'Ntfy المسؤول',
'admin.notifications.adminNtfyPanel.hint':
'يُستخدم موضوع Ntfy هذا حصريًا لإشعارات المسؤول (مثل تنبيهات الإصدارات). وهو مستقل عن مواضيع المستخدمين ويُرسل دائمًا عند تهيئته.',
'admin.notifications.adminNtfyPanel.serverLabel': 'عنوان URL خادم Ntfy',
'admin.notifications.adminNtfyPanel.serverHint':
'يُستخدم أيضًا كخادم افتراضي لإشعارات ntfy للمستخدمين. اتركه فارغًا لاستخدام ntfy.sh. يمكن للمستخدمين تغييره في إعداداتهم الخاصة.',
'admin.notifications.adminNtfyPanel.topicLabel': 'موضوع المسؤول',
'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':
'حدد القنوات التي تُسلّم إشعارات المسؤول (مثل تنبيهات الإصدارات). يُرسل الـ Webhook تلقائيًا عند تعيين رابط URL لـ Webhook المسؤول.',
'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 خارجي (Discord، Slack، إلخ).',
'admin.smtp.testSuccess': 'تم إرسال البريد التجريبي بنجاح',
'admin.smtp.testFailed': 'فشل إرسال البريد التجريبي',
'admin.title': 'الإدارة',
'admin.subtitle': 'إدارة المستخدمين وإعدادات النظام',
'admin.tabs.users': 'المستخدمون',
'admin.tabs.categories': 'الفئات',
'admin.tabs.backup': 'النسخ الاحتياطي',
'admin.tabs.notifications': 'الإشعارات',
'admin.tabs.audit': 'تدقيق',
'admin.stats.users': 'المستخدمون',
'admin.stats.trips': 'الرحلات',
'admin.stats.places': 'الأماكن',
'admin.stats.photos': 'الصور',
'admin.stats.files': 'الملفات',
'admin.table.user': 'المستخدم',
'admin.table.email': 'البريد الإلكتروني',
'admin.table.role': 'الدور',
'admin.table.created': 'تم الإنشاء',
'admin.table.lastLogin': 'آخر تسجيل دخول',
'admin.table.actions': 'الإجراءات',
'admin.you': '(أنت)',
'admin.editUser': 'تعديل المستخدم',
'admin.newPassword': 'كلمة مرور جديدة',
'admin.newPasswordHint': 'اتركه فارغًا للاحتفاظ بالحالية',
'admin.deleteUser': 'حذف المستخدم "{name}"؟ سيتم حذف جميع الرحلات نهائيًا.',
'admin.deleteUserTitle': 'حذف المستخدم',
'admin.newPasswordPlaceholder': 'أدخل كلمة مرور جديدة…',
'admin.toast.loadError': 'فشل تحميل بيانات الإدارة',
'admin.toast.userUpdated': 'تم تحديث المستخدم',
'admin.toast.updateError': 'فشل التحديث',
'admin.toast.userDeleted': 'تم حذف المستخدم',
'admin.toast.deleteError': 'فشل الحذف',
'admin.toast.cannotDeleteSelf': 'لا يمكنك حذف حسابك الخاص',
'admin.toast.userCreated': 'تم إنشاء المستخدم',
'admin.toast.createError': 'فشل إنشاء المستخدم',
'admin.toast.fieldsRequired':
'اسم المستخدم والبريد الإلكتروني وكلمة المرور مطلوبة',
'admin.createUser': 'إنشاء مستخدم',
'admin.invite.title': 'روابط الدعوة',
'admin.invite.subtitle': 'إنشاء روابط تسجيل للاستخدام المحدود',
'admin.invite.create': 'إنشاء رابط',
'admin.invite.createAndCopy': 'إنشاء ونسخ',
'admin.invite.empty': 'لم يتم إنشاء روابط دعوة بعد',
'admin.invite.maxUses': 'الحد الأقصى للاستخدام',
'admin.invite.expiry': 'تنتهي بعد',
'admin.invite.uses': 'مستخدم',
'admin.invite.expiresAt': 'تنتهي في',
'admin.invite.createdBy': 'بواسطة',
'admin.invite.active': 'نشط',
'admin.invite.expired': 'منتهي',
'admin.invite.usedUp': 'مستنفد',
'admin.invite.copied': 'تم نسخ رابط الدعوة',
'admin.invite.copyLink': 'نسخ الرابط',
'admin.invite.deleted': 'تم حذف رابط الدعوة',
'admin.invite.createError': 'فشل إنشاء رابط الدعوة',
'admin.invite.deleteError': 'فشل حذف رابط الدعوة',
'admin.tabs.settings': 'الإعدادات',
'admin.allowRegistration': 'السماح بالتسجيل',
'admin.allowRegistrationHint': 'يمكن للمستخدمين الجدد التسجيل بأنفسهم',
'admin.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 يمكن تحميل الصور والتقييمات وساعات العمل أيضًا. احصل عليه من 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 Issuer 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.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': 'الإجازة',
'admin.addons.catalog.vacay.description': 'مخطط إجازات شخصي مع عرض تقويم',
'admin.addons.catalog.atlas.name': 'الأطلس',
'admin.addons.catalog.atlas.description':
'خريطة العالم مع الدول التي تمت زيارتها وإحصائيات السفر',
'admin.addons.catalog.collab.name': 'التعاون',
'admin.addons.catalog.collab.description':
'ملاحظات واستطلاعات ودردشة لحظية لتخطيط الرحلة',
'admin.addons.catalog.memories.name': 'صور (Immich)',
'admin.addons.catalog.memories.description': 'شارك صور رحلتك عبر Immich',
'admin.addons.catalog.mcp.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': 'منذ 24 مارس 2026',
'admin.weather.description':
'يستخدم TREK خدمة Open-Meteo كمصدر لبيانات الطقس. وهي خدمة مجانية ومفتوحة المصدر ولا تتطلب مفتاح API.',
'admin.weather.forecast': 'توقعات 16 يومًا',
'admin.weather.forecastDesc': 'سابقًا 5 أيام (OpenWeatherMap)',
'admin.weather.climate': 'بيانات المناخ التاريخية',
'admin.weather.climateDesc':
'متوسطات آخر 85 سنة للأيام بعد توقعات الـ 16 يومًا',
'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.audit.subtitle':
'أحداث الأمان والإدارة (النسخ الاحتياطية، المستخدمون، المصادقة الثنائية، الإعدادات).',
'admin.audit.empty': 'لا توجد سجلات تدقيق بعد.',
'admin.audit.refresh': 'تحديث',
'admin.audit.loadMore': 'تحميل المزيد',
'admin.audit.showing': 'تم تحميل {count} · الإجمالي {total}',
'admin.audit.col.time': 'الوقت',
'admin.audit.col.user': 'المستخدم',
'admin.audit.col.action': 'الإجراء',
'admin.audit.col.resource': 'المورد',
'admin.audit.col.ip': 'عنوان IP',
'admin.audit.col.details': 'التفاصيل',
'admin.github.title': 'سجل الإصدارات',
'admin.github.subtitle': 'آخر التحديثات من {repo}',
'admin.github.latest': 'الأحدث',
'admin.github.prerelease': 'إصدار تجريبي',
'admin.github.showDetails': 'إظهار التفاصيل',
'admin.github.hideDetails': 'إخفاء التفاصيل',
'admin.github.loadMore': 'تحميل المزيد',
'admin.github.loading': 'جارٍ التحميل...',
'admin.github.error': 'فشل تحميل الإصدارات',
'admin.github.by': 'بواسطة',
'admin.github.support': 'يساعدني في تطوير TREK',
'admin.update.available': 'يتوفر تحديث',
'admin.update.text': 'TREK {version} متوفر. أنت تستخدم {current}.',
'admin.update.button': 'عرض على GitHub',
'admin.update.install': 'تثبيت التحديث',
'admin.update.confirmTitle': 'تثبيت التحديث؟',
'admin.update.confirmText':
'سيتم تحديث TREK من {current} إلى {version}. سيُعاد تشغيل الخادم تلقائيًا بعد ذلك.',
'admin.update.dataInfo':
'جميع بياناتك (الرحلات، المستخدمون، مفاتيح API، المرفوعات، الإجازة، الأطلس، الميزانيات) ستبقى محفوظة.',
'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.notifications.webhook': 'Webhook', // en-fallback
'admin.notifications.ntfy': 'Ntfy', // en-fallback
'admin.notifications.emailPanel.title': 'Email (SMTP)', // en-fallback
'admin.notifications.webhookPanel.title': 'Webhook', // en-fallback
'admin.notifications.inappPanel.title': 'In-App', // en-fallback
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh', // en-fallback
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts', // en-fallback
'admin.authMethods': 'Authentication Methods', // en-fallback
'admin.passwordLogin': 'Password Login', // en-fallback
'admin.passwordLoginHint': 'Allow users to sign in with email and password', // en-fallback
'admin.passwordRegistration': 'Password Registration', // en-fallback
'admin.passwordRegistrationHint':
'Allow new users to register with email and password', // en-fallback
'admin.oidcLogin': 'SSO Login', // en-fallback
'admin.oidcLoginHint': 'Allow users to sign in with SSO', // en-fallback
'admin.oidcRegistration': 'SSO Auto-Provisioning', // en-fallback
'admin.oidcRegistrationHint':
'Automatically create accounts for new SSO users', // en-fallback
'admin.envOverrideHint':
'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.', // en-fallback
'admin.lockoutWarning': 'At least one login method must remain enabled', // en-fallback
'admin.addons.catalog.mcp.name': 'MCP', // en-fallback
'admin.tabs.github': 'GitHub', // en-fallback
'admin.addons.catalog.journey.name': 'Journey', // en-fallback
'admin.addons.catalog.journey.description':
'Trip tracking & travel journal with check-ins, photos, and daily stories', // en-fallback
'admin.passkey.title': 'تسجيل الدخول بمفتاح المرور',
'admin.passkey.cardHint':
'اسمح للمستخدمين بتسجيل الدخول باستخدام مفاتيح المرور (WebAuthn). معطّل افتراضيًا.',
'admin.passkey.login': 'تفعيل تسجيل الدخول بمفتاح المرور',
'admin.passkey.loginHint':
'إظهار خيار "تسجيل الدخول باستخدام مفتاح المرور" والسماح للمستخدمين بتسجيل مفاتيح المرور في إعداداتهم.',
'admin.passkey.notConfigured':
'لا يوجد نطاق WebAuthn صالح لهذا التثبيت بعد. عيّن APP_URL أو Relying Party ID أدناه — تبقى مفاتيح المرور مخفية حتى ذلك الحين.',
'admin.passkey.rpId': 'Relying Party ID (النطاق)',
'admin.passkey.rpIdHint':
'النطاق المجرّد الذي تُربط به مفاتيح المرور، مثل trek.example.org. اتركه فارغًا لاشتقاقه من APP_URL. تغييره لاحقًا يُبطل مفاتيح المرور الموجودة.',
'admin.passkey.origins': 'الأصول المسموح بها',
'admin.passkey.originsHint':
'أصول كاملة مفصولة بفواصل، مثل https://trek.example.org. اتركه فارغًا لاستخدام APP_URL.',
'admin.passkey.reset': 'إعادة تعيين مفاتيح المرور',
'admin.passkey.resetHint':
'إزالة جميع مفاتيح المرور لهذا المستخدم (مثلًا عند فقدان جهاز). سيظل بإمكانه تسجيل الدخول بكلمة المرور.',
'admin.passkey.resetConfirm': 'إزالة جميع مفاتيح المرور لـ {name}؟',
'admin.passkey.resetDone': 'تمت إزالة {count} من مفاتيح المرور',
'admin.defaultSettings.mapProvider': 'محرك الخرائط',
'admin.defaultSettings.mapProviderHint': 'الخريطة الافتراضية لجميع المستخدمين على هذا الخادم. لا يزال بإمكان كل مستخدم تجاوزها في إعداداته الخاصة.',
'admin.defaultSettings.providerLeaflet': 'قياسي (مجاني)',
'admin.defaultSettings.providerMapbox': 'Mapbox (ثلاثي الأبعاد)',
'admin.defaultSettings.mapboxToken': 'رمز Mapbox المشترك',
'admin.defaultSettings.mapboxTokenHint': 'يُستخدم لكل مستخدم لم يُدخل رمزه الخاص — حتى يحصل الخادم بأكمله على Mapbox دون مشاركة المفتاح بشكل فردي. يُخزَّن مشفّرًا.',
'admin.defaultSettings.mapboxStyle': 'نمط الخريطة',
'admin.defaultSettings.mapboxStylePlaceholder': 'اختر نمطًا…',
'admin.defaultSettings.mapbox3d': 'المباني والتضاريس ثلاثية الأبعاد',
'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.unmark': 'إزالة',
'atlas.confirmMark': 'تعيين هذا البلد كمُزار؟',
'atlas.confirmUnmark': 'إزالة هذا البلد من قائمة المُزارة؟',
'atlas.confirmUnmarkRegion': 'إزالة هذه المنطقة من قائمة المُزارة؟',
'atlas.markVisited': 'تعيين كمُزار',
'atlas.markVisitedHint': 'إضافة هذا البلد إلى قائمة المُزارة',
'atlas.markRegionVisitedHint': 'إضافة هذه المنطقة إلى قائمة المُزارة',
'atlas.addToBucket': 'إضافة إلى قائمة الأمنيات',
'atlas.addPoi': 'إضافة مكان',
'atlas.searchCountry': 'ابحث عن دولة...',
'atlas.bucketNamePlaceholder': 'الاسم (بلد، مدينة، مكان…)',
'atlas.month': 'الشهر',
'atlas.year': 'السنة',
'atlas.addToBucketHint': 'حفظ كمكان تريد زيارته',
'atlas.bucketWhen': 'متى تخطط للزيارة؟',
'atlas.statsTab': 'الإحصائيات',
'atlas.bucketTab': 'قائمة الأمنيات',
'atlas.addBucket': 'إضافة إلى قائمة الأمنيات',
'atlas.bucketNotesPlaceholder': 'ملاحظات (اختياري)',
'atlas.bucketEmpty': 'قائمة أمنياتك فارغة',
'atlas.bucketEmptyHint': 'أضف أماكن تحلم بزيارتها',
'atlas.days': 'الأيام',
'atlas.visitedCountries': 'الدول التي تمت زيارتها',
'atlas.cities': 'المدن',
'atlas.noData': 'لا توجد بيانات سفر بعد',
'atlas.noDataHint': 'أنشئ رحلة وأضف أماكن لرؤية خريطتك العالمية',
'atlas.lastTrip': 'آخر رحلة',
'atlas.nextTrip': 'الرحلة القادمة',
'atlas.daysLeft': 'يوم متبقٍ',
'atlas.streak': 'سلسلة',
'atlas.years': 'سنوات',
'atlas.yearInRow': 'سنة متتالية',
'atlas.yearsInRow': 'سنوات متتالية',
'atlas.tripIn': 'رحلة في',
'atlas.tripsIn': 'رحلات في',
'atlas.since': 'منذ',
'atlas.europe': 'أوروبا',
'atlas.asia': 'آسيا',
'atlas.northAmerica': 'أمريكا الشمالية',
'atlas.southAmerica': 'أمريكا الجنوبية',
'atlas.africa': 'أفريقيا',
'atlas.oceania': 'أوقيانوسيا',
'atlas.other': 'أخرى',
'atlas.firstVisit': 'أول رحلة',
'atlas.lastVisitLabel': 'آخر رحلة',
'atlas.tripSingular': 'رحلة',
'atlas.tripPlural': 'رحلات',
'atlas.placeVisited': 'مكان تمت زيارته',
'atlas.placesVisited': 'أماكن تمت زيارتها',
};
export default atlas;
+76
View File
@@ -0,0 +1,76 @@
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': 'محدود بين 1–28 للتوافق مع جميع الأشهر',
'backup.auto.scheduleSummary': 'الجدول',
'backup.auto.summaryDaily': 'كل يوم الساعة {hour}:00',
'backup.auto.summaryWeekly': 'كل {day} الساعة {hour}:00',
'backup.auto.summaryMonthly': 'اليوم {day} من كل شهر الساعة {hour}:00',
'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': 'يوم واحد',
'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': 'نعم، استعادة',
'backup.auto.envLocked': 'Docker', // en-fallback
};
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": "أنت",
"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;
+26
View File
@@ -0,0 +1,26 @@
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.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': 'مغلق',
'collab.notes.websitePlaceholder': 'https://...', // en-fallback
};
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.update': 'تحديث',
'common.change': 'تغيير',
'common.uploading': 'جارٍ الرفع...',
'common.backToPlanning': 'العودة إلى التخطيط',
'common.reset': 'إعادة تعيين',
'common.expand': 'توسيع',
'common.collapse': 'طي',
'common.copy': 'نسخ',
'common.copied': 'تم النسخ',
'common.justNow': 'just now', // en-fallback
'common.hoursAgo': '{count}h ago', // en-fallback
'common.daysAgo': '{count}d ago', // en-fallback
};
export default common;
+168
View File
@@ -0,0 +1,168 @@
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': 'مثال: Asia/Riyadh',
'dashboard.timezoneCustomAdd': 'إضافة',
'dashboard.timezoneCustomErrorEmpty': 'أدخل معرّف منطقة زمنية',
'dashboard.timezoneCustomErrorInvalid':
'منطقة زمنية غير صالحة. استخدم صيغة مثل Asia/Riyadh',
'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.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': 'بقي يوم',
'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.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.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;
+26
View File
@@ -0,0 +1,26 @@
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;
+58
View File
@@ -0,0 +1,58 @@
import type { TranslationStrings } from '../types';
const dayplan: TranslationStrings = {
'dayplan.icsTooltip': 'تصدير التقويم (ICS)',
'dayplan.emptyDay': 'لا توجد أماكن مخططة لهذا اليوم',
'dayplan.cannotReorderTransport':
'لا يمكن إعادة ترتيب الحجوزات ذات الوقت الثابت',
'dayplan.confirmRemoveTimeTitle': 'إزالة الوقت؟',
'dayplan.confirmRemoveTimeBody':
'هذا المكان له وقت ثابت ({time}). نقله سيزيل الوقت ويسمح بالترتيب الحر.',
'dayplan.confirmRemoveTimeAction': 'إزالة الوقت ونقل',
'dayplan.confirmDeleteNoteTitle': 'حذف الملاحظة؟',
'dayplan.confirmDeleteNoteBody': 'سيتم حذف هذه الملاحظة نهائيًا.',
'dayplan.cannotDropOnTimed':
'لا يمكن وضع العناصر بين الإدخالات المرتبطة بوقت',
'dayplan.cannotBreakChronology':
'سيؤدي هذا إلى كسر الترتيب الزمني للعناصر والحجوزات المجدولة',
'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.pdfTooltip': 'تصدير خطة اليوم بصيغة PDF',
'dayplan.pdfError': 'فشل تصدير PDF',
'dayplan.expandAll': 'Expand all days', // en-fallback
'dayplan.collapseAll': 'Collapse all days', // en-fallback
'dayplan.pdf': 'PDF', // en-fallback
'dayplan.mobile.addPlace': 'Add Place', // en-fallback
'dayplan.mobile.searchPlaces': 'Search places...', // en-fallback
'dayplan.mobile.allAssigned': 'All places assigned', // en-fallback
'dayplan.mobile.noMatch': 'No match', // en-fallback
'dayplan.mobile.createNew': 'Create new place', // 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,63 @@
import type { NotificationLocale } from '../externalNotifications/types';
const ar: 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.booking}" (${p.type}) إلى "${p.trip}".`,
}),
trip_reminder: (p) => ({
title: `تذكير: ${p.trip}`,
body: `رحلتك "${p.trip}" تقترب!`,
}),
todo_due: (p) => ({
title: `مهمة مستحقة: ${p.todo}`,
body: `"${p.todo}" في "${p.trip}" مستحقة في ${p.due}.`,
}),
vacay_invite: (p) => ({
title: 'دعوة دمج الإجازة',
body: `${p.actor} يدعوك لدمج خطط الإجازة. افتح TREK للقبول أو الرفض.`,
}),
photos_shared: (p) => ({
title: `${p.count} صور مشتركة`,
body: `${p.actor} شارك ${p.count} صورة في "${p.trip}".`,
}),
collab_message: (p) => ({
title: `رسالة جديدة في "${p.trip}"`,
body: `${p.actor}: ${p.preview}`,
}),
packing_tagged: (p) => ({
title: `قائمة التعبئة: ${p.category}`,
body: `${p.actor} عيّنك في فئة "${p.category}" في "${p.trip}".`,
}),
version_available: (p) => ({
title: 'إصدار TREK جديد متاح',
body: `TREK ${p.version} متاح الآن. تفضل بزيارة لوحة الإدارة للتحديث.`,
}),
synology_session_cleared: () => ({
title: 'تمت إعادة تعيين جلسة Synology',
body: 'تغيّر حسابك أو رابط Synology. تم تسجيل خروجك من Synology Photos.',
}),
},
passwordReset: {
subject: 'إعادة تعيين كلمة المرور',
greeting: 'مرحبا',
body: 'تلقينا طلبًا لإعادة تعيين كلمة المرور لحسابك في TREK. انقر على الزر أدناه لتعيين كلمة مرور جديدة.',
ctaIntro: 'إعادة تعيين كلمة المرور',
expiry: 'تنتهي صلاحية هذا الرابط خلال 60 دقيقة.',
ignore:
'إذا لم تطلب هذا، يمكنك تجاهل هذه الرسالة — لن تتغير كلمة المرور الخاصة بك.',
},
};
export default ar;
+62
View File
@@ -0,0 +1,62 @@
import type { TranslationStrings } from '../types';
const files: TranslationStrings = {
'files.title': 'الملفات',
'files.pageTitle': 'الملفات والمستندات',
'files.subtitle': '{count} ملف لـ {trip}',
'files.download': 'تنزيل',
'files.openError': 'تعذر فتح الملف',
'files.downloadPdf': 'تنزيل PDF',
'files.count': '{count} ملفات',
'files.countSingular': 'ملف واحد',
'files.uploaded': 'تم رفع {count}',
'files.uploadError': 'فشل الرفع',
'files.dropzone': 'أسقط الملفات هنا',
'files.dropzoneHint': 'أو انقر للتصفح',
'files.allowedTypes':
'صور، PDF، DOC، DOCX، XLS، XLSX، TXT، CSV · حد أقصى 50 ميغابايت',
'files.uploading': 'جارٍ الرفع...',
'files.filterAll': 'الكل',
'files.filterPdf': 'ملفات PDF',
'files.filterImages': 'الصور',
'files.filterDocs': 'المستندات',
'files.filterCollab': 'ملاحظات Collab',
'files.sourceCollab': 'من ملاحظات Collab',
'files.empty': 'لا توجد ملفات بعد',
'files.emptyHint': 'ارفع ملفات لإرفاقها برحلتك',
'files.openTab': 'فتح في تبويب جديد',
'files.confirm.delete': 'هل تريد حذف هذا الملف؟',
'files.toast.deleted': 'تم حذف الملف',
'files.toast.deleteError': 'فشل حذف الملف',
'files.sourcePlan': 'خطة اليوم',
'files.sourceBooking': 'الحجز',
'files.sourceTransport': 'النقل',
'files.attach': 'إرفاق',
'files.pasteHint': 'يمكنك أيضًا لصق الصور من الحافظة (Ctrl+V)',
'files.trash': 'سلة المهملات',
'files.trashEmpty': 'سلة المهملات فارغة',
'files.emptyTrash': 'إفراغ السلة',
'files.restore': 'استعادة',
'files.star': 'تمييز',
'files.unstar': 'إلغاء التمييز',
'files.assign': 'إسناد',
'files.assignTitle': 'إسناد ملف',
'files.assignPlace': 'المكان',
'files.assignBooking': 'الحجز',
'files.assignTransport': 'النقل',
'files.unassigned': 'غير مسند',
'files.unlink': 'إزالة الرابط',
'files.toast.trashed': 'تم النقل إلى سلة المهملات',
'files.toast.restored': 'تمت استعادة الملف',
'files.toast.trashEmptied': 'تم إفراغ سلة المهملات',
'files.toast.assigned': 'تم إسناد الملف',
'files.toast.assignError': 'فشل الإسناد',
'files.toast.restoreError': 'فشلت الاستعادة',
'files.confirm.permanentDelete':
'حذف هذا الملف نهائيًا؟ لا يمكن التراجع عن ذلك.',
'files.confirm.emptyTrash':
'حذف جميع ملفات سلة المهملات نهائيًا؟ لا يمكن التراجع عن ذلك.',
'files.noteLabel': 'ملاحظة',
'files.notePlaceholder': 'أضف ملاحظة...',
};
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,
...notifications,
...todo,
...notif,
...journey,
...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.remove': 'إزالة',
'inspector.removeFromDay': 'إزالة من اليوم',
'inspector.addToDay': 'إضافة إلى اليوم',
'inspector.confirmedRes': 'حجز مؤكد',
'inspector.pendingRes': 'حجز قيد الانتظار',
'inspector.google': 'فتح في Google Maps',
'inspector.website': 'فتح الموقع الإلكتروني',
'inspector.addRes': 'حجز',
'inspector.editRes': 'تعديل الحجز',
'inspector.participants': 'المشاركون',
'inspector.trackStats': 'بيانات المسار',
};
export default inspector;
+244
View File
@@ -0,0 +1,244 @@
import type { TranslationStrings } from '../types';
const journey: TranslationStrings = {
'journey.search.placeholder': 'البحث في الرحلات…',
'journey.search.noResults': 'لا توجد رحلات تطابق "{query}"',
'journey.status.archived': 'مؤرشف',
'journey.detail.backToJourney': 'العودة للمجلة',
'journey.detail.photos': 'صور',
'journey.detail.day': 'اليوم {number}',
'journey.detail.places': 'أماكن',
'journey.skeletons.show': 'إظهار الاقتراحات',
'journey.skeletons.hide': 'إخفاء الاقتراحات',
'journey.editor.discardChangesConfirm':
'لديك تغييرات غير محفوظة. هل تريد تجاهلها؟',
'journey.editor.uploadFailed': 'فشل رفع الصور',
'journey.editor.uploadPhotos': 'رفع صور',
'journey.editor.uploading': '...جارٍ الرفع',
'journey.editor.uploadingProgress': 'جارٍ الرفع {done}/{total}…',
'journey.editor.uploadPartialFailed':
'فشل رفع {failed} من {total} — احفظ مجدداً للمحاولة',
'journey.editor.fromGallery': 'من المعرض',
'journey.editor.addAnother': 'إضافة آخر',
'journey.editor.makeFirst': 'جعله الأول',
'journey.editor.searching': 'جارٍ البحث...',
'journey.share.copy': 'نسخ',
'journey.share.copied': 'تم النسخ!',
'journey.invite.role': 'الدور',
'journey.invite.viewer': 'مشاهد',
'journey.invite.editor': 'محرر',
'journey.invite.invite': 'دعوة',
'journey.invite.inviting': 'جارٍ الدعوة...',
'journey.settings.endJourney': 'أرشفة الرحلة',
'journey.settings.reopenJourney': 'استعادة الرحلة',
'journey.settings.archived': 'تم أرشفة الرحلة',
'journey.settings.reopened': 'تمت إعادة فتح الرحلة',
'journey.settings.endDescription':
'يخفي شارة البث المباشر. يمكنك إعادة الفتح في أي وقت.',
'journey.settings.failedToDelete': 'فشل في الحذف',
'journey.entries.deleteTitle': 'حذف الإدخال',
'journey.photosUploaded': 'تم رفع {count} صورة',
'journey.photosUploadFailed': 'فشل رفع بعض الصور',
'journey.photosAdded': 'تمت إضافة {count} صورة',
'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.title': 'Journey', // en-fallback
'journey.subtitle': 'Track your travels as they happen', // en-fallback
'journey.new': 'New Journey', // en-fallback
'journey.create': 'Create', // en-fallback
'journey.titlePlaceholder': 'Where are you going?', // en-fallback
'journey.empty': 'No journeys yet', // en-fallback
'journey.emptyHint': 'Start documenting your next trip', // en-fallback
'journey.deleted': 'Journey deleted', // en-fallback
'journey.createError': 'Could not create journey', // en-fallback
'journey.deleteError': 'Could not delete journey', // en-fallback
'journey.deleteConfirmTitle': 'Delete', // en-fallback
'journey.deleteConfirmMessage': 'Delete "{title}"? This cannot be undone.', // en-fallback
'journey.deleteConfirmGeneric': 'Are you sure you want to delete this?', // en-fallback
'journey.notFound': 'Journey not found', // en-fallback
'journey.photos': 'Photos', // en-fallback
'journey.timelineEmpty': 'No stops yet', // en-fallback
'journey.timelineEmptyHint':
'Add a check-in or write a journal entry to get started', // en-fallback
'journey.status.draft': 'Draft', // en-fallback
'journey.status.active': 'Active', // en-fallback
'journey.status.completed': 'Completed', // en-fallback
'journey.status.upcoming': 'Upcoming', // en-fallback
'journey.checkin.add': 'Check in', // en-fallback
'journey.checkin.namePlaceholder': 'Location name', // en-fallback
'journey.checkin.notesPlaceholder': 'Notes (optional)', // en-fallback
'journey.checkin.save': 'Save', // en-fallback
'journey.checkin.error': 'Could not save check-in', // en-fallback
'journey.entry.add': 'Journal', // en-fallback
'journey.entry.edit': 'Edit entry', // en-fallback
'journey.entry.titlePlaceholder': 'Title (optional)', // en-fallback
'journey.entry.bodyPlaceholder': 'What happened today?', // en-fallback
'journey.entry.save': 'Save', // en-fallback
'journey.entry.error': 'Could not save entry', // en-fallback
'journey.photo.add': 'Photo', // en-fallback
'journey.photo.uploadError': 'Upload failed', // en-fallback
'journey.share.share': 'Share', // en-fallback
'journey.share.public': 'Public', // en-fallback
'journey.share.linkCopied': 'Public link copied', // en-fallback
'journey.share.disabled': 'Public sharing disabled', // en-fallback
'journey.editor.titlePlaceholder': 'Give this moment a name...', // en-fallback
'journey.editor.bodyPlaceholder': 'Tell the story of this day...', // en-fallback
'journey.editor.placePlaceholder': 'Location (optional)', // en-fallback
'journey.editor.tagsPlaceholder':
'Tags: hidden gem, best meal, must revisit...', // en-fallback
'journey.visibility.private': 'Private', // en-fallback
'journey.visibility.shared': 'Shared', // en-fallback
'journey.visibility.public': 'Public', // en-fallback
'journey.emptyState.title': 'Your story starts here', // en-fallback
'journey.emptyState.subtitle':
'Check in at a place or write your first journal entry', // en-fallback
'journey.frontpage.subtitle':
"Turn your trips into stories you'll never forget", // en-fallback
'journey.frontpage.createJourney': 'Create Journey', // en-fallback
'journey.frontpage.activeJourney': 'Active Journey', // en-fallback
'journey.frontpage.allJourneys': 'All Journeys', // en-fallback
'journey.frontpage.journeys': 'journeys', // en-fallback
'journey.frontpage.createNew': 'Create a new Journey', // en-fallback
'journey.frontpage.createNewSub':
'Pick trips, write stories, share your adventures', // en-fallback
'journey.frontpage.live': 'Live', // en-fallback
'journey.frontpage.synced': 'Synced', // en-fallback
'journey.frontpage.continueWriting': 'Continue writing', // en-fallback
'journey.frontpage.updated': 'Updated {time}', // en-fallback
'journey.frontpage.suggestionLabel': 'Trip just ended', // en-fallback
'journey.frontpage.suggestionText':
'Turn <strong>{title}</strong> into a Journey', // en-fallback
'journey.frontpage.dismiss': 'Dismiss', // en-fallback
'journey.frontpage.journeyName': 'Journey Name', // en-fallback
'journey.frontpage.namePlaceholder': 'e.g. Southeast Asia 2026', // en-fallback
'journey.frontpage.selectTrips': 'Select Trips', // en-fallback
'journey.frontpage.tripsSelected': 'trips selected', // en-fallback
'journey.frontpage.trips': 'trips', // en-fallback
'journey.frontpage.placesImported': 'places will be imported', // en-fallback
'journey.frontpage.places': 'places', // en-fallback
'journey.detail.syncedWithTrips': 'Synced with Trips', // en-fallback
'journey.detail.addEntry': 'Add Entry', // en-fallback
'journey.detail.newEntry': 'New Entry', // en-fallback
'journey.detail.editEntry': 'Edit Entry', // en-fallback
'journey.detail.noEntries': 'No entries yet', // en-fallback
'journey.detail.noEntriesHint':
'Add a trip to get started with skeleton entries', // en-fallback
'journey.detail.noPhotos': 'No photos yet', // en-fallback
'journey.detail.noPhotosHint':
'Upload photos to entries or browse your Immich/Synology library', // en-fallback
'journey.detail.journeyTab': 'Journey', // en-fallback
'journey.detail.journeyStats': 'Journey Stats', // en-fallback
'journey.detail.syncedTrips': 'Synced Trips', // en-fallback
'journey.detail.noTripsLinked': 'No trips linked yet', // en-fallback
'journey.detail.contributors': 'Contributors', // en-fallback
'journey.detail.readMore': 'Read more', // en-fallback
'journey.detail.prosCons': 'Pros & Cons', // en-fallback
'journey.stats.days': 'Days', // en-fallback
'journey.stats.cities': 'Cities', // en-fallback
'journey.stats.entries': 'Entries', // en-fallback
'journey.stats.photos': 'Photos', // en-fallback
'journey.stats.places': 'Places', // en-fallback
'journey.verdict.lovedIt': 'Loved it', // en-fallback
'journey.verdict.couldBeBetter': 'Could be better', // en-fallback
'journey.synced.places': 'places', // en-fallback
'journey.synced.synced': 'synced', // en-fallback
'journey.editor.allPhotosAdded': 'All photos already added', // en-fallback
'journey.editor.writeStory': 'Write your story...', // en-fallback
'journey.editor.prosCons': 'Pros & Cons', // en-fallback
'journey.editor.pros': 'Pros', // en-fallback
'journey.editor.cons': 'Cons', // en-fallback
'journey.editor.proPlaceholder': 'Something great...', // en-fallback
'journey.editor.conPlaceholder': 'Not so great...', // en-fallback
'journey.editor.date': 'Date', // en-fallback
'journey.editor.location': 'Location', // en-fallback
'journey.editor.searchLocation': 'Search location...', // en-fallback
'journey.editor.mood': 'Mood', // en-fallback
'journey.editor.weather': 'Weather', // en-fallback
'journey.editor.photoFirst': '1st', // en-fallback
'journey.mood.amazing': 'Amazing', // en-fallback
'journey.mood.good': 'Good', // en-fallback
'journey.mood.neutral': 'Neutral', // en-fallback
'journey.mood.rough': 'Rough', // en-fallback
'journey.weather.sunny': 'Sunny', // en-fallback
'journey.weather.partly': 'Partly cloudy', // en-fallback
'journey.weather.cloudy': 'Cloudy', // en-fallback
'journey.weather.rainy': 'Rainy', // en-fallback
'journey.weather.stormy': 'Stormy', // en-fallback
'journey.weather.cold': 'Snowy', // en-fallback
'journey.trips.linkTrip': 'Link Trip', // en-fallback
'journey.trips.searchTrip': 'Search Trip', // en-fallback
'journey.trips.searchPlaceholder': 'Trip name or destination...', // en-fallback
'journey.trips.noTripsAvailable': 'No trips available', // en-fallback
'journey.trips.link': 'Link', // en-fallback
'journey.trips.tripLinked': 'Trip linked', // en-fallback
'journey.trips.linkFailed': 'Failed to link trip', // en-fallback
'journey.trips.addTrip': 'Add Trip', // en-fallback
'journey.trips.unlinkTrip': 'Unlink Trip', // en-fallback
'journey.trips.unlinkMessage':
'Unlink "{title}"? All synced entries and photos from this trip will be permanently deleted. This cannot be undone.', // en-fallback
'journey.trips.unlink': 'Unlink', // en-fallback
'journey.trips.tripUnlinked': 'Trip unlinked', // en-fallback
'journey.trips.unlinkFailed': 'Failed to unlink trip', // en-fallback
'journey.trips.noTripsLinkedSettings': 'No trips linked', // en-fallback
'journey.contributors.invite': 'Invite Contributor', // en-fallback
'journey.contributors.searchUser': 'Search User', // en-fallback
'journey.contributors.searchPlaceholder': 'Username or email...', // en-fallback
'journey.contributors.noUsers': 'No users found', // en-fallback
'journey.contributors.role': 'Role', // en-fallback
'journey.contributors.added': 'Contributor added', // en-fallback
'journey.contributors.addFailed': 'Failed to add contributor', // 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
'journey.share.publicShare': 'Public Share', // en-fallback
'journey.share.createLink': 'Create share link', // en-fallback
'journey.share.linkCreated': 'Share link created', // en-fallback
'journey.share.createFailed': 'Failed to create link', // en-fallback
'journey.share.timeline': 'Timeline', // en-fallback
'journey.share.gallery': 'Gallery', // en-fallback
'journey.share.map': 'Map', // en-fallback
'journey.share.removeLink': 'Remove share link', // en-fallback
'journey.share.linkDeleted': 'Share link deleted', // en-fallback
'journey.share.deleteFailed': 'Failed to delete', // en-fallback
'journey.share.updateFailed': 'Failed to update', // en-fallback
'journey.settings.title': 'Journey Settings', // en-fallback
'journey.settings.coverImage': 'Cover Image', // en-fallback
'journey.settings.changeCover': 'Change cover', // en-fallback
'journey.settings.addCover': 'Add cover image', // en-fallback
'journey.settings.name': 'Name', // en-fallback
'journey.settings.subtitle': 'Subtitle', // en-fallback
'journey.settings.subtitlePlaceholder': 'e.g. Thailand, Vietnam & Cambodia', // en-fallback
'journey.settings.delete': 'Delete', // en-fallback
'journey.settings.deleteJourney': 'Delete Journey', // en-fallback
'journey.settings.deleteMessage':
'Delete "{title}"? All entries and photos will be lost.', // en-fallback
'journey.settings.saved': 'Settings saved', // en-fallback
'journey.settings.saveFailed': 'Failed to save', // en-fallback
'journey.settings.coverUpdated': 'Cover updated', // en-fallback
'journey.settings.coverFailed': 'Upload failed', // en-fallback
'journey.public.notFound': 'Not Found', // en-fallback
'journey.public.notFoundMessage':
"This journey doesn't exist or the link has expired.", // en-fallback
'journey.public.readOnly': 'Read-only · Public Journey', // en-fallback
'journey.public.tagline': 'Travel Resource & Exploration Kit', // en-fallback
'journey.public.sharedVia': 'Shared via', // en-fallback
'journey.public.madeWith': 'Made with', // en-fallback
'journey.pdf.journeyBook': 'Journey Book', // en-fallback
'journey.pdf.madeWith': 'Made with TREK', // en-fallback
'journey.pdf.day': 'Day', // en-fallback
'journey.pdf.theEnd': 'The End', // en-fallback
'journey.pdf.saveAsPdf': 'Save as PDF', // en-fallback
'journey.pdf.pages': 'pages', // en-fallback
};
export default journey;
+95
View File
@@ -0,0 +1,95 @@
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.username': 'اسم المستخدم',
'login.oidc.registrationDisabled': 'التسجيل معطّل. تواصل مع المسؤول.',
'login.oidc.noEmail': 'لم يتم استلام بريد إلكتروني من المزوّد.',
'login.oidc.tokenFailed': 'فشلت المصادقة.',
'login.oidc.invalidState': 'جلسة غير صالحة. حاول مرة أخرى.',
'login.demoFailed': 'فشل الدخول إلى العرض التجريبي',
'login.oidcSignIn': 'تسجيل الدخول عبر {name}',
'login.oidcOnly':
'تم تعطيل المصادقة بكلمة المرور. يرجى تسجيل الدخول عبر مزود SSO.',
'login.oidcLoggedOut': 'تم تسجيل خروجك. سجّل الدخول مجدداً عبر مزود SSO.',
'login.demoHint': 'جرّب العرض التجريبي دون الحاجة للتسجيل',
'login.mfaTitle': 'المصادقة الثنائية',
'login.mfaSubtitle': 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.',
'login.mfaCodeLabel': 'رمز التحقق',
'login.mfaCodeRequired': 'أدخل الرمز من تطبيق المصادقة.',
'login.mfaHint': 'افتح Google Authenticator أو Authy أو أي تطبيق TOTP آخر.',
'login.mfaBack': '← العودة لتسجيل الدخول',
'login.mfaVerify': 'تحقق',
'login.invalidInviteLink': 'رابط الدعوة غير صالح أو منتهي الصلاحية',
'login.oidcFailed': 'فشل تسجيل الدخول عبر OIDC',
'login.usernameRequired': 'اسم المستخدم مطلوب',
'login.passwordMinLength': 'يجب أن تكون كلمة المرور 8 أحرف على الأقل',
'login.forgotPassword': 'نسيت كلمة المرور؟',
'login.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.emailPlaceholder': 'your@email.com', // en-fallback
'login.passkey.signIn': 'تسجيل الدخول باستخدام مفتاح المرور',
'login.passkey.failed': 'فشل تسجيل الدخول بمفتاح المرور. يرجى المحاولة مرة أخرى.',
};
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': 'Immich غير متصل',
'memories.notConnectedHint':
'قم بتوصيل Immich في الإعدادات لعرض صور رحلتك هنا.',
'memories.notConnectedMultipleHint':
'قم بتوصيل أحد موفري الصور هؤلاء: {provider_names} في الإعدادات لتتمكن من إضافة صور إلى هذه الرحلة.',
'memories.noDates': 'أضف تواريخ لرحلتك لتحميل الصور.',
'memories.noPhotos': 'لم يتم العثور على صور',
'memories.noPhotosHint': 'لم يتم العثور على صور في Immich لفترة هذه الرحلة.',
'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': 'نسخ صور الرحلة إلى Immich عند الرفع',
'memories.providerUrlHintSynology':
'أدرج مسار تطبيق Photos في URL، مثل https://nas:5001/photo',
'memories.testConnection': 'اختبار الاتصال',
'memories.testShort': 'اختبار',
'memories.testFirst': 'اختبر الاتصال أولاً',
'memories.connected': 'متصل',
'memories.disconnected': 'غير متصل',
'memories.connectionSuccess': 'تم الاتصال بـ Immich',
'memories.connectionError': 'تعذر الاتصال بـ Immich',
'memories.saved': 'تم حفظ إعدادات {provider_name}',
'memories.providerDisconnectedBanner':
'اتصالك بـ {provider_name} مفقود. أعد الاتصال في الإعدادات لعرض الصور.',
'memories.saveError': 'تعذّر حفظ إعدادات {provider_name}',
'memories.addPhotos': 'إضافة صور',
'memories.linkAlbum': 'ربط ألبوم',
'memories.selectAlbum': 'اختيار ألبوم Immich',
'memories.selectAlbumMultiple': 'اختيار ألبوم',
'memories.noAlbums': 'لم يتم العثور على ألبومات',
'memories.syncAlbum': 'مزامنة الألبوم',
'memories.unlinkAlbum': 'إلغاء الربط',
'memories.photos': 'صور',
'memories.selectPhotos': 'اختيار صور من Immich',
'memories.selectPhotosMultiple': 'اختيار الصور',
'memories.selectHint': 'انقر على الصور لتحديدها.',
'memories.selected': 'محدد',
'memories.addSelected': 'إضافة {count} صور',
'memories.alreadyAdded': 'تمت الإضافة',
'memories.private': 'خاص',
'memories.stopSharing': 'إيقاف المشاركة',
'memories.oldest': 'الأقدم أولاً',
'memories.newest': 'الأحدث أولاً',
'memories.allLocations': 'جميع المواقع',
'memories.tripDates': 'تواريخ الرحلة',
'memories.allPhotos': 'جميع الصور',
'memories.confirmShareTitle': 'مشاركة مع أعضاء الرحلة؟',
'memories.confirmShareHint':
'{count} صور ستكون مرئية لجميع أعضاء هذه الرحلة. يمكنك جعل الصور الفردية خاصة لاحقًا.',
'memories.confirmShareButton': 'مشاركة الصور',
'memories.error.loadAlbums': 'فشل تحميل الألبومات',
'memories.error.linkAlbum': 'فشل ربط الألبوم',
'memories.error.unlinkAlbum': 'فشل إلغاء ربط الألبوم',
'memories.error.syncAlbum': 'فشل مزامنة الألبوم',
'memories.error.loadPhotos': 'فشل تحميل الصور',
'memories.error.addPhotos': 'فشل إضافة الصور',
'memories.error.removePhoto': 'فشل حذف الصورة',
'memories.error.toggleSharing': 'فشل تحديث إعدادات المشاركة',
'memories.saveRouteNotConfigured': 'مسار الحفظ غير مهيأ لهذا المزود',
'memories.testRouteNotConfigured': 'مسار الاختبار غير مهيأ لهذا المزود',
'memories.fillRequiredFields': 'يرجى ملء جميع الحقول المطلوبة',
};
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': '{todo} في {trip} مستحقة في {due}',
'notif.vacay_invite.title': 'دعوة دمج الإجازة',
'notif.vacay_invite.text': '{actor} يدعوك لدمج خطط الإجازة',
'notif.photos_shared.title': 'تمت مشاركة الصور',
'notif.photos_shared.text': '{actor} شارك {count} صورة في {trip}',
'notif.collab_message.title': 'رسالة جديدة',
'notif.collab_message.text': '{actor} أرسل رسالة في {trip}',
'notif.packing_tagged.title': 'مهمة التعبئة',
'notif.packing_tagged.text': '{actor} عيّنك في {category} في {trip}',
'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;
+37
View File
@@ -0,0 +1,37 @@
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.versionAvailable.title': 'تحديث متاح',
'notifications.versionAvailable.text': 'TREK {version} متاح الآن.',
'notifications.versionAvailable.button': 'عرض التفاصيل',
'notifications.test.title': 'إشعار تجريبي من {actor}',
'notifications.test.text': 'هذا إشعار تجريبي بسيط.',
'notifications.test.booleanTitle': 'يطلب منك {actor} الموافقة',
'notifications.test.booleanText': 'إشعار تجريبي يتطلب إجابة.',
'notifications.test.accept': 'موافقة',
'notifications.test.decline': 'رفض',
'notifications.test.navigateTitle': 'تحقق من شيء ما',
'notifications.test.navigateText': 'إشعار تجريبي للتنقل.',
'notifications.test.goThere': 'اذهب إلى هناك',
'notifications.test.adminTitle': 'إذاعة المسؤول',
'notifications.test.adminText':
'أرسل {actor} إشعاراً تجريبياً لجميع المسؤولين.',
'notifications.test.tripTitle': 'نشر {actor} في رحلتك',
'notifications.test.tripText': 'إشعار تجريبي للرحلة "{trip}".',
};
export default notifications;
+119
View File
@@ -0,0 +1,119 @@
import type { TranslationStrings } from '../types';
const oauth: TranslationStrings = {
'oauth.scope.group.trips': 'الرحلات',
'oauth.scope.group.places': 'الأماكن',
'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.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':
'البحث عن مواقع وحل عناوين الخرائط والترميز الجغرافي العكسي للإحداثيات',
'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.scope.group.atlas': 'Atlas', // en-fallback
'oauth.scope.group.geo': 'Geo', // en-fallback
'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;
+184
View File
@@ -0,0 +1,184 @@
import type { TranslationStrings } from '../types';
const packing: TranslationStrings = {
'packing.title': 'قائمة التجهيز',
'packing.empty': 'قائمة التجهيز فارغة',
'packing.import': 'استيراد',
'packing.importTitle': 'استيراد قائمة التعبئة',
'packing.importHint':
'عنصر واحد لكل سطر. يمكن إضافة الفئة والكمية مفصولة بفاصلة أو فاصلة منقوطة أو علامة تبويب: الاسم، الفئة، الكمية',
'packing.importPlaceholder':
'فرشاة أسنان\nواقي شمس، نظافة\nقمصان، ملابس، 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.noMembers': 'لا أعضاء',
'packing.addItem': 'إضافة عنصر',
'packing.addItemPlaceholder': 'اسم العنصر...',
'packing.addCategory': 'إضافة فئة',
'packing.newCategoryPlaceholder': 'اسم الفئة (مثال: ملابس)',
'packing.applyTemplate': 'تطبيق قالب',
'packing.template': 'قالب',
'packing.templateApplied': 'تمت إضافة {count} عنصر من القالب',
'packing.templateError': 'فشل تطبيق القالب',
'packing.saveAsTemplate': 'حفظ كقالب',
'packing.templateName': 'اسم القالب',
'packing.templateSaved': 'تم حفظ قائمة الحقائب كقالب',
'packing.bags': 'أمتعة',
'packing.noBag': 'غير معيّن',
'packing.totalWeight': 'الوزن الإجمالي',
'packing.bagName': 'الاسم...',
'packing.addBag': 'إضافة أمتعة',
'packing.changeCategory': 'تغيير الفئة',
'packing.confirm.clearChecked': 'هل تريد إزالة {count} عنصر محدد؟',
'packing.confirm.deleteCat': 'هل تريد حذف الفئة "{name}" مع {count} عنصر؟',
'packing.defaultCategory': 'أخرى',
'packing.toast.saveError': 'فشل الحفظ',
'packing.toast.deleteError': 'فشل الحذف',
'packing.toast.renameError': 'فشلت إعادة التسمية',
'packing.toast.addError': 'فشلت الإضافة',
'packing.suggestions.items': [
{
name: 'جواز السفر',
category: 'المستندات',
},
{
name: 'بطاقة الهوية',
category: 'المستندات',
},
{
name: 'تأمين السفر',
category: 'المستندات',
},
{
name: 'تذاكر الطيران',
category: 'المستندات',
},
{
name: 'بطاقة ائتمان',
category: 'المالية',
},
{
name: 'نقد',
category: 'المالية',
},
{
name: 'تأشيرة',
category: 'المستندات',
},
{
name: 'قمصان',
category: 'الملابس',
},
{
name: 'بنطلونات',
category: 'الملابس',
},
{
name: 'ملابس داخلية',
category: 'الملابس',
},
{
name: 'جوارب',
category: 'الملابس',
},
{
name: 'جاكيت',
category: 'الملابس',
},
{
name: 'ملابس نوم',
category: 'الملابس',
},
{
name: 'ملابس سباحة',
category: 'الملابس',
},
{
name: 'معطف مطر',
category: 'الملابس',
},
{
name: 'أحذية مريحة',
category: 'الملابس',
},
{
name: 'فرشاة أسنان',
category: 'أدوات العناية',
},
{
name: 'معجون أسنان',
category: 'أدوات العناية',
},
{
name: 'شامبو',
category: 'أدوات العناية',
},
{
name: 'مزيل عرق',
category: 'أدوات العناية',
},
{
name: 'واقي شمس',
category: 'أدوات العناية',
},
{
name: 'شفرة حلاقة',
category: 'أدوات العناية',
},
{
name: 'شاحن',
category: 'الإلكترونيات',
},
{
name: 'بطارية محمولة',
category: 'الإلكترونيات',
},
{
name: 'سماعات',
category: 'الإلكترونيات',
},
{
name: 'محول سفر',
category: 'الإلكترونيات',
},
{
name: 'كاميرا',
category: 'الإلكترونيات',
},
{
name: 'مسكنات ألم',
category: 'الصحة',
},
{
name: 'لاصقات جروح',
category: 'الصحة',
},
{
name: 'مطهر',
category: 'الصحة',
},
],
};
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;
+56
View File
@@ -0,0 +1,56 @@
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;
+26
View File
@@ -0,0 +1,26 @@
import type { TranslationStrings } from '../types';
const photos: TranslationStrings = {
'photos.title': 'صور',
'photos.subtitle': '{count} صورة لـ {trip}',
'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 ميغابايت · حتى 30 صورة',
};
export default photos;
+95
View File
@@ -0,0 +1,95 @@
import type { TranslationStrings } from '../types';
const places: TranslationStrings = {
'places.addPlace': 'إضافة مكان/نشاط',
'places.importFile': 'استيراد ملف',
'places.sidebarDrop': 'أفلت للاستيراد',
'places.importFileHint':
'استورد ملفات .gpx أو .kml أو .kmz من أدوات مثل Google My Maps وGoogle Earth أو جهاز تتبع GPS.',
'places.importFileDropHere': 'انقر لاختيار ملف أو اسحبه وأفلته هنا',
'places.importFileDropActive': 'أفلت الملف للاختيار',
'places.importFileUnsupported':
'نوع الملف غير مدعوم. استخدم .gpx أو .kml أو .kmz.',
'places.importFileTooLarge':
'الملف كبير جدًا. الحد الأقصى لحجم الرفع هو {maxMb} MB.',
'places.importFileError': 'فشل الاستيراد',
'places.importAllSkipped': 'جميع الأماكن موجودة بالفعل في الرحلة.',
'places.gpxImported': 'تم استيراد {count} مكان من GPX',
'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': 'تم استيراد {count} مكان من KMZ/KML',
'places.urlResolved': 'تم استيراد المكان من الرابط',
'places.importList': 'استيراد قائمة',
'places.kmlKmzSummaryValues':
'علامات المواضع: {total} • تم الاستيراد: {created} • تم التجاوز: {skipped}',
'places.importGoogleList': 'قائمة Google',
'places.importNaverList': 'قائمة Naver',
'places.googleListHint':
'الصق رابط قائمة Google Maps المشتركة لاستيراد جميع الأماكن.',
'places.googleListImported': 'تم استيراد {count} أماكن من "{list}"',
'places.googleListError': 'فشل استيراد قائمة Google Maps',
'places.naverListHint':
'الصق رابط قائمة Naver Maps مشتركة لاستيراد جميع الأماكن.',
'places.naverListImported': 'تم استيراد {count} مكان من "{list}"',
'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': 'مكان واحد',
'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.',
};
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': 'يلزم مكانان على الأقل مع إحداثيات',
'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': 'مكان واحد',
'planner.placeN': '{n} أماكن',
'planner.addNote': 'إضافة ملاحظة',
'planner.noEntries': 'لا توجد عناصر لهذا اليوم',
'planner.addPlace': 'إضافة مكان/نشاط',
'planner.addPlaceShort': '+ إضافة مكان/نشاط',
'planner.resPending': 'حجز قيد الانتظار · ',
'planner.resConfirmed': 'حجز مؤكد · ',
'planner.notePlaceholder': 'ملاحظة…',
'planner.noteTimePlaceholder': 'الوقت (اختياري)',
'planner.noteExamplePlaceholder':
'مثال: S3 الساعة 14:30 من المحطة المركزية، عبّارة من الرصيف 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;
+161
View File
@@ -0,0 +1,161 @@
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': 'مثال: Lufthansa 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': 'TZ المغادرة',
'reservations.meta.arrivalTimezone': 'TZ الوصول',
'reservations.span.departure': 'المغادرة',
'reservations.span.arrival': 'الوصول',
'reservations.span.inTransit': 'في الطريق',
'reservations.span.pickup': 'الاستلام',
'reservations.span.return': 'الإرجاع',
'reservations.span.active': 'نشط',
'reservations.span.start': 'البداية',
'reservations.span.end': 'النهاية',
'reservations.span.ongoing': 'جارٍ',
'reservations.validation.endBeforeStart':
'يجب أن يكون تاريخ/وقت الانتهاء بعد تاريخ/وقت البدء',
'reservations.addBooking': 'إضافة حجز',
'reservations.import.title': 'استيراد تأكيدات الحجز',
'reservations.import.cta': 'استيراد من ملف',
'reservations.import.dropHere': 'أسقط ملفات تأكيد الحجز هنا أو انقر للتحديد',
'reservations.import.dropActive': 'أسقط الملفات للاستيراد',
'reservations.import.acceptedFormats': 'المقبول: EML، PDF، PKPass، HTML، TXT (بحد أقصى 10 ميغابايت لكل ملف، حتى 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 ميغابايت.',
'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;
+339
View File
@@ -0,0 +1,339 @@
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.about': 'حول',
'settings.map': 'الخريطة',
'settings.mapTemplate': 'قالب الخريطة',
'settings.mapTemplatePlaceholder.select': 'اختر قالبًا...',
'settings.mapDefaultHint': 'اتركه فارغًا لاستخدام OpenStreetMap افتراضيًا',
'settings.mapHint': 'قالب URL لبلاطات الخريطة',
'settings.mapProvider': 'مزود الخريطة',
'settings.mapProviderHint':
'يؤثر على خرائط Trip Planner و Journey. يستخدم Atlas دائمًا Leaflet.',
'settings.mapLeafletSubtitle': '2D كلاسيكي، أي بلاطات نقطية',
'settings.mapMapboxSubtitle': 'بلاطات متجهية ومبانٍ ثلاثية الأبعاد وتضاريس',
'settings.mapExperimental': 'تجريبي',
'settings.mapMapboxToken': 'رمز وصول Mapbox',
'settings.mapMapboxTokenHint': 'الرمز العام (pk.*) من',
'settings.mapMapboxTokenLink': 'mapbox.com ← رموز الوصول',
'settings.mapStyle': 'نمط الخريطة',
'settings.mapStylePlaceholder': 'اختر نمط Mapbox',
'settings.mapStyleHint':
'إعداد مسبق أو عنوان URL mapbox://styles/USER/ID خاص بك',
'settings.map3dBuildings': 'مبانٍ ثلاثية الأبعاد وتضاريس',
'settings.map3dHint':
'إمالة + مبانٍ ثلاثية الأبعاد حقيقية — يعمل مع كل نمط بما في ذلك الأقمار الصناعية.',
'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).',
'settings.weatherKey': 'مفتاح OpenWeatherMap API',
'settings.weatherKeyHint': 'لبيانات الطقس.',
'settings.keyPlaceholder': 'أدخل المفتاح...',
'settings.configured': 'مُعدّ',
'settings.saveKeys': 'حفظ المفاتيح',
'settings.display': 'العرض',
'settings.colorMode': 'نمط الألوان',
'settings.light': 'فاتح',
'settings.dark': 'داكن',
'settings.auto': 'تلقائي',
'settings.language': 'اللغة',
'settings.temperature': 'وحدة الحرارة',
'settings.timeFormat': 'تنسيق الوقت',
'settings.bookingLabels': 'تسميات مسارات الحجوزات',
'settings.bookingLabelsHint':
'عرض أسماء المحطات/المطارات على الخريطة. عند الإيقاف، يتم عرض الرمز فقط.',
'settings.blurBookingCodes': 'إخفاء رموز الحجز',
'settings.optimizeFromAccommodation': 'تحسين المسار انطلاقًا من مكان الإقامة',
'settings.optimizeFromAccommodationHint':
'عند تحسين يوم ما، يبدأ المسار من الفندق الذي تستيقظ فيه وينتهي عند الفندق الذي تسجّل الوصول إليه في تلك الليلة.',
'settings.notifications': 'الإشعارات',
'settings.notifyTripInvite': 'دعوات الرحلات',
'settings.notifyBookingChange': 'تغييرات الحجز',
'settings.notifyTripReminder': 'تذكيرات الرحلات',
'settings.notifyTodoDue': 'مهمة مستحقة',
'settings.notifyVacayInvite': 'دعوات دمج الإجازات',
'settings.notifyPhotosShared': 'صور مشتركة (Immich)',
'settings.notifyCollabMessage': 'رسائل الدردشة (Collab)',
'settings.notifyPackingTagged': 'قائمة الأمتعة: التعيينات',
'settings.notifyWebhook': 'إشعارات Webhook',
'settings.notifyVersionAvailable': 'إصدار جديد متاح',
'settings.notificationPreferences.noChannels':
'لم يتم تكوين قنوات إشعارات. اطلب من المسؤول إعداد إشعارات البريد الإلكتروني أو webhook.',
'settings.webhookUrl.label': 'رابط Webhook',
'settings.webhookUrl.hint':
'أدخل رابط Webhook الخاص بـ Discord أو Slack أو المخصص لتلقي الإشعارات.',
'settings.webhookUrl.saved': 'تم حفظ رابط Webhook',
'settings.webhookUrl.test': 'اختبار',
'settings.webhookUrl.testSuccess': 'تم إرسال Webhook الاختباري بنجاح',
'settings.webhookUrl.testFailed': 'فشل إرسال Webhook الاختباري',
'settings.ntfyUrl.topicLabel': 'موضوع Ntfy',
'settings.ntfyUrl.serverLabel': 'عنوان URL خادم Ntfy (اختياري)',
'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 إلى ضبط وفق نظامك (مثلاً C:\\PROGRA~1\\nodejs\\npx.cmd على Windows).',
'settings.mcp.clientConfigHintOAuth':
'استبدل <your_client_id> و<your_client_secret> ببيانات الاعتماد المعروضة في عميل OAuth 2.1 الذي أنشأته أعلاه. سيفتح mcp-remote متصفحك لإتمام التفويض في أول اتصال. قد يحتاج مسار npx إلى تعديل حسب نظامك (مثال: C:PROGRA~1\nodejs\npx.cmd على Windows).',
'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': 'معرّف العميل',
'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.redirectUrisHint':
'عنوان URI واحد لكل سطر. يُطلب HTTPS (localhost مستثنى). يُطبق تطابق دقيق.',
'settings.oauth.modal.scopes': 'النطاقات المسموح بها',
'settings.oauth.modal.scopesHint':
'list_trips وget_trip_summary متاحان دائماً — لا يُطلب نطاق. يساعدان الذكاء الاصطناعي في اكتشاف معرّفات الرحلات.',
'settings.oauth.modal.selectAll': 'تحديد الكل',
'settings.oauth.modal.deselectAll': 'إلغاء تحديد الكل',
'settings.oauth.modal.creating': 'جارٍ التسجيل…',
'settings.oauth.modal.create': 'تسجيل العميل',
'settings.oauth.modal.createdTitle': 'تم تسجيل العميل',
'settings.oauth.modal.createdWarning':
'يُعرض سر العميل مرة واحدة فقط. انسخه الآن — لا يمكن استرداده.',
'settings.oauth.toast.createError': 'فشل تسجيل عميل 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 — لا تحتاج إلى عناوين إعادة التوجيه. يُصدر الرمز المميز مباشرةً عبر client_id + client_secret ويعمل بصلاحياتك ضمن النطاقات المحددة.',
'settings.oauth.modal.machineClientUsage':
'للحصول على رمز مميز: POST /oauth/token مع grant_type=client_credentials وclient_id وclient_secret. بدون متصفح، بدون رمز تحديث.',
'settings.oauth.badge.machine': 'آلي',
'settings.account': 'الحساب',
'settings.about': 'حول',
'settings.about.reportBug': 'الإبلاغ عن خطأ',
'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.description':
'TREK هو مخطط سفر مستضاف ذاتيًا يساعدك على تنظيم رحلاتك من أول فكرة حتى آخر ذكرى. تخطيط يومي، ميزانية، قوائم تعبئة، صور والمزيد — كل شيء في مكان واحد، على خادمك الخاص.',
'settings.about.madeWith': 'صُنع بـ',
'settings.about.madeBy': 'بواسطة موريس ومجتمع مفتوح المصدر متنامٍ.',
'settings.username': 'اسم المستخدم',
'settings.email': 'البريد الإلكتروني',
'settings.role': 'الدور',
'settings.roleAdmin': 'مسؤول',
'settings.oidcLinked': 'مرتبط مع',
'settings.changePassword': 'تغيير كلمة المرور',
'settings.currentPassword': 'كلمة المرور الحالية',
'settings.currentPasswordRequired': 'كلمة المرور الحالية مطلوبة',
'settings.newPassword': 'كلمة المرور الجديدة',
'settings.confirmPassword': 'تأكيد كلمة المرور الجديدة',
'settings.updatePassword': 'تحديث كلمة المرور',
'settings.passwordRequired': 'أدخل كلمة المرور الحالية والجديدة',
'settings.passwordTooShort': 'يجب أن تتكون كلمة المرور من 8 أحرف على الأقل',
'settings.passwordMismatch': 'كلمتا المرور غير متطابقتين',
'settings.passwordWeak':
'يجب أن تحتوي كلمة المرور على حرف كبير وحرف صغير ورقم ورمز خاص',
'settings.passwordChanged': 'تم تغيير كلمة المرور بنجاح',
'settings.mustChangePassword':
'يجب عليك تغيير كلمة المرور قبل المتابعة. يرجى تعيين كلمة مرور جديدة أدناه.',
'settings.deleteAccount': 'حذف الحساب',
'settings.deleteAccountTitle': 'هل تريد حذف حسابك؟',
'settings.deleteAccountWarning':
'سيتم حذف حسابك وجميع رحلاتك وأماكنك وملفاتك نهائيًا. لا يمكن التراجع عن ذلك.',
'settings.deleteAccountConfirm': 'حذف نهائي',
'settings.deleteBlockedTitle': 'الحذف غير ممكن',
'settings.deleteBlockedMessage':
'أنت المسؤول الوحيد. قم بترقية مستخدم آخر إلى مسؤول قبل حذف حسابك.',
'settings.roleUser': 'مستخدم',
'settings.saveProfile': 'حفظ الملف الشخصي',
'settings.toast.mapSaved': 'تم حفظ إعدادات الخريطة',
'settings.toast.keysSaved': 'تم حفظ مفاتيح API',
'settings.toast.displaySaved': 'تم حفظ إعدادات العرض',
'settings.toast.profileSaved': 'تم حفظ الملف الشخصي',
'settings.uploadAvatar': 'رفع صورة الملف الشخصي',
'settings.removeAvatar': 'إزالة صورة الملف الشخصي',
'settings.avatarUploaded': 'تم تحديث صورة الملف الشخصي',
'settings.avatarRemoved': 'تمت إزالة صورة الملف الشخصي',
'settings.avatarError': 'فشل الرفع',
'settings.mfa.title': 'المصادقة الثنائية (2FA)',
'settings.mfa.description':
'تضيف خطوة ثانية عند تسجيل الدخول. استخدم تطبيق مصادقة (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': 'المصادقة الثنائية مفعّلة على حسابك.',
'settings.mfa.disabled': 'المصادقة الثنائية غير مفعّلة.',
'settings.mfa.setup': 'إعداد المصادقة',
'settings.mfa.scanQr': 'امسح رمز QR بتطبيقك أو أدخل المفتاح يدويًا.',
'settings.mfa.secretLabel': 'المفتاح السري (إدخال يدوي)',
'settings.mfa.codePlaceholder': 'رمز من 6 أرقام',
'settings.mfa.enable': 'تفعيل 2FA',
'settings.mfa.cancelSetup': 'إلغاء',
'settings.mfa.disableTitle': 'تعطيل 2FA',
'settings.mfa.disableHint': 'أدخل كلمة مرور حسابك ورمزًا حاليًا من المصادقة.',
'settings.mfa.disable': 'تعطيل 2FA',
'settings.mfa.toastEnabled': 'تم تفعيل المصادقة الثنائية',
'settings.mfa.toastDisabled': 'تم تعطيل المصادقة الثنائية',
'settings.mfa.demoBlocked': 'غير متاح في الوضع التجريبي',
'settings.tabs.offline': 'Offline', // en-fallback
'settings.mapTemplatePlaceholder':
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', // en-fallback
'settings.notificationPreferences.email': 'Email', // en-fallback
'settings.notificationPreferences.webhook': 'Webhook', // en-fallback
'settings.notificationPreferences.inapp': 'In-App', // en-fallback
'settings.notificationPreferences.ntfy': 'Ntfy', // en-fallback
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', // en-fallback
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts', // en-fallback
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh', // en-fallback
'settings.oauth.modal.redirectUrisPlaceholder':
'https://your-app.com/callback\nhttps://your-app.com/auth', // en-fallback
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket', // en-fallback
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP', // en-fallback
'settings.about.supporter.tier.businessClassDreamer':
'Business Class Dreamer', // en-fallback
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller', // en-fallback
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate', // en-fallback
"settings.currency": "Currency",
"settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.",
'settings.passkey.title': 'مفاتيح المرور',
'settings.passkey.description':
'سجّل الدخول بشكل أسرع وأكثر مقاومة للتصيّد باستخدام مفتاح مرور — ببصمة إصبعك أو وجهك أو رمز PIN أو مفتاح أمان مادي. تبقى كلمة المرور كنسخة احتياطية.',
'settings.passkey.notConfigured':
'مفاتيح المرور مفعّلة لكنها لم تُهيّأ بالكامل على هذا الخادم بعد. اطلب من المسؤول تعيين نطاق WebAuthn.',
'settings.passkey.add': 'إضافة مفتاح مرور',
'settings.passkey.addTitle': 'إضافة مفتاح مرور',
'settings.passkey.passwordPrompt':
'أكّد كلمة المرور الحالية، ثم اتبع التعليمات على جهازك.',
'settings.passkey.passwordRequired': 'كلمة المرور الحالية مطلوبة.',
'settings.passkey.namePlaceholder': 'الاسم (اختياري، مثل "iPhone")',
'settings.passkey.addedToast': 'تمت إضافة مفتاح المرور',
'settings.passkey.added': 'تمت الإضافة',
'settings.passkey.addError': 'تعذّرت إضافة مفتاح المرور',
'settings.passkey.cancelled': 'تم إلغاء إعداد مفتاح المرور',
'settings.passkey.deleted': 'تمت إزالة مفتاح المرور',
'settings.passkey.deleteConfirm':
'إزالة مفتاح المرور هذا؟ أكّد بكلمة المرور الخاصة بك.',
'settings.passkey.rename': 'إعادة التسمية',
'settings.passkey.defaultName': 'مفتاح المرور',
'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 المُستضافة ذاتيًا لاستيراد الرحلات ومزامنتها. أنشئ مفتاح API في AirTrail ضمن الإعدادات ← الأمان.',
'settings.airtrail.url': 'رابط النسخة',
'settings.airtrail.apiKey': 'مفتاح API',
'settings.airtrail.apiKeyPlaceholder': 'مفتاح API من نوع Bearer',
'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;
+54
View File
@@ -0,0 +1,54 @@
import type { TranslationStrings } from '../types';
const system_notice: TranslationStrings = {
'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':
'إعادة تصميم لوحة التحكم mobile-first',
'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': 'MCP: ترقية OAuth 2.1',
'system_notice.v3_mcp.body':
'تمت إعادة تصميم تكامل MCP بالكامل. OAuth 2.1 هو الآن طريقة المصادقة الموصى بها. الرموز الثابتة (trek_…) مهملة وستُزال في إصدار مستقبلي.',
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 موصى به (mcp-remote)',
'system_notice.v3_mcp.highlight_scopes': '24 نطاق أذونات دقيق',
'system_notice.v3_mcp.highlight_deprecated': 'الرموز الثابتة trek_ مهملة',
'system_notice.v3_mcp.highlight_tools': 'مجموعة أدوات وإرشادات موسعة',
'system_notice.v3_thankyou.title': 'كلمة شخصية مني',
'system_notice.v3_thankyou.body':
'قبل أن تمضي — أريد أن أتوقف لحظة.\n\nبدأ TREK كمشروع جانبي بنيته لرحلاتي الخاصة. لم أتخيل يومًا أنه سيكبر ليصبح شيئًا يعتمد عليه 4,000 منكم لتخطيط مغامراتهم. كل نجمة، كل مشكلة، كل طلب ميزة — أقرأها جميعًا، وهي ما يبقيني مستمرًا في الليالي المتأخرة بين عمل بدوام كامل والجامعة.\n\nأريدكم أن تعرفوا: TREK سيبقى دائمًا مفتوح المصدر، دائمًا مستضافًا ذاتيًا، دائمًا ملككم. لا تتبع، لا اشتراكات، لا شروط خفية. مجرد أداة بناها شخص يحب السفر بقدر ما تحبونه.\n\nشكر خاص لـ [jubnl](https://github.com/jubnl) — لقد أصبحت متعاونًا رائعًا. الكثير مما يجعل الإصدار 3.0 عظيمًا يحمل بصماتك. شكرًا لإيمانك بهذا المشروع عندما كان لا يزال في بداياته.\n\nولكل واحد منكم ممن أبلغ عن خطأ، أو ترجم نصًا، أو شارك TREK مع صديق، أو ببساطة استخدمه لتخطيط رحلة — **شكرًا لكم**. أنتم السبب في وجود هذا.\n\nإلى المزيد من المغامرات معًا.\n\n— Maurice\n\n---\n\n[انضم إلى المجتمع على Discord](https://discord.gg/7Q6M6jDwzf)\n\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** لتحديد الحسابات التي تحتاج إلى مراجعة.',
'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.pager.prev': 'الإشعار السابق',
'system_notice.pager.next': 'الإشعار التالي',
'system_notice.pager.goto': 'الانتقال إلى الإشعار {n}',
'system_notice.pager.position': 'الإشعار {current} من {total}',
'system_notice.dev_test_modal.title': '[Dev] Test notice', // en-fallback
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.', // en-fallback
'system_notice.pager.counter': '{current} / {total}', // en-fallback
};
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;
+96
View File
@@ -0,0 +1,96 @@
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.addCalendar': 'إضافة تقويم',
'vacay.calendarLabel': 'التسمية',
'vacay.calendarColor': 'اللون',
'vacay.noCalendars': 'لا توجد تقويمات',
'vacay.companyHolidays': 'عطل الشركة',
'vacay.companyHolidaysHint': 'السماح بوضع علامة على أيام عطلات الشركة',
'vacay.companyHolidaysNoDeduct': 'لا تُخصم عطل الشركة من أيام الإجازة.',
'vacay.weekStart': 'يبدأ الأسبوع في',
'vacay.weekStartHint': 'اختر ما إذا كان الأسبوع يبدأ يوم الاثنين أو الأحد',
'vacay.carryOver': 'الترحيل',
'vacay.carryOverHint':
'ترحيل أيام الإجازة المتبقية تلقائيًا إلى السنة التالية',
'vacay.sharing': 'المشاركة',
'vacay.sharingHint': 'شارك خطة إجازاتك مع مستخدمي TREK الآخرين',
'vacay.owner': 'المالك',
'vacay.shareEmailPlaceholder': 'البريد الإلكتروني لمستخدم TREK',
'vacay.shareSuccess': 'تمت مشاركة الخطة بنجاح',
'vacay.shareError': 'تعذرت مشاركة الخطة',
'vacay.dissolve': 'فك الدمج',
'vacay.dissolveHint': 'افصل التقويمات مرة أخرى. سيتم الاحتفاظ بإدخالاتك.',
'vacay.dissolveAction': 'فك',
'vacay.dissolved': 'تم فصل التقويم',
'vacay.fusedWith': 'مُدمج مع',
'vacay.you': 'أنت',
'vacay.noData': 'لا توجد بيانات',
'vacay.changeColor': 'تغيير اللون',
'vacay.inviteUser': 'دعوة مستخدم',
'vacay.inviteHint': 'ادعُ مستخدم TREK آخرًا لمشاركة تقويم إجازة مشترك.',
'vacay.selectUser': 'اختر مستخدمًا',
'vacay.sendInvite': 'إرسال الدعوة',
'vacay.inviteSent': 'تم إرسال الدعوة',
'vacay.inviteError': 'تعذر إرسال الدعوة',
'vacay.pending': 'قيد الانتظار',
'vacay.noUsersAvailable': 'لا يوجد مستخدمون متاحون',
'vacay.accept': 'قبول',
'vacay.decline': 'رفض',
'vacay.acceptFusion': 'قبول ودمج',
'vacay.inviteTitle': 'طلب دمج',
'vacay.inviteWantsToFuse': 'يريد مشاركة تقويم إجازة معك.',
'vacay.fuseInfo1': 'سيرى كلاكما جميع إدخالات الإجازة في تقويم مشترك واحد.',
'vacay.fuseInfo2': 'يمكن لكلا الطرفين إنشاء وتعديل الإدخالات لبعضهما البعض.',
'vacay.fuseInfo3': 'يمكن لكلا الطرفين حذف الإدخالات وتغيير مستحقات الإجازة.',
'vacay.fuseInfo4': 'تتم مشاركة الإعدادات مثل العطل الرسمية وعطل الشركة.',
'vacay.fuseInfo5':
'يمكن فك الدمج في أي وقت من قبل أي طرف. ستبقى إدخالاتك محفوظة.',
};
export default vacay;
+396
View File
@@ -0,0 +1,396 @@
import type { TranslationStrings } from '../types';
const admin: TranslationStrings = {
'admin.notifications.title': 'Notificações',
'admin.notifications.hint':
'Escolha um canal de notificação. Apenas um pode estar ativo por vez.',
'admin.notifications.none': 'Desativado',
'admin.notifications.email': 'E-mail (SMTP)',
'admin.notifications.webhook': 'Webhook',
'admin.notifications.save': 'Salvar configurações de notificação',
'admin.notifications.saved': 'Configurações de notificação salvas',
'admin.notifications.testWebhook': 'Enviar webhook de teste',
'admin.notifications.testWebhookSuccess':
'Webhook de teste enviado com sucesso',
'admin.notifications.testWebhookFailed': 'Falha ao enviar webhook de teste',
'admin.smtp.title': 'E-mail e notificações',
'admin.smtp.hint': 'Configuração SMTP para envio de notificações por e-mail.',
'admin.smtp.testButton': 'Enviar e-mail de teste',
'admin.webhook.hint':
'Enviar notificações para um webhook externo (Discord, Slack, etc.).',
'admin.smtp.testSuccess': 'E-mail de teste enviado com sucesso',
'admin.smtp.testFailed': 'Falha ao enviar e-mail de teste',
'admin.title': 'Administração',
'admin.subtitle': 'Gestão de usuários e configurações do sistema',
'admin.tabs.users': 'Usuários',
'admin.tabs.categories': 'Categorias',
'admin.tabs.backup': 'Backup',
'admin.stats.users': 'Usuários',
'admin.stats.trips': 'Viagens',
'admin.stats.places': 'Lugares',
'admin.stats.photos': 'Fotos',
'admin.stats.files': 'Arquivos',
'admin.table.user': 'Usuário',
'admin.table.email': 'E-mail',
'admin.table.role': 'Função',
'admin.table.created': 'Criado',
'admin.table.lastLogin': 'Último acesso',
'admin.table.actions': 'Ações',
'admin.you': '(Você)',
'admin.editUser': 'Editar usuário',
'admin.newPassword': 'Nova senha',
'admin.newPasswordHint': 'Deixe em branco para manter a senha atual',
'admin.deleteUser':
'Excluir o usuário "{name}"? Todas as viagens serão excluídas permanentemente.',
'admin.deleteUserTitle': 'Excluir usuário',
'admin.newPasswordPlaceholder': 'Digite a nova senha…',
'admin.toast.loadError': 'Falha ao carregar dados do admin',
'admin.toast.userUpdated': 'Usuário atualizado',
'admin.toast.updateError': 'Falha ao atualizar',
'admin.toast.userDeleted': 'Usuário excluído',
'admin.toast.deleteError': 'Falha ao excluir',
'admin.toast.cannotDeleteSelf': 'Não é possível excluir a própria conta',
'admin.toast.userCreated': 'Usuário criado',
'admin.toast.createError': 'Falha ao criar usuário',
'admin.toast.fieldsRequired':
'Nome de usuário, e-mail e senha são obrigatórios',
'admin.createUser': 'Criar usuário',
'admin.invite.title': 'Links de convite',
'admin.invite.subtitle': 'Crie links de cadastro de uso único',
'admin.invite.create': 'Criar link',
'admin.invite.createAndCopy': 'Criar e copiar',
'admin.invite.empty': 'Nenhum link de convite criado ainda',
'admin.invite.maxUses': 'Máx. usos',
'admin.invite.expiry': 'Expira após',
'admin.invite.uses': 'usado(s)',
'admin.invite.expiresAt': 'expira',
'admin.invite.createdBy': 'por',
'admin.invite.active': 'Ativo',
'admin.invite.expired': 'Expirado',
'admin.invite.usedUp': 'Esgotado',
'admin.invite.copied': 'Link de convite copiado para a área de transferência',
'admin.invite.copyLink': 'Copiar link',
'admin.invite.deleted': 'Link de convite excluído',
'admin.invite.createError': 'Falha ao criar link de convite',
'admin.invite.deleteError': 'Falha ao excluir link de convite',
'admin.tabs.settings': 'Configurações',
'admin.allowRegistration': 'Permitir cadastro',
'admin.allowRegistrationHint': 'Novos usuários podem se cadastrar sozinhos',
'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': 'Exigir autenticação em dois fatores (2FA)',
'admin.requireMfaHint':
'Usuários sem 2FA precisam concluir a configuração em Configurações antes de usar o app.',
'admin.apiKeys': 'Chaves de API',
'admin.apiKeysHint':
'Opcional. Habilita dados estendidos de lugares, como fotos e clima.',
'admin.mapsKey': 'Chave da API Google Maps',
'admin.mapsKeyHint':
'Necessária para busca de lugares. Obtenha em console.cloud.google.com',
'admin.mapsKeyHintLong':
'Sem chave de API, o OpenStreetMap é usado na busca. Com uma chave Google, também podem ser carregadas fotos, avaliações e horários. Obtenha em console.cloud.google.com.',
'admin.recommended': 'Recomendado',
'admin.weatherKey': 'Chave OpenWeatherMap',
'admin.weatherKeyHint':
'Para dados meteorológicos. Grátis em openweathermap.org',
'admin.validateKey': 'Testar',
'admin.keyValid': 'Conectado',
'admin.keyInvalid': 'Inválida',
'admin.keySaved': 'Chaves de API salvas',
'admin.oidcTitle': 'Login Único (OIDC)',
'admin.oidcSubtitle':
'Permitir login via provedores externos como Google, Apple, Authentik ou Keycloak.',
'admin.oidcDisplayName': 'Nome exibido',
'admin.oidcIssuer': 'URL do emissor',
'admin.oidcIssuerHint':
'URL do emissor OpenID Connect do provedor, ex.: https://accounts.google.com',
'admin.oidcSaved': 'Configuração OIDC salva',
'admin.oidcOnlyMode': 'Desativar login por senha',
'admin.oidcOnlyModeHint':
'Quando ativado, só é permitido login SSO. Login e cadastro por senha ficam bloqueados.',
'admin.fileTypes': 'Tipos de arquivo permitidos',
'admin.fileTypesHint':
'Configure quais tipos de arquivo os usuários podem enviar.',
'admin.fileTypesFormat':
'Extensões separadas por vírgula (ex.: jpg,png,pdf,doc). Use * para permitir todos.',
'admin.fileTypesSaved': 'Configurações de tipos de arquivo salvas',
'admin.placesPhotos.title': 'Fotos de Locais',
'admin.placesPhotos.subtitle':
'Busca fotos da Google Places API. Desative para economizar cota da API. Fotos do Wikimedia não são afetadas.',
'admin.placesAutocomplete.title': 'Autocompletar de Locais',
'admin.placesAutocomplete.subtitle':
'Usa a Google Places API para sugestões de pesquisa. Desative para economizar cota da API.',
'admin.placesDetails.title': 'Detalhes do Local',
'admin.placesDetails.subtitle':
'Busca informações detalhadas do local (horários, avaliação, site) da Google Places API. Desative para economizar cota da API.',
'admin.bagTracking.title': 'Rastreamento de malas',
'admin.bagTracking.subtitle':
'Ativar peso e atribuição de mala para itens da lista',
'admin.collab.chat.title': 'Chat',
'admin.collab.chat.subtitle': 'Mensagens em tempo real para colaboração',
'admin.collab.notes.title': 'Notas',
'admin.collab.notes.subtitle': 'Notas e documentos compartilhados',
'admin.collab.polls.title': 'Enquetes',
'admin.collab.polls.subtitle': 'Enquetes e votações em grupo',
'admin.collab.whatsnext.title': 'Próximos passos',
'admin.collab.whatsnext.subtitle':
'Sugestões de atividades e próximos passos',
'admin.tabs.config': 'Personalização',
'admin.tabs.defaults': 'Padrões do usuário',
'admin.defaultSettings.title': 'Configurações padrão do usuário',
'admin.defaultSettings.description':
'Defina padrões para toda a instância. Usuários que não alteraram uma configuração verão esses valores. As próprias alterações deles sempre têm prioridade.',
'admin.defaultSettings.saved': 'Padrão salvo',
'admin.defaultSettings.reset': 'Redefinir para o padrão integrado',
'admin.defaultSettings.resetToBuiltIn': 'redefinir',
'admin.tabs.templates': 'Modelos de mala',
'admin.packingTemplates.title': 'Modelos de mala',
'admin.packingTemplates.subtitle':
'Crie listas de mala reutilizáveis para suas viagens',
'admin.packingTemplates.create': 'Novo modelo',
'admin.packingTemplates.namePlaceholder': 'Nome do modelo (ex.: Praia)',
'admin.packingTemplates.empty': 'Nenhum modelo criado ainda',
'admin.packingTemplates.items': 'itens',
'admin.packingTemplates.categories': 'categorias',
'admin.packingTemplates.itemName': 'Nome do item',
'admin.packingTemplates.itemCategory': 'Categoria',
'admin.packingTemplates.categoryName': 'Nome da categoria (ex.: Roupas)',
'admin.packingTemplates.addCategory': 'Adicionar categoria',
'admin.packingTemplates.created': 'Modelo criado',
'admin.packingTemplates.deleted': 'Modelo excluído',
'admin.packingTemplates.loadError': 'Falha ao carregar modelos',
'admin.packingTemplates.createError': 'Falha ao criar modelo',
'admin.packingTemplates.deleteError': 'Falha ao excluir modelo',
'admin.packingTemplates.saveError': 'Falha ao salvar',
'admin.tabs.addons': 'Complementos',
'admin.addons.title': 'Complementos',
'admin.addons.subtitle':
'Ative ou desative recursos para personalizar sua experiência no TREK.',
'admin.addons.catalog.memories.name': 'Memórias',
'admin.addons.catalog.memories.description':
'Álbuns de fotos compartilhados em cada viagem',
'admin.addons.catalog.packing.name': 'Listas',
'admin.addons.catalog.packing.description':
'Listas de bagagem e tarefas a fazer para suas viagens',
'admin.addons.catalog.budget.name': 'Orçamento',
'admin.addons.catalog.budget.description':
'Acompanhe despesas e planeje o orçamento da viagem',
'admin.addons.catalog.documents.name': 'Documentos',
'admin.addons.catalog.documents.description':
'Armazene e gerencie documentos de viagem',
'admin.addons.catalog.vacay.name': 'Férias',
'admin.addons.catalog.vacay.description':
'Planejador de férias pessoal com visão em calendário',
'admin.addons.catalog.atlas.name': 'Atlas',
'admin.addons.catalog.atlas.description':
'Mapa mundial com países visitados e estatísticas',
'admin.addons.catalog.collab.name': 'Colab',
'admin.addons.catalog.collab.description':
'Notas, enquetes e chat em tempo real para planejar a viagem',
'admin.addons.catalog.mcp.name': 'MCP',
'admin.addons.catalog.mcp.description':
'Model Context Protocol para integração com assistentes de IA',
'admin.addons.subtitleBefore':
'Ative ou desative recursos para personalizar sua ',
'admin.addons.subtitleAfter': ' experiência.',
'admin.addons.enabled': 'Ativado',
'admin.addons.disabled': 'Desativado',
'admin.addons.type.trip': 'Viagem',
'admin.addons.type.global': 'Global',
'admin.addons.type.integration': 'Integração',
'admin.addons.tripHint': 'Disponível como aba em cada viagem',
'admin.addons.globalHint':
'Disponível como seção própria na navegação principal',
'admin.addons.toast.updated': 'Complemento atualizado',
'admin.addons.toast.error': 'Falha ao atualizar complemento',
'admin.addons.noAddons': 'Nenhum complemento disponível',
'admin.addons.integrationHint':
'Serviços de backend e integrações de API sem página dedicada',
'admin.weather.title': 'Dados meteorológicos',
'admin.weather.badge': 'Desde 24 de março de 2026',
'admin.weather.description':
'O TREK usa Open-Meteo como fonte de clima. Open-Meteo é um serviço gratuito e de código aberto — sem chave de API.',
'admin.weather.forecast': 'Previsão de 16 dias',
'admin.weather.forecastDesc': 'Antes eram 5 dias (OpenWeatherMap)',
'admin.weather.climate': 'Dados climáticos históricos',
'admin.weather.climateDesc':
'Médias dos últimos 85 anos para dias além da previsão de 16 dias',
'admin.weather.requests': '10.000 requisições / dia',
'admin.weather.requestsDesc': 'Grátis, sem chave de API',
'admin.weather.locationHint':
'O clima usa o primeiro lugar com coordenadas de cada dia. Se nenhum lugar estiver atribuído ao dia, qualquer lugar da lista serve como referência.',
'admin.tabs.audit': 'Auditoria',
'admin.audit.subtitle':
'Eventos sensíveis de segurança e administração (backups, usuários, 2FA, configurações).',
'admin.audit.empty': 'Nenhum registro de auditoria.',
'admin.audit.refresh': 'Atualizar',
'admin.audit.loadMore': 'Carregar mais',
'admin.audit.showing': '{count} carregados · {total} no total',
'admin.audit.col.time': 'Hora',
'admin.audit.col.user': 'Usuário',
'admin.audit.col.action': 'Ação',
'admin.audit.col.resource': 'Recurso',
'admin.audit.col.ip': 'IP',
'admin.audit.col.details': 'Detalhes',
'admin.tabs.github': 'GitHub',
'admin.github.title': 'Histórico de versões',
'admin.github.subtitle': 'Últimas atualizações de {repo}',
'admin.github.latest': 'Mais recente',
'admin.github.prerelease': 'Pré-lançamento',
'admin.github.showDetails': 'Mostrar detalhes',
'admin.github.hideDetails': 'Ocultar detalhes',
'admin.github.loadMore': 'Carregar mais',
'admin.github.loading': 'Carregando...',
'admin.github.error': 'Falha ao carregar versões',
'admin.github.by': 'por',
'admin.github.support': 'Ajuda a continuar desenvolvendo o TREK',
'admin.update.available': 'Atualização disponível',
'admin.update.text':
'O TREK {version} está disponível. Você está na {current}.',
'admin.update.button': 'Ver no GitHub',
'admin.update.install': 'Instalar atualização',
'admin.update.confirmTitle': 'Instalar atualização?',
'admin.update.confirmText':
'O TREK será atualizado de {current} para {version}. O servidor reiniciará automaticamente em seguida.',
'admin.update.dataInfo':
'Todos os seus dados (viagens, usuários, chaves de API, envios, Vacay, Atlas, orçamentos) serão preservados.',
'admin.update.warning':
'O app ficará brevemente indisponível durante o reinício.',
'admin.update.confirm': 'Atualizar agora',
'admin.update.installing': 'Atualizando…',
'admin.update.success': 'Atualização instalada! O servidor está reiniciando…',
'admin.update.failed': 'Falha na atualização',
'admin.update.backupHint': 'Recomendamos criar um backup antes de atualizar.',
'admin.update.backupLink': 'Ir para Backup',
'admin.update.howTo': 'Como atualizar',
'admin.update.dockerText':
'Sua instância TREK roda no Docker. Para atualizar para {version}, execute no servidor:',
'admin.update.reloadHint': 'Recarregue a página em alguns segundos.',
'admin.tabs.permissions': 'Permissões',
'admin.tabs.mcpTokens': 'Acesso MCP',
'admin.mcpTokens.title': 'Acesso MCP',
'admin.mcpTokens.subtitle':
'Gerenciar sessões OAuth e tokens de API de todos os usuários',
'admin.mcpTokens.sectionTitle': 'Tokens de API',
'admin.mcpTokens.owner': 'Proprietário',
'admin.mcpTokens.tokenName': 'Nome do Token',
'admin.mcpTokens.created': 'Criado',
'admin.mcpTokens.lastUsed': 'Último uso',
'admin.mcpTokens.never': 'Nunca',
'admin.mcpTokens.empty': 'Nenhum token MCP foi criado ainda',
'admin.mcpTokens.deleteTitle': 'Excluir Token',
'admin.mcpTokens.deleteMessage':
'Isso revogará o token imediatamente. O usuário perderá o acesso MCP por este token.',
'admin.mcpTokens.deleteSuccess': 'Token excluído',
'admin.mcpTokens.deleteError': 'Falha ao excluir token',
'admin.mcpTokens.loadError': 'Falha ao carregar tokens',
'admin.oauthSessions.sectionTitle': 'Sessões OAuth',
'admin.oauthSessions.clientName': 'Cliente',
'admin.oauthSessions.owner': 'Proprietário',
'admin.oauthSessions.scopes': 'Permissões',
'admin.oauthSessions.created': 'Criado',
'admin.oauthSessions.empty': 'Nenhuma sessão OAuth ativa',
'admin.oauthSessions.revokeTitle': 'Revogar sessão',
'admin.oauthSessions.revokeMessage':
'Esta sessão OAuth será revogada imediatamente. O cliente perderá o acesso MCP.',
'admin.oauthSessions.revokeSuccess': 'Sessão revogada',
'admin.oauthSessions.revokeError': 'Falha ao revogar sessão',
'admin.oauthSessions.loadError': 'Falha ao carregar sessões OAuth',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.inappPanel.title': 'In-App',
'admin.notifications.inappPanel.hint':
'As notificações no aplicativo estão sempre ativas e não podem ser desativadas globalmente.',
'admin.notifications.adminWebhookPanel.title': 'Webhook de admin',
'admin.notifications.adminWebhookPanel.hint':
'Este webhook é usado exclusivamente para notificações de admin (ex. alertas de versão). É independente dos webhooks de usuários e dispara automaticamente quando uma URL está configurada.',
'admin.notifications.adminWebhookPanel.saved':
'URL do webhook de admin salva',
'admin.notifications.adminWebhookPanel.testSuccess':
'Webhook de teste enviado com sucesso',
'admin.notifications.adminWebhookPanel.testFailed':
'Falha no webhook de teste',
'admin.notifications.adminWebhookPanel.alwaysOnHint':
'O webhook de admin dispara automaticamente quando uma URL está configurada',
'admin.notifications.ntfy': 'Ntfy',
'admin.ntfy.hint':
'Permite que os usuários configurem seus próprios tópicos ntfy para notificações push. Configure o servidor padrão abaixo para preencher as configurações do usuário.',
'admin.notifications.testNtfy': 'Enviar Ntfy de teste',
'admin.notifications.testNtfySuccess': 'Ntfy de teste enviado com sucesso',
'admin.notifications.testNtfyFailed': 'Falha ao enviar Ntfy de teste',
'admin.notifications.adminNtfyPanel.title': 'Ntfy de admin',
'admin.notifications.adminNtfyPanel.hint':
'Este tópico Ntfy é usado exclusivamente para notificações de admin (ex. alertas de versão). É independente dos tópicos por usuário e sempre dispara quando configurado.',
'admin.notifications.adminNtfyPanel.serverLabel': 'URL do servidor Ntfy',
'admin.notifications.adminNtfyPanel.serverHint':
'Também usado como servidor padrão para notificações ntfy dos usuários. Deixe em branco para usar ntfy.sh. Os usuários podem substituir isso em suas próprias configurações.',
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
'admin.notifications.adminNtfyPanel.topicLabel': 'Tópico de admin',
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
'admin.notifications.adminNtfyPanel.tokenLabel': 'Token de acesso (opcional)',
'admin.notifications.adminNtfyPanel.tokenCleared':
'Token de acesso admin removido',
'admin.notifications.adminNtfyPanel.saved':
'Configurações de Ntfy de admin salvas',
'admin.notifications.adminNtfyPanel.test': 'Enviar Ntfy de teste',
'admin.notifications.adminNtfyPanel.testSuccess':
'Ntfy de teste enviado com sucesso',
'admin.notifications.adminNtfyPanel.testFailed':
'Falha ao enviar Ntfy de teste',
'admin.notifications.adminNtfyPanel.alwaysOnHint':
'O Ntfy de admin sempre dispara quando um tópico está configurado',
'admin.notifications.adminNotificationsHint':
'Configure quais canais entregam notificações de admin (ex. alertas de versão). O webhook dispara automaticamente se uma URL de webhook de admin estiver definida.',
'admin.notifications.tripReminders.title': 'Lembretes de viagem',
'admin.notifications.tripReminders.hint':
'Envia uma notificação de lembrete antes do início de uma viagem (requer dias de lembrete definidos na viagem).',
'admin.notifications.tripReminders.enabled': 'Lembretes de viagem ativados',
'admin.notifications.tripReminders.disabled':
'Lembretes de viagem desativados',
'admin.tabs.notifications': 'Notificações',
'admin.addons.catalog.journey.name': 'Jornada',
'admin.addons.catalog.journey.description':
'Rastreamento de viagens e diário de viajante com check-ins, fotos e histórias diárias',
'admin.passkey.title': 'Login com passkey',
'admin.passkey.cardHint':
'Permite que os usuários entrem com passkeys (WebAuthn). Desativado por padrão.',
'admin.passkey.login': 'Ativar login com passkey',
'admin.passkey.loginHint':
'Mostra a opção "Entrar com uma passkey" e permite que os usuários cadastrem passkeys nas configurações.',
'admin.passkey.notConfigured':
'Nenhum domínio WebAuthn é resolvido para esta instalação ainda. Defina APP_URL ou o Relying Party ID abaixo — as passkeys ficam ocultas até lá.',
'admin.passkey.rpId': 'Relying Party ID (domínio)',
'admin.passkey.rpIdHint':
'O domínio puro ao qual as passkeys ficam vinculadas, ex.: trek.example.org. Deixe vazio para derivá-lo de APP_URL. Alterá-lo depois invalida as passkeys existentes.',
'admin.passkey.origins': 'Origens permitidas',
'admin.passkey.originsHint':
'Origens completas separadas por vírgula, ex.: https://trek.example.org. Deixe vazio para usar APP_URL.',
'admin.passkey.reset': 'Redefinir passkeys',
'admin.passkey.resetHint':
'Remove todas as passkeys deste usuário (ex.: em caso de perda do dispositivo). Ele ainda poderá entrar com a senha.',
'admin.passkey.resetConfirm': 'Remover todas as passkeys de {name}?',
'admin.passkey.resetDone': 'Removida(s) {count} passkey(s)',
'admin.defaultSettings.mapProvider': 'Motor de mapas',
'admin.defaultSettings.mapProviderHint': 'O mapa padrão para todos nesta instância. Cada usuário ainda pode substituí-lo nas próprias configurações.',
'admin.defaultSettings.providerLeaflet': 'Padrão (gratuito)',
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
'admin.defaultSettings.mapboxToken': 'Token compartilhado do Mapbox',
'admin.defaultSettings.mapboxTokenHint': 'Usado para todos os usuários que não inseriram o próprio token — assim toda a instância usa o Mapbox sem compartilhar a chave individualmente. Armazenado de forma criptografada.',
'admin.defaultSettings.mapboxStyle': 'Estilo do mapa',
'admin.defaultSettings.mapboxStylePlaceholder': 'Escolha um estilo…',
'admin.defaultSettings.mapbox3d': 'Edifícios & relevo em 3D',
'admin.defaultSettings.mapboxQuality': 'Modo de alta qualidade',
};
export default admin;
+6
View File
@@ -0,0 +1,6 @@
import type { TranslationStrings } from '../types';
const airport: TranslationStrings = {
'airport.searchPlaceholder': 'Código ou cidade do aeroporto (ex. FRA)',
};
export default airport;
+59
View File
@@ -0,0 +1,59 @@
import type { TranslationStrings } from '../types';
const atlas: TranslationStrings = {
'atlas.subtitle': 'Sua pegada de viagens pelo mundo',
'atlas.countries': 'Países',
'atlas.trips': 'Viagens',
'atlas.places': 'Lugares',
'atlas.unmark': 'Remover',
'atlas.confirmMark': 'Marcar este país como visitado?',
'atlas.confirmUnmark': 'Remover este país da lista de visitados?',
'atlas.confirmUnmarkRegion': 'Remover esta região da lista de visitados?',
'atlas.markVisited': 'Marcar como visitado',
'atlas.markVisitedHint': 'Adicionar este país à lista de visitados',
'atlas.markRegionVisitedHint': 'Adicionar esta região à lista de visitados',
'atlas.addToBucket': 'Adicionar à lista de desejos',
'atlas.addPoi': 'Adicionar lugar',
'atlas.searchCountry': 'Buscar um país...',
'atlas.bucketNamePlaceholder': 'Nome (país, cidade, lugar…)',
'atlas.month': 'Mês',
'atlas.year': 'Ano',
'atlas.addToBucketHint': 'Salvar como lugar que você quer visitar',
'atlas.bucketWhen': 'Quando pretende visitar?',
'atlas.statsTab': 'Estatísticas',
'atlas.bucketTab': 'Lista de desejos',
'atlas.addBucket': 'Adicionar à lista de desejos',
'atlas.bucketNotesPlaceholder': 'Notas (opcional)',
'atlas.bucketEmpty': 'Sua lista de desejos está vazia',
'atlas.bucketEmptyHint': 'Adicione lugares que sonha em visitar',
'atlas.days': 'Dias',
'atlas.visitedCountries': 'Países visitados',
'atlas.cities': 'Cidades',
'atlas.noData': 'Ainda sem dados de viagem',
'atlas.noDataHint':
'Crie uma viagem e adicione lugares para ver o mapa mundial',
'atlas.lastTrip': 'Última viagem',
'atlas.nextTrip': 'Próxima viagem',
'atlas.daysLeft': 'dias restantes',
'atlas.streak': 'Sequência',
'atlas.years': 'anos',
'atlas.yearInRow': 'ano seguido',
'atlas.yearsInRow': 'anos seguidos',
'atlas.tripIn': 'viagem em',
'atlas.tripsIn': 'viagens em',
'atlas.since': 'desde',
'atlas.europe': 'Europa',
'atlas.asia': 'Ásia',
'atlas.northAmerica': 'América do Norte',
'atlas.southAmerica': 'América do Sul',
'atlas.africa': 'África',
'atlas.oceania': 'Oceania',
'atlas.other': 'Outro',
'atlas.firstVisit': 'Primeira viagem',
'atlas.lastVisitLabel': 'Última viagem',
'atlas.tripSingular': 'Viagem',
'atlas.tripPlural': 'Viagens',
'atlas.placeVisited': 'Lugar visitado',
'atlas.placesVisited': 'Lugares visitados',
};
export default atlas;
+78
View File
@@ -0,0 +1,78 @@
import type { TranslationStrings } from '../types';
const backup: TranslationStrings = {
'backup.title': 'Backup de dados',
'backup.subtitle': 'Banco de dados e todos os arquivos enviados',
'backup.refresh': 'Atualizar',
'backup.upload': 'Enviar backup',
'backup.uploading': 'Enviando…',
'backup.create': 'Criar backup',
'backup.creating': 'Criando…',
'backup.empty': 'Nenhum backup ainda',
'backup.createFirst': 'Criar primeiro backup',
'backup.download': 'Baixar',
'backup.restore': 'Restaurar',
'backup.confirm.restore':
'Restaurar o backup "{name}"?\n\nTodos os dados atuais serão substituídos pelo backup.',
'backup.confirm.uploadRestore':
'Enviar e restaurar o arquivo "{name}"?\n\nTodos os dados atuais serão sobrescritos.',
'backup.confirm.delete': 'Excluir o backup "{name}"?',
'backup.toast.loadError': 'Falha ao carregar backups',
'backup.toast.created': 'Backup criado com sucesso',
'backup.toast.createError': 'Falha ao criar backup',
'backup.toast.restored': 'Backup restaurado. A página será recarregada…',
'backup.toast.restoreError': 'Falha ao restaurar',
'backup.toast.uploadError': 'Falha no envio',
'backup.toast.deleted': 'Backup excluído',
'backup.toast.deleteError': 'Falha ao excluir',
'backup.toast.downloadError': 'Falha no download',
'backup.toast.settingsSaved': 'Configurações de backup automático salvas',
'backup.toast.settingsError': 'Falha ao salvar configurações',
'backup.auto.title': 'Backup automático',
'backup.auto.subtitle': 'Backup automático em agenda',
'backup.auto.enable': 'Ativar backup automático',
'backup.auto.enableHint':
'Backups serão criados automaticamente conforme a agenda escolhida',
'backup.auto.interval': 'Intervalo',
'backup.auto.hour': 'Executar no horário',
'backup.auto.hourHint': 'Horário local do servidor (formato {format})',
'backup.auto.dayOfWeek': 'Dia da semana',
'backup.auto.dayOfMonth': 'Dia do mês',
'backup.auto.dayOfMonthHint':
'Limitado a 128 para compatibilidade com todos os meses',
'backup.auto.scheduleSummary': 'Agenda',
'backup.auto.summaryDaily': 'Todos os dias às {hour}:00',
'backup.auto.summaryWeekly': 'Toda {day} às {hour}:00',
'backup.auto.summaryMonthly': 'Dia {day} de cada mês às {hour}:00',
'backup.auto.envLocked': 'Docker',
'backup.auto.envLockedHint':
'O backup automático é configurado via variáveis de ambiente Docker. Para alterar essas configurações, atualize o docker-compose.yml e reinicie o contêiner.',
'backup.auto.copyEnv': 'Copiar variáveis de ambiente Docker',
'backup.auto.envCopied':
'Variáveis de ambiente Docker copiadas para a área de transferência',
'backup.auto.keepLabel': 'Excluir backups antigos após',
'backup.dow.sunday': 'Dom',
'backup.dow.monday': 'Seg',
'backup.dow.tuesday': 'Ter',
'backup.dow.wednesday': 'Qua',
'backup.dow.thursday': 'Qui',
'backup.dow.friday': 'Sex',
'backup.dow.saturday': 'Sáb',
'backup.interval.hourly': 'A cada hora',
'backup.interval.daily': 'Diário',
'backup.interval.weekly': 'Semanal',
'backup.interval.monthly': 'Mensal',
'backup.keep.1day': '1 dia',
'backup.keep.3days': '3 dias',
'backup.keep.7days': '7 dias',
'backup.keep.14days': '14 dias',
'backup.keep.30days': '30 dias',
'backup.keep.forever': 'Manter para sempre',
'backup.restoreConfirmTitle': 'Restaurar backup?',
'backup.restoreWarning':
'Todos os dados atuais (viagens, lugares, usuários, envios) serão permanentemente substituídos pelo backup. Esta ação não pode ser desfeita.',
'backup.restoreTip':
'Dica: crie um backup do estado atual antes de restaurar.',
'backup.restoreConfirm': 'Sim, restaurar',
};
export default backup;
+116
View File
@@ -0,0 +1,116 @@
import type { TranslationStrings } from '../types';
const budget: TranslationStrings = {
'budget.title': 'Orçamento',
'budget.exportCsv': 'Exportar CSV',
'budget.emptyTitle': 'Nenhum orçamento criado ainda',
'budget.emptyText':
'Crie categorias e lançamentos para planejar o orçamento da viagem',
'budget.emptyPlaceholder': 'Nome da categoria...',
'budget.createCategory': 'Criar categoria',
'budget.category': 'Categoria',
'budget.categoryName': 'Nome da categoria',
'budget.table.name': 'Nome',
'budget.table.total': 'Total',
'budget.table.persons': 'Pessoas',
'budget.table.days': 'Dias',
'budget.table.perPerson': 'Por pessoa',
'budget.table.perDay': 'Por dia',
'budget.table.perPersonDay': 'P. p. / dia',
'budget.table.note': 'Obs.',
'budget.table.date': 'Data',
'budget.newEntry': 'Novo lançamento',
'budget.defaultEntry': 'Novo lançamento',
'budget.defaultCategory': 'Nova categoria',
'budget.total': 'Total',
'budget.totalBudget': 'Orçamento total',
'budget.byCategory': 'Por categoria',
'budget.editTooltip': 'Clique para editar',
'budget.linkedToReservation': 'Vinculado a uma reserva — edite o nome por lá',
'budget.confirm.deleteCategory':
'Excluir a categoria "{name}" com {count} lançamento(s)?',
'budget.deleteCategory': 'Excluir categoria',
'budget.perPerson': 'Por pessoa',
'budget.paid': 'Pago',
'budget.open': 'Em aberto',
'budget.noMembers': 'Nenhum membro atribuído',
'budget.settlement': 'Acerto',
'budget.settlementInfo':
'Clique no avatar de um membro em um item do orçamento para marcá-lo em verde — significa que ele pagou. O acerto mostra quem deve quanto a quem.',
'budget.netBalances': 'Saldos líquidos',
'budget.categoriesLabel': 'categorias',
"costs.you": "Você",
"costs.youShort": "V",
"costs.youLower": "você",
"costs.youOwe": "Você deve",
"costs.youOweSub": "Você deve pagar os outros",
"costs.youreOwed": "Devem a você",
"costs.youreOwedSub": "Os outros devem pagar você",
"costs.totalSpend": "Gasto total da viagem",
"costs.totalSpendSub": "Entre todos os viajantes",
"costs.to": "Para",
"costs.from": "De",
"costs.allSettled": "Suas contas estão acertadas",
"costs.nothingOwed": "Ninguém deve nada a você",
"costs.yourShare": "Sua parte",
"costs.youPaid": "Você pagou",
"costs.expenses": "Despesas",
"costs.entries": "{count} lançamentos",
"costs.searchPlaceholder": "Buscar despesas…",
"costs.filter.all": "Todas",
"costs.filter.mine": "Pagas por mim",
"costs.filter.owed": "Devem a mim",
"costs.addExpense": "Adicionar despesa",
"costs.editExpense": "Editar despesa",
"costs.noMatch": "Nenhuma despesa corresponde à busca.",
"costs.emptyText": "Nenhuma despesa ainda. Adicione a primeira.",
"costs.spent": "{amount} gastos",
"costs.noDate": "Sem data",
"costs.noOnePaid": "Ninguém pagou ainda",
"costs.youLent": "você emprestou {amount}",
"costs.youBorrowed": "você pegou emprestado {amount}",
"costs.settleUp": "Acertar contas",
"costs.history": "Histórico",
"costs.everyoneSquare": "Todos quitados",
"costs.nothingOutstanding": "Nenhum pagamento pendente no momento.",
"costs.pay": "paga",
"costs.pays": "paga",
"costs.settle": "Acertar",
"costs.balances": "Saldos",
"costs.byCategory": "Por categoria",
"costs.noCategories": "Nenhuma despesa ainda.",
"costs.settleHistory": "Histórico de acertos",
"costs.noSettlements": "Nenhum pagamento acertado ainda.",
"costs.paymentsSettled": "{count} pagamentos acertados",
"costs.paid": "pago",
"costs.undo": "Desfazer",
"costs.whatFor": "Para que foi?",
"costs.namePlaceholder": "ex.: jantar, lembranças, gasolina…",
"costs.totalAmount": "Valor total",
"costs.currency": "Moeda",
"costs.day": "Dia",
"costs.rateLabel": "1 {from} em {to}",
"costs.category": "Categoria",
"costs.whoPaid": "Quem pagou?",
"costs.splitBetween": "Dividir igualmente entre",
"costs.pickSomeone": "Escolha pelo menos uma pessoa para dividir.",
"costs.splitSummary": "Dividido entre {count} · {amount} cada",
"costs.cat.accommodation": "Hospedagem",
"costs.cat.food": "Comida e bebida",
"costs.cat.groceries": "Mercado",
"costs.cat.transport": "Transporte",
"costs.cat.flights": "Voos",
"costs.cat.activities": "Atividades",
"costs.cat.sightseeing": "Passeios turísticos",
"costs.cat.shopping": "Compras",
"costs.cat.fees": "Taxas e ingressos",
"costs.cat.health": "Saúde",
"costs.cat.tips": "Gorjetas",
"costs.cat.other": "Outros",
"costs.daysCount": "{count} dias",
"costs.travelers": "{count} viajantes",
"costs.liveRate": "taxa ao vivo",
"costs.settleAll": "Acertar tudo",
};
export default budget;
+26
View File
@@ -0,0 +1,26 @@
import type { TranslationStrings } from '../types';
const categories: TranslationStrings = {
'categories.title': 'Categorias',
'categories.subtitle': 'Gerenciar categorias de lugares',
'categories.new': 'Nova categoria',
'categories.empty': 'Nenhuma categoria ainda',
'categories.namePlaceholder': 'Nome da categoria',
'categories.icon': 'Ícone',
'categories.color': 'Cor',
'categories.customColor': 'Escolher cor personalizada',
'categories.preview': 'Pré-visualização',
'categories.defaultName': 'Categoria',
'categories.update': 'Atualizar',
'categories.create': 'Criar',
'categories.confirm.delete':
'Excluir categoria? Os lugares desta categoria não serão excluídos.',
'categories.toast.loadError': 'Falha ao carregar categorias',
'categories.toast.nameRequired': 'Digite um nome',
'categories.toast.updated': 'Categoria atualizada',
'categories.toast.created': 'Categoria criada',
'categories.toast.saveError': 'Falha ao salvar',
'categories.toast.deleted': 'Categoria excluída',
'categories.toast.deleteError': 'Falha ao excluir',
};
export default categories;
+77
View File
@@ -0,0 +1,77 @@
import type { TranslationStrings } from '../types';
const collab: TranslationStrings = {
'collab.tabs.chat': 'Chat',
'collab.tabs.notes': 'Notas',
'collab.tabs.polls': 'Enquetes',
'collab.whatsNext.title': 'Próximos passos',
'collab.whatsNext.today': 'Hoje',
'collab.whatsNext.tomorrow': 'Amanhã',
'collab.whatsNext.empty': 'Nenhuma atividade próxima',
'collab.whatsNext.until': 'até',
'collab.whatsNext.emptyHint': 'Atividades com horário aparecerão aqui',
'collab.chat.send': 'Enviar',
'collab.chat.placeholder': 'Digite uma mensagem...',
'collab.chat.empty': 'Inicie a conversa',
'collab.chat.emptyHint':
'As mensagens são compartilhadas com todos os membros da viagem',
'collab.chat.emptyDesc':
'Compartilhe ideias, planos e atualizações com o grupo',
'collab.chat.today': 'Hoje',
'collab.chat.yesterday': 'Ontem',
'collab.chat.deletedMessage': 'apagou uma mensagem',
'collab.chat.reply': 'Responder',
'collab.chat.loadMore': 'Carregar mensagens antigas',
'collab.chat.justNow': 'agora mesmo',
'collab.chat.minutesAgo': 'há {n} min',
'collab.chat.hoursAgo': 'há {n} h',
'collab.notes.title': 'Notas',
'collab.notes.new': 'Nova nota',
'collab.notes.empty': 'Nenhuma nota ainda',
'collab.notes.emptyHint': 'Comece a registrar ideias e planos',
'collab.notes.all': 'Todas',
'collab.notes.titlePlaceholder': 'Título da nota',
'collab.notes.contentPlaceholder': 'Escreva algo...',
'collab.notes.categoryPlaceholder': 'Categoria',
'collab.notes.newCategory': 'Nova categoria...',
'collab.notes.category': 'Categoria',
'collab.notes.noCategory': 'Sem categoria',
'collab.notes.color': 'Cor',
'collab.notes.save': 'Salvar',
'collab.notes.cancel': 'Cancelar',
'collab.notes.edit': 'Editar',
'collab.notes.delete': 'Excluir',
'collab.notes.confirmDeleteTitle': 'Excluir nota?',
'collab.notes.confirmDeleteBody': 'Esta nota será excluída permanentemente.',
'collab.notes.pin': 'Fixar',
'collab.notes.unpin': 'Desafixar',
'collab.notes.daysAgo': 'há {n} d',
'collab.notes.categorySettings': 'Gerenciar categorias',
'collab.notes.create': 'Criar',
'collab.notes.website': 'Site',
'collab.notes.websitePlaceholder': 'https://...',
'collab.notes.attachFiles': 'Anexar arquivos',
'collab.notes.noCategoriesYet': 'Nenhuma categoria ainda',
'collab.notes.emptyDesc': 'Crie uma nota para começar',
'collab.polls.title': 'Enquetes',
'collab.polls.new': 'Nova enquete',
'collab.polls.empty': 'Nenhuma enquete ainda',
'collab.polls.emptyHint': 'Pergunte ao grupo e votem juntos',
'collab.polls.question': 'Pergunta',
'collab.polls.questionPlaceholder': 'O que vamos fazer?',
'collab.polls.addOption': '+ Adicionar opção',
'collab.polls.optionPlaceholder': 'Opção {n}',
'collab.polls.create': 'Criar enquete',
'collab.polls.close': 'Encerrar',
'collab.polls.closed': 'Encerrada',
'collab.polls.votes': '{n} votos',
'collab.polls.vote': '{n} voto',
'collab.polls.multipleChoice': 'Múltipla escolha',
'collab.polls.multiChoice': 'Múltipla escolha',
'collab.polls.deadline': 'Prazo',
'collab.polls.option': 'Opção',
'collab.polls.options': 'Opções',
'collab.polls.delete': 'Excluir',
'collab.polls.closedSection': 'Encerradas',
};
export default collab;
+54
View File
@@ -0,0 +1,54 @@
import type { TranslationStrings } from '../types';
const common: TranslationStrings = {
'common.save': 'Salvar',
'common.showMore': 'Mostrar mais',
'common.showLess': 'Mostrar menos',
'common.cancel': 'Cancelar',
'common.clear': 'Limpar',
'common.delete': 'Excluir',
'common.edit': 'Editar',
'common.add': 'Adicionar',
'common.loading': 'Carregando...',
'common.import': 'Importar',
'common.select': 'Selecionar',
'common.selectAll': 'Selecionar tudo',
'common.deselectAll': 'Desmarcar tudo',
'common.error': 'Erro',
'common.unknownError': 'Erro desconhecido',
'common.tooManyAttempts': 'Muitas tentativas. Tente novamente mais tarde.',
'common.back': 'Voltar',
'common.all': 'Todos',
'common.close': 'Fechar',
'common.open': 'Abrir',
'common.upload': 'Enviar',
'common.search': 'Buscar',
'common.confirm': 'Confirmar',
'common.ok': 'OK',
'common.yes': 'Sim',
'common.no': 'Não',
'common.or': 'ou',
'common.none': 'Nenhum',
'common.date': 'Data',
'common.rename': 'Renomear',
'common.discardChanges': 'Descartar alterações',
'common.discard': 'Descartar',
'common.name': 'Nome',
'common.email': 'E-mail',
'common.password': 'Senha',
'common.saving': 'Salvando...',
'common.saved': 'Salvo',
'common.expand': 'Expandir',
'common.collapse': 'Recolher',
'common.update': 'Atualizar',
'common.change': 'Alterar',
'common.uploading': 'Enviando…',
'common.backToPlanning': 'Voltar ao planejamento',
'common.reset': 'Redefinir',
'common.copy': 'Copiar',
'common.copied': 'Copiado',
'common.justNow': 'agora mesmo',
'common.hoursAgo': 'há {count}h',
'common.daysAgo': 'há {count}d',
};
export default common;
+168
View File
@@ -0,0 +1,168 @@
import type { TranslationStrings } from '../types';
const dashboard: TranslationStrings = {
'dashboard.title': 'Minhas viagens',
'dashboard.subtitle.loading': 'Carregando viagens...',
'dashboard.subtitle.trips': '{count} viagens ({archived} arquivadas)',
'dashboard.subtitle.empty': 'Comece sua primeira viagem',
'dashboard.subtitle.activeOne': '{count} viagem ativa',
'dashboard.subtitle.activeMany': '{count} viagens ativas',
'dashboard.subtitle.archivedSuffix': ' · {count} arquivadas',
'dashboard.newTrip': 'Nova viagem',
'dashboard.newTripSub': 'Planeje uma nova viagem do zero',
'dashboard.gridView': 'Grade',
'dashboard.listView': 'Lista',
'dashboard.currency': 'Moeda',
'dashboard.timezone': 'Fusos horários',
'dashboard.localTime': 'Local',
'dashboard.timezoneCustomTitle': 'Fuso personalizado',
'dashboard.timezoneCustomLabelPlaceholder': 'Rótulo (opcional)',
'dashboard.timezoneCustomTzPlaceholder': 'ex.: America/Sao_Paulo',
'dashboard.timezoneCustomAdd': 'Adicionar',
'dashboard.timezoneCustomErrorEmpty': 'Informe um identificador de fuso',
'dashboard.timezoneCustomErrorInvalid':
'Fuso inválido. Use o formato Europe/Berlin',
'dashboard.timezoneCustomErrorDuplicate': 'Já adicionado',
'dashboard.emptyTitle': 'Nenhuma viagem ainda',
'dashboard.emptyText': 'Crie sua primeira viagem e comece a planejar!',
'dashboard.emptyButton': 'Criar primeira viagem',
'dashboard.nextTrip': 'Próxima viagem',
'dashboard.shared': 'Compartilhada',
'dashboard.sharedBy': 'Compartilhada por {name}',
'dashboard.days': 'Dias',
'dashboard.places': 'Lugares',
'dashboard.members': 'Parceiros de viagem',
'dashboard.archive': 'Arquivar',
'dashboard.copyTrip': 'Copiar',
'dashboard.copySuffix': 'cópia',
'dashboard.restore': 'Restaurar',
'dashboard.archived': 'Arquivada',
'dashboard.status.ongoing': 'Em andamento',
'dashboard.status.today': 'Hoje',
'dashboard.status.tomorrow': 'Amanhã',
'dashboard.status.past': 'Passada',
'dashboard.status.daysLeft': 'Faltam {count} dias',
'dashboard.toast.loadError': 'Não foi possível carregar as viagens',
'dashboard.toast.created': 'Viagem criada com sucesso!',
'dashboard.toast.createError': 'Não foi possível criar a viagem',
'dashboard.toast.updated': 'Viagem atualizada!',
'dashboard.toast.updateError': 'Não foi possível atualizar a viagem',
'dashboard.toast.deleted': 'Viagem excluída',
'dashboard.toast.deleteError': 'Não foi possível excluir a viagem',
'dashboard.toast.archived': 'Viagem arquivada',
'dashboard.toast.archiveError': 'Não foi possível arquivar',
'dashboard.toast.restored': 'Viagem restaurada',
'dashboard.toast.restoreError': 'Não foi possível restaurar',
'dashboard.toast.copied': 'Viagem copiada!',
'dashboard.toast.copyError': 'Não foi possível copiar a viagem',
'dashboard.confirm.delete':
'Excluir a viagem "{title}"? Todos os lugares e planos serão excluídos permanentemente.',
'dashboard.editTrip': 'Editar viagem',
'dashboard.createTrip': 'Criar nova viagem',
'dashboard.tripTitle': 'Título',
'dashboard.tripTitlePlaceholder': 'ex.: Verão no Japão',
'dashboard.tripDescription': 'Descrição',
'dashboard.tripDescriptionPlaceholder': 'Sobre o que é esta viagem?',
'dashboard.startDate': 'Data de início',
'dashboard.endDate': 'Data de término',
'dashboard.dayCount': 'Número de dias',
'dashboard.dayCountHint':
'Quantos dias planejar quando nenhuma data de viagem for definida.',
'dashboard.noDateHint':
'Sem datas — serão criados 7 dias padrão. Você pode alterar depois.',
'dashboard.coverImage': 'Imagem de capa',
'dashboard.addCoverImage': 'Adicionar capa (ou arrastar e soltar)',
'dashboard.addMembers': 'Companheiros de viagem',
'dashboard.addMember': 'Adicionar membro',
'dashboard.coverSaved': 'Capa salva',
'dashboard.coverUploadError': 'Falha no envio',
'dashboard.coverRemoveError': 'Falha ao remover',
'dashboard.titleRequired': 'O título é obrigatório',
'dashboard.endDateError': 'A data final deve ser depois da inicial',
'dashboard.greeting.morning': 'Bom dia,',
'dashboard.greeting.afternoon': 'Boa tarde,',
'dashboard.greeting.evening': 'Boa noite,',
'dashboard.mobile.liveNow': 'Ao vivo agora',
'dashboard.mobile.tripProgress': 'Progresso da viagem',
'dashboard.mobile.daysLeft': '{count} dias restantes',
'dashboard.mobile.places': 'Lugares',
'dashboard.mobile.buddies': 'Companheiros',
'dashboard.mobile.newTrip': 'Nova viagem',
'dashboard.mobile.currency': 'Moeda',
'dashboard.mobile.timezone': 'Fuso horário',
'dashboard.mobile.upcomingTrips': 'Próximas viagens',
'dashboard.mobile.yourTrips': 'Suas viagens',
'dashboard.mobile.trips': 'viagens',
'dashboard.mobile.starts': 'Começa',
'dashboard.mobile.duration': 'Duração',
'dashboard.mobile.day': 'dia',
'dashboard.mobile.days': 'dias',
'dashboard.mobile.ongoing': 'Em andamento',
'dashboard.mobile.startsToday': 'Começa hoje',
'dashboard.mobile.tomorrow': 'Amanhã',
'dashboard.mobile.inDays': 'Em {count} dias',
'dashboard.mobile.inMonths': 'Em {count} meses',
'dashboard.mobile.completed': 'Concluído',
'dashboard.mobile.currencyConverter': 'Conversor de moedas',
'dashboard.filter.planned': 'Planejadas',
'dashboard.hero.badgeLive': 'AO VIVO AGORA',
'dashboard.hero.badgeToday': 'COMEÇA HOJE',
'dashboard.hero.badgeTomorrow': 'AMANHÃ',
'dashboard.hero.badgeNext': 'A SEGUIR',
'dashboard.hero.badgeRecent': 'RECENTE',
'dashboard.hero.tripDates': 'Datas da viagem',
'dashboard.hero.noDates': 'Sem datas definidas',
'dashboard.hero.travelerOne': '{count} viajante',
'dashboard.hero.travelerMany': '{count} viajantes',
'dashboard.hero.destinationOne': '{count} destino',
'dashboard.hero.destinationMany': '{count} destinos',
'dashboard.hero.dayUnitOne': 'dia',
'dashboard.hero.dayUnitMany': 'dias',
'dashboard.hero.dayLeft': 'Dia restante',
'dashboard.hero.daysLeft': 'Dias restantes',
'dashboard.hero.lastDay': 'Último dia',
'dashboard.hero.untilStart': 'Até o início',
'dashboard.hero.startsIn': 'A viagem começa em',
'dashboard.atlas.countriesVisited': 'Atlas · Países visitados',
'dashboard.atlas.ofTotal': 'de {total}',
'dashboard.atlas.tripsTotal': 'Total de viagens',
'dashboard.atlas.placesMapped': '{count} lugares mapeados',
'dashboard.atlas.daysTraveled': 'Dias de viagem',
'dashboard.atlas.daysUnit': 'dias',
'dashboard.atlas.acrossAllTrips': 'em todas as viagens',
'dashboard.atlas.distanceFlown': 'Distância voada',
'dashboard.atlas.kmUnit': 'km',
'dashboard.atlas.aroundEquator': '≈ {count}× ao redor do equador',
'dashboard.card.idea': 'Ideia',
'dashboard.card.buddyOne': 'Parceiro',
'dashboard.fx.from': 'De',
'dashboard.fx.to': 'Para',
'dashboard.fx.unavailable': 'Taxa indisponível',
'dashboard.tz.searchPlaceholder': 'Buscar fuso horário…',
'dashboard.tz.empty': 'Ainda sem outros fusos horários — adicione um com +',
'dashboard.upcoming.title': 'Próximas reservas',
'dashboard.upcoming.empty': 'Nada reservado ainda.',
'dashboard.confirm.copy.title': 'Copiar esta viagem?',
'dashboard.confirm.copy.willCopy': 'Será copiado',
'dashboard.confirm.copy.will1': 'Dias, lugares e atribuições por dia',
'dashboard.confirm.copy.will2': 'Hospedagens e reservas',
'dashboard.confirm.copy.will3': 'Itens de orçamento e ordem das categorias',
'dashboard.confirm.copy.will4': 'Listas de bagagem (desmarcadas)',
'dashboard.confirm.copy.will5': 'Tarefas (não atribuídas e desmarcadas)',
'dashboard.confirm.copy.will6': 'Notas do dia',
'dashboard.confirm.copy.wontCopy': 'Não será copiado',
'dashboard.confirm.copy.wont1': 'Colaboradores e atribuições de membros',
'dashboard.confirm.copy.wont2': 'Notas, enquetes e mensagens compartilhadas',
'dashboard.confirm.copy.wont3': 'Arquivos e fotos',
'dashboard.confirm.copy.wont4': 'Tokens de compartilhamento',
'dashboard.confirm.copy.confirm': 'Copiar viagem',
'dashboard.aria.toggleView': 'Alternar visualização',
'dashboard.aria.filter': 'Filtrar',
'dashboard.aria.duplicate': 'Duplicar',
'dashboard.aria.refreshRates': 'Atualizar taxas',
'dashboard.aria.swapCurrencies': 'Trocar moedas',
'dashboard.aria.addTimezone': 'Adicionar fuso horário',
'dashboard.aria.removeTimezone': 'Remover {city}',
'dashboard.dayCountRequired': 'O número de dias é obrigatório',
};
export default dashboard;
+27
View File
@@ -0,0 +1,27 @@
import type { TranslationStrings } from '../types';
const day: TranslationStrings = {
'day.precipProb': 'Probabilidade de chuva',
'day.precipitation': 'Precipitação',
'day.wind': 'Vento',
'day.sunrise': 'Nascer do sol',
'day.sunset': 'Pôr do sol',
'day.hourlyForecast': 'Previsão por hora',
'day.climateHint':
'Médias históricas — previsão real disponível até 16 dias desta data.',
'day.noWeather':
'Sem dados meteorológicos. Adicione um lugar com coordenadas.',
'day.overview': 'Resumo do dia',
'day.accommodation': 'Hospedagem',
'day.addAccommodation': 'Adicionar hospedagem',
'day.hotelDayRange': 'Aplicar aos dias',
'day.noPlacesForHotel': 'Adicione lugares à viagem primeiro',
'day.allDays': 'Todos',
'day.checkIn': 'Check-in',
'day.checkInUntil': 'Até',
'day.checkOut': 'Check-out',
'day.confirmation': 'Confirmação',
'day.editAccommodation': 'Editar hospedagem',
'day.reservations': 'Reservas',
};
export default day;
+61
View File
@@ -0,0 +1,61 @@
import type { TranslationStrings } from '../types';
const dayplan: TranslationStrings = {
'dayplan.icsTooltip': 'Exportar calendário (ICS)',
'dayplan.emptyDay': 'Nenhum lugar planejado para este dia',
'dayplan.addNote': 'Adicionar nota',
'dayplan.editNote': 'Editar nota',
'dayplan.noteAdd': 'Adicionar nota',
'dayplan.noteEdit': 'Editar nota',
'dayplan.noteTitle': 'Nota',
'dayplan.noteSubtitle': 'Nota do dia',
'dayplan.totalCost': 'Custo total',
'dayplan.days': 'Dias',
'dayplan.dayN': 'Dia {n}',
'dayplan.calculating': 'Calculando...',
'dayplan.route': 'Rota',
'dayplan.optimize': 'Otimizar',
'dayplan.optimized': 'Rota otimizada',
'dayplan.routeError': 'Falha ao calcular a rota',
'dayplan.toast.needTwoPlaces':
'São necessários pelo menos dois lugares para otimizar a rota',
'dayplan.toast.routeOptimized': 'Rota otimizada',
'dayplan.toast.routeOptimizedFromHotel':
'Rota otimizada a partir da sua hospedagem',
'dayplan.toast.noGeoPlaces':
'Nenhum lugar com coordenadas para calcular a rota',
'dayplan.confirmed': 'Confirmada',
'dayplan.pendingRes': 'Pendente',
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Exportar plano do dia em PDF',
'dayplan.pdfError': 'Falha ao exportar PDF',
'dayplan.cannotReorderTransport':
'Reservas com horário fixo não podem ser reordenadas',
'dayplan.confirmRemoveTimeTitle': 'Remover horário?',
'dayplan.confirmRemoveTimeBody':
'Este lugar tem um horário fixo ({time}). Movê-lo removerá o horário e permitirá ordenação livre.',
'dayplan.confirmRemoveTimeAction': 'Remover horário e mover',
'dayplan.confirmDeleteNoteTitle': 'Excluir nota?',
'dayplan.confirmDeleteNoteBody': 'Esta nota será excluída permanentemente.',
'dayplan.cannotDropOnTimed':
'Itens não podem ser colocados entre entradas com horário fixo',
'dayplan.cannotBreakChronology':
'Isso quebraria a ordem cronológica dos itens e reservas agendados',
'dayplan.mobile.addPlace': 'Adicionar lugar',
'dayplan.mobile.searchPlaces': 'Buscar lugares...',
'dayplan.mobile.allAssigned': 'Todos os lugares atribuídos',
'dayplan.mobile.noMatch': 'Sem correspondência',
'dayplan.mobile.createNew': 'Criar novo lugar',
'dayplan.expandAll': 'Expand all days', // en-fallback
'dayplan.collapseAll': 'Collapse all days', // en-fallback
'dayplan.reorderDays': 'Reordenar dias',
'dayplan.reorderTitle': 'Reordenar dias',
'dayplan.reorderHint': 'Os lugares, notas e reservas de um dia se movem junto com ele.',
'dayplan.addDay': 'Adicionar dia',
'dayplan.moveUp': 'Mover para cima',
'dayplan.moveDown': 'Mover para baixo',
'dayplan.reorderUndo': 'Reordenar dias',
'dayplan.reorderError': 'Falha ao reordenar os dias',
'dayplan.addDayError': 'Falha ao adicionar o dia',
};
export default dayplan;
@@ -0,0 +1,63 @@
import type { NotificationLocale } from '../externalNotifications/types';
const br: NotificationLocale = {
email: {
footer: 'Você recebeu isso porque tem as notificações ativadas no TREK.',
manage: 'Gerenciar preferências nas configurações',
madeWith: 'Made with',
openTrek: 'Abrir TREK',
},
events: {
trip_invite: (p) => ({
title: `Convite para "${p.trip}"`,
body: `${p.actor} convidou ${p.invitee || 'um membro'} para a viagem "${p.trip}".`,
}),
booking_change: (p) => ({
title: `Nova reserva: ${p.booking}`,
body: `${p.actor} adicionou uma reserva "${p.booking}" (${p.type}) em "${p.trip}".`,
}),
trip_reminder: (p) => ({
title: `Lembrete: ${p.trip}`,
body: `Sua viagem "${p.trip}" está chegando!`,
}),
todo_due: (p) => ({
title: `Tarefa com vencimento: ${p.todo}`,
body: `"${p.todo}" em "${p.trip}" vence em ${p.due}.`,
}),
vacay_invite: (p) => ({
title: 'Convite Vacay Fusion',
body: `${p.actor} convidou você para fundir planos de férias. Abra o TREK para aceitar ou recusar.`,
}),
photos_shared: (p) => ({
title: `${p.count} fotos compartilhadas`,
body: `${p.actor} compartilhou ${p.count} foto(s) em "${p.trip}".`,
}),
collab_message: (p) => ({
title: `Nova mensagem em "${p.trip}"`,
body: `${p.actor}: ${p.preview}`,
}),
packing_tagged: (p) => ({
title: `Bagagem: ${p.category}`,
body: `${p.actor} atribuiu você à categoria "${p.category}" em "${p.trip}".`,
}),
version_available: (p) => ({
title: 'Nova versão do TREK disponível',
body: `O TREK ${p.version} está disponível. Acesse o painel de administração para atualizar.`,
}),
synology_session_cleared: () => ({
title: 'Sessão Synology encerrada',
body: 'Sua conta ou URL do Synology foi alterada. Você foi desconectado do Synology Photos.',
}),
},
passwordReset: {
subject: 'Redefinir sua senha',
greeting: 'Olá',
body: 'Recebemos um pedido para redefinir a senha da sua conta TREK. Clique no botão abaixo para definir uma nova senha.',
ctaIntro: 'Redefinir senha',
expiry: 'Este link expira em 60 minutos.',
ignore:
'Se você não solicitou isto, pode ignorar este e-mail — sua senha não será alterada.',
},
};
export default br;
+63
View File
@@ -0,0 +1,63 @@
import type { TranslationStrings } from '../types';
const files: TranslationStrings = {
'files.title': 'Arquivos',
'files.pageTitle': 'Arquivos e documentos',
'files.subtitle': '{count} arquivos para {trip}',
'files.download': 'Baixar',
'files.openError': 'Não foi possível abrir o arquivo',
'files.downloadPdf': 'Baixar PDF',
'files.count': '{count} arquivos',
'files.countSingular': '1 arquivo',
'files.uploaded': '{count} enviado(s)',
'files.uploadError': 'Falha no envio',
'files.dropzone': 'Solte os arquivos aqui',
'files.dropzoneHint': 'ou clique para escolher',
'files.allowedTypes':
'Imagens, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Máx. 50 MB',
'files.uploading': 'Enviando...',
'files.filterAll': 'Todos',
'files.filterPdf': 'PDFs',
'files.filterImages': 'Imagens',
'files.filterDocs': 'Documentos',
'files.filterCollab': 'Notas Colab',
'files.sourceCollab': 'Das notas Colab',
'files.empty': 'Nenhum arquivo ainda',
'files.emptyHint': 'Envie arquivos para anexá-los à viagem',
'files.openTab': 'Abrir em nova aba',
'files.confirm.delete': 'Excluir este arquivo?',
'files.toast.deleted': 'Arquivo excluído',
'files.toast.deleteError': 'Falha ao excluir arquivo',
'files.sourcePlan': 'Plano do dia',
'files.sourceBooking': 'Reserva',
'files.sourceTransport': 'Transporte',
'files.attach': 'Anexar',
'files.pasteHint':
'Você também pode colar imagens da área de transferência (Ctrl+V)',
'files.trash': 'Lixeira',
'files.trashEmpty': 'A lixeira está vazia',
'files.emptyTrash': 'Esvaziar lixeira',
'files.restore': 'Restaurar',
'files.star': 'Favoritar',
'files.unstar': 'Remover favorito',
'files.assign': 'Atribuir',
'files.assignTitle': 'Atribuir arquivo',
'files.assignPlace': 'Lugar',
'files.assignBooking': 'Reserva',
'files.assignTransport': 'Transporte',
'files.unassigned': 'Não atribuído',
'files.unlink': 'Remover vínculo',
'files.toast.trashed': 'Movido para a lixeira',
'files.toast.restored': 'Arquivo restaurado',
'files.toast.trashEmptied': 'Lixeira esvaziada',
'files.toast.assigned': 'Arquivo atribuído',
'files.toast.assignError': 'Falha na atribuição',
'files.toast.restoreError': 'Falha ao restaurar',
'files.confirm.permanentDelete':
'Excluir permanentemente este arquivo? Não é possível desfazer.',
'files.confirm.emptyTrash':
'Excluir permanentemente todos os arquivos na lixeira? Não é possível desfazer.',
'files.noteLabel': 'Nota',
'files.notePlaceholder': 'Adicione uma nota...',
};
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,
...collab,
...memories,
...perm,
...undo,
...notifications,
...todo,
...notif,
...journey,
...oauth,
...system_notice,
...transport,
};
export default locale;
+22
View File
@@ -0,0 +1,22 @@
import type { TranslationStrings } from '../types';
const inspector: TranslationStrings = {
'inspector.opened': 'Aberto',
'inspector.closed': 'Fechado',
'inspector.openingHours': 'Horário de funcionamento',
'inspector.showHours': 'Mostrar horário de funcionamento',
'inspector.files': 'Arquivos',
'inspector.filesCount': '{count} arquivos',
'inspector.removeFromDay': 'Remover do dia',
'inspector.remove': 'Remover',
'inspector.addToDay': 'Adicionar ao dia',
'inspector.confirmedRes': 'Reserva confirmada',
'inspector.pendingRes': 'Reserva pendente',
'inspector.google': 'Abrir no Google Maps',
'inspector.website': 'Abrir site',
'inspector.addRes': 'Reserva',
'inspector.editRes': 'Editar reserva',
'inspector.participants': 'Participantes',
'inspector.trackStats': 'Dados da trilha',
};
export default inspector;
+245
View File
@@ -0,0 +1,245 @@
import type { TranslationStrings } from '../types';
const journey: TranslationStrings = {
'journey.search.placeholder': 'Buscar jornadas…',
'journey.search.noResults': 'Nenhuma jornada corresponde a "{query}"',
'journey.title': 'Jornada',
'journey.subtitle': 'Registre suas viagens em tempo real',
'journey.new': 'Nova jornada',
'journey.create': 'Criar',
'journey.titlePlaceholder': 'Para onde você vai?',
'journey.empty': 'Nenhuma jornada ainda',
'journey.emptyHint': 'Comece a documentar sua próxima viagem',
'journey.deleted': 'Jornada excluída',
'journey.createError': 'Não foi possível criar a jornada',
'journey.deleteError': 'Não foi possível excluir a jornada',
'journey.deleteConfirmTitle': 'Excluir',
'journey.deleteConfirmMessage':
'Excluir "{title}"? Isso não pode ser desfeito.',
'journey.deleteConfirmGeneric': 'Tem certeza de que deseja excluir isso?',
'journey.notFound': 'Jornada não encontrada',
'journey.photos': 'Fotos',
'journey.timelineEmpty': 'Nenhuma parada ainda',
'journey.timelineEmptyHint':
'Adicione um check-in ou escreva uma entrada no diário para começar',
'journey.status.draft': 'Rascunho',
'journey.status.active': 'Ativa',
'journey.status.completed': 'Concluída',
'journey.status.upcoming': 'Próxima',
'journey.status.archived': 'Arquivado',
'journey.checkin.add': 'Fazer check-in',
'journey.checkin.namePlaceholder': 'Nome do local',
'journey.checkin.notesPlaceholder': 'Notas (opcional)',
'journey.checkin.save': 'Salvar',
'journey.checkin.error': 'Não foi possível salvar o check-in',
'journey.entry.add': 'Diário',
'journey.entry.edit': 'Editar entrada',
'journey.entry.titlePlaceholder': 'Título (opcional)',
'journey.entry.bodyPlaceholder': 'O que aconteceu hoje?',
'journey.entry.save': 'Salvar',
'journey.entry.error': 'Não foi possível salvar a entrada',
'journey.photo.add': 'Foto',
'journey.photo.uploadError': 'Falha no envio',
'journey.share.share': 'Compartilhar',
'journey.share.public': 'Público',
'journey.share.linkCopied': 'Link público copiado',
'journey.share.disabled': 'Compartilhamento público desativado',
'journey.editor.titlePlaceholder': 'Dê um nome a este momento...',
'journey.editor.bodyPlaceholder': 'Conte a história deste dia...',
'journey.editor.placePlaceholder': 'Localização (opcional)',
'journey.editor.tagsPlaceholder':
'Tags: joia escondida, melhor refeição, preciso voltar...',
'journey.visibility.private': 'Privado',
'journey.visibility.shared': 'Compartilhado',
'journey.visibility.public': 'Público',
'journey.emptyState.title': 'Sua história começa aqui',
'journey.emptyState.subtitle':
'Faça check-in em um lugar ou escreva sua primeira entrada no diário',
'journey.frontpage.subtitle':
'Transforme suas viagens em histórias que você nunca vai esquecer',
'journey.frontpage.createJourney': 'Criar jornada',
'journey.frontpage.activeJourney': 'Jornada ativa',
'journey.frontpage.allJourneys': 'Todas as jornadas',
'journey.frontpage.journeys': 'jornadas',
'journey.frontpage.createNew': 'Criar uma nova jornada',
'journey.frontpage.createNewSub':
'Escolha viagens, escreva histórias, compartilhe suas aventuras',
'journey.frontpage.live': 'Ao vivo',
'journey.frontpage.synced': 'Sincronizado',
'journey.frontpage.continueWriting': 'Continuar escrevendo',
'journey.frontpage.updated': 'Atualizado {time}',
'journey.frontpage.suggestionLabel': 'A viagem acabou de terminar',
'journey.frontpage.suggestionText':
'Transforme <strong>{title}</strong> em uma jornada',
'journey.frontpage.dismiss': 'Dispensar',
'journey.frontpage.journeyName': 'Nome da jornada',
'journey.frontpage.namePlaceholder': 'ex. Sudeste Asiático 2026',
'journey.frontpage.selectTrips': 'Selecionar viagens',
'journey.frontpage.tripsSelected': 'viagens selecionadas',
'journey.frontpage.trips': 'viagens',
'journey.frontpage.placesImported': 'lugares serão importados',
'journey.frontpage.places': 'lugares',
'journey.detail.backToJourney': 'Voltar à jornada',
'journey.detail.syncedWithTrips': 'Sincronizado com viagens',
'journey.detail.addEntry': 'Adicionar entrada',
'journey.detail.newEntry': 'Nova entrada',
'journey.detail.editEntry': 'Editar entrada',
'journey.detail.noEntries': 'Nenhuma entrada ainda',
'journey.detail.noEntriesHint':
'Adicione uma viagem para começar com entradas preliminares',
'journey.detail.noPhotos': 'Nenhuma foto ainda',
'journey.detail.noPhotosHint':
'Envie fotos para as entradas ou explore sua biblioteca do Immich/Synology',
'journey.detail.journeyStats': 'Estatísticas da jornada',
'journey.detail.syncedTrips': 'Viagens sincronizadas',
'journey.detail.noTripsLinked': 'Nenhuma viagem vinculada ainda',
'journey.detail.contributors': 'Colaboradores',
'journey.detail.readMore': 'Ler mais',
'journey.detail.prosCons': 'Prós e contras',
'journey.detail.photos': 'fotos',
'journey.detail.day': 'Dia {number}',
'journey.detail.places': 'lugares',
'journey.stats.days': 'Dias',
'journey.stats.cities': 'Cidades',
'journey.stats.entries': 'Entradas',
'journey.stats.photos': 'Fotos',
'journey.stats.places': 'Lugares',
'journey.skeletons.show': 'Mostrar sugestões',
'journey.skeletons.hide': 'Ocultar sugestões',
'journey.verdict.lovedIt': 'Adorei',
'journey.verdict.couldBeBetter': 'Poderia ser melhor',
'journey.synced.places': 'lugares',
'journey.synced.synced': 'sincronizado',
'journey.editor.discardChangesConfirm':
'Você tem alterações não salvas. Descartá-las?',
'journey.editor.uploadFailed': 'Falha ao enviar fotos',
'journey.editor.uploadPhotos': 'Enviar fotos',
'journey.editor.uploading': 'Enviando...',
'journey.editor.uploadingProgress': 'Enviando {done}/{total}…',
'journey.editor.uploadPartialFailed':
'{failed} de {total} fotos falharam — salve novamente para tentar',
'journey.editor.fromGallery': 'Da galeria',
'journey.editor.allPhotosAdded': 'Todas as fotos já foram adicionadas',
'journey.editor.writeStory': 'Escreva sua história...',
'journey.editor.prosCons': 'Prós e contras',
'journey.editor.pros': 'Prós',
'journey.editor.cons': 'Contras',
'journey.editor.proPlaceholder': 'Algo ótimo...',
'journey.editor.conPlaceholder': 'Não tão bom...',
'journey.editor.addAnother': 'Adicionar outro',
'journey.editor.date': 'Data',
'journey.editor.location': 'Localização',
'journey.editor.searchLocation': 'Buscar localização...',
'journey.editor.mood': 'Humor',
'journey.editor.weather': 'Clima',
'journey.editor.photoFirst': '1º',
'journey.editor.makeFirst': 'Tornar 1º',
'journey.editor.searching': 'Pesquisando...',
'journey.mood.amazing': 'Incrível',
'journey.mood.good': 'Bom',
'journey.mood.neutral': 'Neutro',
'journey.mood.rough': 'Difícil',
'journey.weather.sunny': 'Ensolarado',
'journey.weather.partly': 'Parcialmente nublado',
'journey.weather.cloudy': 'Nublado',
'journey.weather.rainy': 'Chuvoso',
'journey.weather.stormy': 'Tempestuoso',
'journey.weather.cold': 'Nevando',
'journey.trips.linkTrip': 'Vincular viagem',
'journey.trips.searchTrip': 'Buscar viagem',
'journey.trips.searchPlaceholder': 'Nome da viagem ou destino...',
'journey.trips.noTripsAvailable': 'Nenhuma viagem disponível',
'journey.trips.link': 'Vincular',
'journey.trips.tripLinked': 'Viagem vinculada',
'journey.trips.linkFailed': 'Não foi possível vincular a viagem',
'journey.trips.addTrip': 'Adicionar viagem',
'journey.trips.unlinkTrip': 'Desvincular viagem',
'journey.trips.unlinkMessage':
'Desvincular "{title}"? Todas as entradas e fotos sincronizadas desta viagem serão excluídas permanentemente. Isso não pode ser desfeito.',
'journey.trips.unlink': 'Desvincular',
'journey.trips.tripUnlinked': 'Viagem desvinculada',
'journey.trips.unlinkFailed': 'Não foi possível desvincular a viagem',
'journey.trips.noTripsLinkedSettings': 'Nenhuma viagem vinculada',
'journey.contributors.invite': 'Convidar colaborador',
'journey.contributors.searchUser': 'Buscar usuário',
'journey.contributors.searchPlaceholder': 'Nome de usuário ou e-mail...',
'journey.contributors.noUsers': 'Nenhum usuário encontrado',
'journey.contributors.role': 'Função',
'journey.contributors.added': 'Colaborador adicionado',
'journey.contributors.addFailed': 'Não foi possível adicionar o colaborador',
'journey.share.publicShare': 'Compartilhamento público',
'journey.share.createLink': 'Criar link de compartilhamento',
'journey.share.linkCreated': 'Link de compartilhamento criado',
'journey.share.createFailed': 'Não foi possível criar o link',
'journey.share.copy': 'Copiar',
'journey.share.copied': 'Copiado!',
'journey.share.timeline': 'Linha do tempo',
'journey.share.gallery': 'Galeria',
'journey.share.map': 'Mapa',
'journey.share.removeLink': 'Remover link de compartilhamento',
'journey.share.linkDeleted': 'Link de compartilhamento removido',
'journey.share.deleteFailed': 'Não foi possível excluir',
'journey.share.updateFailed': 'Não foi possível atualizar',
'journey.invite.role': 'Função',
'journey.invite.viewer': 'Visualizador',
'journey.invite.editor': 'Editor',
'journey.invite.invite': 'Convidar',
'journey.invite.inviting': 'Convidando...',
'journey.settings.title': 'Configurações da jornada',
'journey.settings.coverImage': 'Imagem de capa',
'journey.settings.changeCover': 'Alterar capa',
'journey.settings.addCover': 'Adicionar imagem de capa',
'journey.settings.name': 'Nome',
'journey.settings.subtitle': 'Subtítulo',
'journey.settings.subtitlePlaceholder': 'ex. Tailândia, Vietnã e Camboja',
'journey.settings.endJourney': 'Arquivar Jornada',
'journey.settings.reopenJourney': 'Restaurar Jornada',
'journey.settings.archived': 'Jornada arquivada',
'journey.settings.reopened': 'Jornada reaberta',
'journey.settings.endDescription':
'Oculta o selo Ao Vivo. Você pode reabrir a qualquer momento.',
'journey.settings.delete': 'Excluir',
'journey.settings.deleteJourney': 'Excluir jornada',
'journey.settings.deleteMessage':
'Excluir "{title}"? Todas as entradas e fotos serão perdidas.',
'journey.settings.saved': 'Configurações salvas',
'journey.settings.saveFailed': 'Não foi possível salvar',
'journey.settings.coverUpdated': 'Capa atualizada',
'journey.settings.coverFailed': 'Falha no envio',
'journey.settings.failedToDelete': 'Falha ao excluir',
'journey.entries.deleteTitle': 'Excluir entrada',
'journey.photosUploaded': '{count} fotos enviadas',
'journey.photosUploadFailed': 'Algumas fotos não foram enviadas',
'journey.photosAdded': '{count} fotos adicionadas',
'journey.public.notFound': 'Não encontrado',
'journey.public.notFoundMessage':
'Esta jornada não existe ou o link expirou.',
'journey.public.readOnly': 'Somente leitura · Jornada pública',
'journey.public.tagline': 'Kit de recursos e exploração de viagens',
'journey.public.sharedVia': 'Compartilhado via',
'journey.public.madeWith': 'Feito com',
'journey.pdf.journeyBook': 'Livro da jornada',
'journey.pdf.madeWith': 'Feito com TREK',
'journey.pdf.day': 'Dia',
'journey.pdf.theEnd': 'Fim',
'journey.pdf.saveAsPdf': 'Salvar como PDF',
'journey.pdf.pages': 'páginas',
'journey.picker.tripPeriod': 'Período da viagem',
'journey.picker.dateRange': 'Período',
'journey.picker.allPhotos': 'Todas as fotos',
'journey.picker.albums': 'Álbuns',
'journey.picker.selected': 'selecionados',
'journey.picker.addTo': 'Adicionar a',
'journey.picker.newGallery': 'Nova galeria',
'journey.picker.selectAll': 'Selecionar tudo',
'journey.picker.deselectAll': 'Desmarcar tudo',
'journey.picker.noAlbums': 'Nenhum álbum encontrado',
'journey.picker.selectDate': 'Selecionar data',
'journey.picker.search': 'Pesquisar',
'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;
+99
View File
@@ -0,0 +1,99 @@
import type { TranslationStrings } from '../types';
const login: TranslationStrings = {
'login.error': 'Falha no login. Verifique suas credenciais.',
'login.tagline': 'Suas viagens.\nSeu plano.',
'login.description':
'Planeje viagens em equipe com mapas interativos, orçamento e sincronização em tempo real.',
'login.features.maps': 'Mapas interativos',
'login.features.mapsDesc': 'Google Places, rotas e agrupamento',
'login.features.realtime': 'Sincronização em tempo real',
'login.features.realtimeDesc': 'Planejem juntos via WebSocket',
'login.features.budget': 'Controle de orçamento',
'login.features.budgetDesc': 'Categorias, gráficos e custo por pessoa',
'login.features.collab': 'Colaboração',
'login.features.collabDesc': 'Vários usuários com viagens compartilhadas',
'login.features.packing': 'Listas de malas',
'login.features.packingDesc': 'Categorias, progresso e sugestões',
'login.features.bookings': 'Reservas',
'login.features.bookingsDesc': 'Voos, hotéis, restaurantes e mais',
'login.features.files': 'Documentos',
'login.features.filesDesc': 'Envie e gerencie documentos',
'login.features.routes': 'Rotas inteligentes',
'login.features.routesDesc': 'Otimize e exporte para o Google Maps',
'login.selfHosted': 'Auto-hospedado · Código aberto · Seus dados são seus',
'login.title': 'Entrar',
'login.subtitle': 'Bem-vindo de volta',
'login.signingIn': 'Entrando…',
'login.signIn': 'Entrar',
'login.createAdmin': 'Criar conta de administrador',
'login.createAdminHint':
'Configure a primeira conta de administrador do TREK.',
'login.setNewPassword': 'Definir nova senha',
'login.setNewPasswordHint': 'Você deve alterar sua senha antes de continuar.',
'login.createAccount': 'Criar conta',
'login.createAccountHint': 'Cadastre uma nova conta.',
'login.creating': 'Criando…',
'login.noAccount': 'Não tem conta?',
'login.hasAccount': 'Já tem conta?',
'login.register': 'Cadastrar',
'login.emailPlaceholder': 'seu@email.com',
'login.username': 'Nome de usuário',
'login.oidc.registrationDisabled':
'Cadastro desativado. Fale com o administrador.',
'login.oidc.noEmail': 'Nenhum e-mail recebido do provedor.',
'login.oidc.tokenFailed': 'Falha na autenticação.',
'login.oidc.invalidState': 'Sessão inválida. Tente novamente.',
'login.demoFailed': 'Falha no login de demonstração',
'login.oidcSignIn': 'Entrar com {name}',
'login.oidcOnly': 'Login por senha desativado. Use o provedor SSO.',
'login.oidcLoggedOut':
'Você foi desconectado. Entre novamente usando o provedor SSO.',
'login.demoHint': 'Experimente a demonstração — sem cadastro',
'login.mfaTitle': 'Autenticação em duas etapas',
'login.mfaSubtitle': 'Digite o código de 6 dígitos do seu app autenticador.',
'login.mfaCodeLabel': 'Código de verificação',
'login.mfaCodeRequired': 'Digite o código do app autenticador.',
'login.mfaHint': 'Abra o Google Authenticator, Authy ou outro app TOTP.',
'login.mfaBack': '← Voltar ao login',
'login.mfaVerify': 'Verificar',
'login.invalidInviteLink': 'Link de convite inválido ou expirado',
'login.oidcFailed': 'Falha no login OIDC',
'login.usernameRequired': 'Nome de usuário é obrigatório',
'login.passwordMinLength': 'A senha deve ter pelo menos 8 caracteres',
'login.forgotPassword': 'Esqueceu a senha?',
'login.rememberMe': 'Lembrar de mim',
'login.forgotPasswordTitle': 'Redefinir sua senha',
'login.forgotPasswordBody':
'Digite o e-mail cadastrado. Se houver uma conta, enviaremos um link de redefinição.',
'login.forgotPasswordSubmit': 'Enviar link',
'login.forgotPasswordSentTitle': 'Verifique seu e-mail',
'login.forgotPasswordSentBody':
'Se houver uma conta para esse e-mail, o link está a caminho. Ele expira em 60 minutos.',
'login.forgotPasswordSmtpHintOff':
'Observação: seu administrador não configurou SMTP, então o link de redefinição será gravado no console do servidor em vez de ser enviado por e-mail.',
'login.backToLogin': 'Voltar ao login',
'login.newPassword': 'Nova senha',
'login.confirmPassword': 'Confirmar nova senha',
'login.passwordsDontMatch': 'As senhas não coincidem',
'login.mfaCode': 'Código 2FA',
'login.resetPasswordTitle': 'Definir uma nova senha',
'login.resetPasswordBody':
'Escolha uma senha forte que você ainda não tenha usado aqui. Mínimo de 8 caracteres.',
'login.resetPasswordMfaBody':
'Digite seu código 2FA ou um código de backup para concluir a redefinição.',
'login.resetPasswordSubmit': 'Redefinir senha',
'login.resetPasswordVerify': 'Verificar e redefinir',
'login.resetPasswordSuccessTitle': 'Senha atualizada',
'login.resetPasswordSuccessBody':
'Agora você pode entrar com sua nova senha.',
'login.resetPasswordInvalidLink': 'Link de redefinição inválido',
'login.resetPasswordInvalidLinkBody':
'Este link está ausente ou corrompido. Solicite um novo para continuar.',
'login.resetPasswordFailed':
'Falha na redefinição. O link pode ter expirado.',
'login.passkey.signIn': 'Entrar com uma passkey',
'login.passkey.failed':
'Falha ao entrar com passkey. Tente novamente.',
};
export default login;
+17
View File
@@ -0,0 +1,17 @@
import type { TranslationStrings } from '../types';
const map: TranslationStrings = {
'map.connections': 'Conexões',
'map.showConnections': 'Mostrar rotas de reservas',
'map.hideConnections': 'Ocultar rotas de reservas',
'poi.searchThisArea': 'Pesquisar nesta área',
'poi.cat.restaurants': 'Restaurantes',
'poi.cat.cafes': 'Cafés',
'poi.cat.bars': 'Bares e vida noturna',
'poi.cat.hotels': 'Hospedagem',
'poi.cat.sights': 'Pontos turísticos',
'poi.cat.museums': 'Museus e cultura',
'poi.cat.nature': 'Natureza e parques',
'poi.cat.activities': 'Atividades',
};
export default map;
+24
View File
@@ -0,0 +1,24 @@
import type { TranslationStrings } from '../types';
const members: TranslationStrings = {
'members.shareTrip': 'Compartilhar viagem',
'members.inviteUser': 'Convidar usuário',
'members.selectUser': 'Selecionar usuário…',
'members.invite': 'Convidar',
'members.allHaveAccess': 'Todos os usuários já têm acesso.',
'members.access': 'Acesso',
'members.person': 'pessoa',
'members.persons': 'pessoas',
'members.you': 'você',
'members.owner': 'Proprietário',
'members.leaveTrip': 'Sair da viagem',
'members.removeAccess': 'Remover acesso',
'members.confirmLeave': 'Sair da viagem? Você perderá o acesso.',
'members.confirmRemove': 'Remover o acesso deste usuário?',
'members.loadError': 'Falha ao carregar membros',
'members.added': 'adicionado',
'members.addError': 'Falha ao adicionar',
'members.removed': 'Membro removido',
'members.removeError': 'Falha ao remover',
};
export default members;
+83
View File
@@ -0,0 +1,83 @@
import type { TranslationStrings } from '../types';
const memories: TranslationStrings = {
'memories.title': 'Fotos',
'memories.notConnected': 'Immich não conectado',
'memories.notConnectedHint':
'Conecte sua instância Immich nas Configurações para ver suas fotos de viagem aqui.',
'memories.notConnectedMultipleHint':
'Conecte um destes provedores de fotos: {provider_names} nas Configurações para poder adicionar fotos a esta viagem.',
'memories.noDates': 'Adicione datas à sua viagem para carregar fotos.',
'memories.noPhotos': 'Nenhuma foto encontrada',
'memories.noPhotosHint':
'Nenhuma foto encontrada no Immich para o período desta viagem.',
'memories.photosFound': 'fotos',
'memories.fromOthers': 'de outros',
'memories.sharePhotos': 'Compartilhar fotos',
'memories.sharing': 'Compartilhando',
'memories.reviewTitle': 'Revise suas fotos',
'memories.reviewHint':
'Clique nas fotos para excluí-las do compartilhamento.',
'memories.shareCount': 'Compartilhar {count} fotos',
'memories.providerUrl': 'URL do servidor',
'memories.providerApiKey': 'Chave de API',
'memories.providerUsername': 'Nome de usuário',
'memories.providerPassword': 'Senha',
'memories.providerOTP': 'Código MFA (se habilitado)',
'memories.skipSSLVerification': 'Pular verificação de certificado SSL',
'memories.immichAutoUpload': 'Espelhar fotos da jornada no Immich ao enviar',
'memories.providerUrlHintSynology':
'Inclua o caminho do aplicativo Photos na URL, ex. https://nas:5001/photo',
'memories.testConnection': 'Testar conexão',
'memories.testShort': 'Testar',
'memories.testFirst': 'Teste a conexão primeiro',
'memories.connected': 'Conectado',
'memories.disconnected': 'Não conectado',
'memories.connectionSuccess': 'Conectado ao Immich',
'memories.connectionError': 'Não foi possível conectar ao Immich',
'memories.saved': 'Configurações do {provider_name} salvas',
'memories.providerDisconnectedBanner':
'Sua conexão com {provider_name} foi perdida. Reconecte nas Configurações para ver as fotos.',
'memories.saveError':
'Não foi possível salvar as configurações de {provider_name}',
'memories.addPhotos': 'Adicionar fotos',
'memories.linkAlbum': 'Vincular álbum',
'memories.selectAlbum': 'Selecionar álbum do Immich',
'memories.selectAlbumMultiple': 'Selecionar álbum',
'memories.noAlbums': 'Nenhum álbum encontrado',
'memories.syncAlbum': 'Sincronizar álbum',
'memories.unlinkAlbum': 'Desvincular',
'memories.photos': 'fotos',
'memories.selectPhotos': 'Selecionar fotos do Immich',
'memories.selectPhotosMultiple': 'Selecionar fotos',
'memories.selectHint': 'Toque nas fotos para selecioná-las.',
'memories.selected': 'selecionadas',
'memories.addSelected': 'Adicionar {count} fotos',
'memories.alreadyAdded': 'Já adicionada',
'memories.private': 'Privado',
'memories.stopSharing': 'Parar de compartilhar',
'memories.oldest': 'Mais antigas',
'memories.newest': 'Mais recentes',
'memories.allLocations': 'Todos os locais',
'memories.tripDates': 'Datas da viagem',
'memories.allPhotos': 'Todas as fotos',
'memories.confirmShareTitle': 'Compartilhar com membros da viagem?',
'memories.confirmShareHint':
'{count} fotos serão visíveis para todos os membros desta viagem. Você pode tornar fotos individuais privadas depois.',
'memories.confirmShareButton': 'Compartilhar fotos',
'memories.error.loadAlbums': 'Falha ao carregar álbuns',
'memories.error.linkAlbum': 'Falha ao vincular álbum',
'memories.error.unlinkAlbum': 'Falha ao desvincular álbum',
'memories.error.syncAlbum': 'Falha ao sincronizar álbum',
'memories.error.loadPhotos': 'Falha ao carregar fotos',
'memories.error.addPhotos': 'Falha ao adicionar fotos',
'memories.error.removePhoto': 'Falha ao remover foto',
'memories.error.toggleSharing': 'Falha ao atualizar compartilhamento',
'memories.saveRouteNotConfigured':
'A rota de salvamento não está configurada para este provedor',
'memories.testRouteNotConfigured':
'A rota de teste não está configurada para este provedor',
'memories.fillRequiredFields':
'Por favor preencha todos os campos obrigatórios',
};
export default memories;
+20
View File
@@ -0,0 +1,20 @@
import type { TranslationStrings } from '../types';
const nav: TranslationStrings = {
'nav.trip': 'Viagem',
'nav.share': 'Compartilhar',
'nav.settings': 'Configurações',
'nav.admin': 'Admin',
'nav.logout': 'Sair',
'nav.lightMode': 'Modo claro',
'nav.darkMode': 'Modo escuro',
'nav.autoMode': 'Automático',
'nav.administrator': 'Administrador',
'nav.myTrips': 'Minhas viagens',
'nav.profile': 'Perfil',
'nav.bottomSettings': 'Configurações',
'nav.bottomAdmin': 'Administração',
'nav.bottomLogout': 'Sair',
'nav.bottomAdminBadge': 'Admin',
};
export default nav;
+42
View File
@@ -0,0 +1,42 @@
import type { TranslationStrings } from '../types';
const notif: TranslationStrings = {
'notif.test.title': '[Teste] Notificação',
'notif.test.simple.text': 'Esta é uma notificação de teste simples.',
'notif.test.boolean.text': 'Você aceita esta notificação de teste?',
'notif.test.navigate.text': 'Clique abaixo para ir ao painel.',
'notif.trip_invite.title': 'Convite para viagem',
'notif.trip_invite.text': '{actor} convidou você para {trip}',
'notif.booking_change.title': 'Reserva atualizada',
'notif.booking_change.text': '{actor} atualizou uma reserva em {trip}',
'notif.trip_reminder.title': 'Lembrete de viagem',
'notif.trip_reminder.text': 'Sua viagem {trip} está chegando!',
'notif.todo_due.title': 'Tarefa com vencimento',
'notif.todo_due.text': '{todo} em {trip} vence em {due}',
'notif.vacay_invite.title': 'Convite Vacay Fusion',
'notif.vacay_invite.text':
'{actor} convidou você para fundir planos de férias',
'notif.photos_shared.title': 'Fotos compartilhadas',
'notif.photos_shared.text': '{actor} compartilhou {count} foto(s) em {trip}',
'notif.collab_message.title': 'Nova mensagem',
'notif.collab_message.text': '{actor} enviou uma mensagem em {trip}',
'notif.packing_tagged.title': 'Atribuição de bagagem',
'notif.packing_tagged.text': '{actor} atribuiu você a {category} em {trip}',
'notif.version_available.title': 'Nova versão disponível',
'notif.version_available.text': 'TREK {version} está disponível',
'notif.action.view_trip': 'Ver viagem',
'notif.action.view_collab': 'Ver mensagens',
'notif.action.view_packing': 'Ver bagagem',
'notif.action.view_photos': 'Ver fotos',
'notif.action.view_vacay': 'Ver Vacay',
'notif.action.view_admin': 'Ir para admin',
'notif.action.view': 'Ver',
'notif.action.accept': 'Aceitar',
'notif.action.decline': 'Recusar',
'notif.generic.title': 'Notificação',
'notif.generic.text': 'Você tem uma nova notificação',
'notif.dev.unknown_event.title': '[DEV] Evento desconhecido',
'notif.dev.unknown_event.text':
'O tipo de evento "{event}" não está registrado em EVENT_NOTIFICATION_CONFIG',
};
export default notif;
+37
View File
@@ -0,0 +1,37 @@
import type { TranslationStrings } from '../types';
const notifications: TranslationStrings = {
'notifications.title': 'Notificações',
'notifications.markAllRead': 'Marcar tudo como lido',
'notifications.deleteAll': 'Excluir tudo',
'notifications.showAll': 'Ver todas as notificações',
'notifications.empty': 'Sem notificações',
'notifications.emptyDescription': 'Você está em dia!',
'notifications.all': 'Todas',
'notifications.unreadOnly': 'Não lidas',
'notifications.markRead': 'Marcar como lido',
'notifications.markUnread': 'Marcar como não lido',
'notifications.delete': 'Excluir',
'notifications.system': 'Sistema',
'notifications.synologySessionCleared.title': 'Synology Photos desconectado',
'notifications.synologySessionCleared.text':
'Seu servidor ou conta foi alterado — vá para Configurações para testar sua conexão novamente.',
'notifications.test.title': 'Notificação de teste de {actor}',
'notifications.test.text': 'Esta é uma notificação de teste simples.',
'notifications.test.booleanTitle': '{actor} solicita sua aprovação',
'notifications.test.booleanText': 'Notificação de teste booleana.',
'notifications.test.accept': 'Aprovar',
'notifications.test.decline': 'Recusar',
'notifications.test.navigateTitle': 'Confira algo',
'notifications.test.navigateText': 'Notificação de teste de navegação.',
'notifications.test.goThere': 'Ir lá',
'notifications.test.adminTitle': 'Transmissão do admin',
'notifications.test.adminText':
'{actor} enviou uma notificação de teste para todos os admins.',
'notifications.test.tripTitle': '{actor} postou na sua viagem',
'notifications.test.tripText': 'Notificação de teste para a viagem "{trip}".',
'notifications.versionAvailable.title': 'Atualização disponível',
'notifications.versionAvailable.text': 'TREK {version} já está disponível.',
'notifications.versionAvailable.button': 'Ver detalhes',
};
export default notifications;
+122
View File
@@ -0,0 +1,122 @@
import type { TranslationStrings } from '../types';
const oauth: TranslationStrings = {
'oauth.scope.group.trips': 'Viagens',
'oauth.scope.group.places': 'Locais',
'oauth.scope.group.atlas': 'Atlas',
'oauth.scope.group.packing': 'Bagagem',
'oauth.scope.group.todos': 'Tarefas',
'oauth.scope.group.budget': 'Orçamento',
'oauth.scope.group.reservations': 'Reservas',
'oauth.scope.group.collab': 'Colaboração',
'oauth.scope.group.notifications': 'Notificações',
'oauth.scope.group.vacay': 'Férias',
'oauth.scope.group.geo': 'Geo',
'oauth.scope.group.weather': 'Clima',
'oauth.scope.group.journey': 'Jornada',
'oauth.scope.trips:read.label': 'Ver viagens e itinerários',
'oauth.scope.trips:read.description': 'Ler viagens, dias, notas e membros',
'oauth.scope.trips:write.label': 'Editar viagens e itinerários',
'oauth.scope.trips:write.description':
'Criar e atualizar viagens, dias, notas e gerenciar membros',
'oauth.scope.trips:delete.label': 'Excluir viagens',
'oauth.scope.trips:delete.description':
'Excluir viagens permanentemente — esta ação é irreversível',
'oauth.scope.trips:share.label': 'Gerenciar links de compartilhamento',
'oauth.scope.trips:share.description':
'Criar, atualizar e revogar links de compartilhamento públicos',
'oauth.scope.places:read.label': 'Ver locais e dados do mapa',
'oauth.scope.places:read.description':
'Ler locais, atribuições de dias, tags e categorias',
'oauth.scope.places:write.label': 'Gerenciar locais',
'oauth.scope.places:write.description':
'Criar, atualizar e excluir locais, atribuições e tags',
'oauth.scope.atlas:read.label': 'Ver Atlas',
'oauth.scope.atlas:read.description':
'Ler países visitados, regiões e lista de desejos',
'oauth.scope.atlas:write.label': 'Gerenciar Atlas',
'oauth.scope.atlas:write.description':
'Marcar países e regiões como visitados, gerenciar lista de desejos',
'oauth.scope.packing:read.label': 'Ver listas de bagagem',
'oauth.scope.packing:read.description':
'Ler itens, malas e responsáveis por categoria',
'oauth.scope.packing:write.label': 'Gerenciar listas de bagagem',
'oauth.scope.packing:write.description':
'Adicionar, atualizar, excluir, marcar e reordenar itens e malas',
'oauth.scope.todos:read.label': 'Ver listas de tarefas',
'oauth.scope.todos:read.description':
'Ler tarefas da viagem e responsáveis por categoria',
'oauth.scope.todos:write.label': 'Gerenciar listas de tarefas',
'oauth.scope.todos:write.description':
'Criar, atualizar, marcar, excluir e reordenar tarefas',
'oauth.scope.budget:read.label': 'Ver orçamento',
'oauth.scope.budget:read.description':
'Ler itens de orçamento e detalhamento de despesas',
'oauth.scope.budget:write.label': 'Gerenciar orçamento',
'oauth.scope.budget:write.description':
'Criar, atualizar e excluir itens de orçamento',
'oauth.scope.reservations:read.label': 'Ver reservas',
'oauth.scope.reservations:read.description':
'Ler reservas e detalhes de acomodação',
'oauth.scope.reservations:write.label': 'Gerenciar reservas',
'oauth.scope.reservations:write.description':
'Criar, atualizar, excluir e reordenar reservas',
'oauth.scope.collab:read.label': 'Ver colaboração',
'oauth.scope.collab:read.description':
'Ler notas colaborativas, enquetes e mensagens',
'oauth.scope.collab:write.label': 'Gerenciar colaboração',
'oauth.scope.collab:write.description':
'Criar, atualizar e excluir notas, enquetes e mensagens',
'oauth.scope.notifications:read.label': 'Ver notificações',
'oauth.scope.notifications:read.description':
'Ler notificações e contagens não lidas',
'oauth.scope.notifications:write.label': 'Gerenciar notificações',
'oauth.scope.notifications:write.description':
'Marcar notificações como lidas e respondê-las',
'oauth.scope.vacay:read.label': 'Ver planos de férias',
'oauth.scope.vacay:read.description':
'Ler dados de planejamento de férias, entradas e estatísticas',
'oauth.scope.vacay:write.label': 'Gerenciar planos de férias',
'oauth.scope.vacay:write.description':
'Criar e gerenciar entradas de férias, feriados e planos de equipe',
'oauth.scope.geo:read.label': 'Mapas e geocodificação',
'oauth.scope.geo:read.description':
'Pesquisar locais, resolver URLs de mapa e geocodificar coordenadas',
'oauth.scope.weather:read.label': 'Previsão do tempo',
'oauth.scope.weather:read.description':
'Obter previsão do tempo para locais e datas da viagem',
'oauth.scope.journey:read.label': 'Ver jornadas',
'oauth.scope.journey:read.description':
'Ler jornadas, entradas e lista de colaboradores',
'oauth.scope.journey:write.label': 'Gerenciar jornadas',
'oauth.scope.journey:write.description':
'Criar, atualizar e excluir jornadas e suas entradas',
'oauth.scope.journey:share.label': 'Gerenciar links de jornadas',
'oauth.scope.journey:share.description':
'Criar, atualizar e revogar links de compartilhamento públicos para jornadas',
'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;
+185
View File
@@ -0,0 +1,185 @@
import type { TranslationStrings } from '../types';
const packing: TranslationStrings = {
'packing.title': 'Lista de mala',
'packing.empty': 'A lista de mala está vazia',
'packing.import': 'Importar',
'packing.importTitle': 'Importar lista de bagagem',
'packing.importHint':
'Um item por linha. Formato: Categoria, Nome, Peso (g), Bolsa, checked/unchecked (opcional)',
'packing.importPlaceholder':
'Higiene, Escova de dentes\nRoupas, Camisetas, 200\nDocumentos, Passaporte, , Mala de mão\nEletrônicos, Carregador, 50, Mala, checked',
'packing.importCsv': 'Carregar CSV/TXT',
'packing.importAction': 'Importar {count}',
'packing.importSuccess': '{count} itens importados',
'packing.importError': 'Falha na importação',
'packing.importEmpty': 'Nenhum item para importar',
'packing.progress': '{packed} de {total} na mala ({percent}%)',
'packing.clearChecked': 'Remover {count} marcado(s)',
'packing.clearCheckedShort': 'Remover {count}',
'packing.suggestions': 'Sugestões',
'packing.suggestionsTitle': 'Adicionar sugestões',
'packing.allSuggested': 'Todas as sugestões adicionadas',
'packing.allPacked': 'Tudo na mala!',
'packing.addPlaceholder': 'Adicionar item...',
'packing.categoryPlaceholder': 'Categoria...',
'packing.saveAsTemplate': 'Salvar como modelo',
'packing.templateName': 'Nome do modelo',
'packing.templateSaved': 'Lista de bagagem salva como modelo',
'packing.filterAll': 'Todos',
'packing.filterOpen': 'Abertos',
'packing.filterDone': 'Prontos',
'packing.emptyTitle': 'A lista de mala está vazia',
'packing.emptyHint': 'Adicione itens ou use as sugestões',
'packing.emptyFiltered': 'Nenhum item corresponde ao filtro',
'packing.menuRename': 'Renomear',
'packing.menuCheckAll': 'Marcar todos',
'packing.menuUncheckAll': 'Desmarcar todos',
'packing.menuDeleteCat': 'Excluir categoria',
'packing.noMembers': 'Nenhum membro na viagem',
'packing.addItem': 'Adicionar item',
'packing.addItemPlaceholder': 'Nome do item...',
'packing.addCategory': 'Adicionar categoria',
'packing.newCategoryPlaceholder': 'Nome da categoria (ex.: Roupas)',
'packing.applyTemplate': 'Aplicar modelo',
'packing.template': 'Modelo',
'packing.templateApplied': '{count} itens adicionados do modelo',
'packing.templateError': 'Falha ao aplicar modelo',
'packing.bags': 'Malas',
'packing.noBag': 'Sem mala',
'packing.totalWeight': 'Peso total',
'packing.bagName': 'Nome da mala...',
'packing.addBag': 'Adicionar mala',
'packing.changeCategory': 'Alterar categoria',
'packing.confirm.clearChecked': 'Remover {count} item(ns) marcado(s)?',
'packing.confirm.deleteCat':
'Excluir a categoria "{name}" com {count} item(ns)?',
'packing.defaultCategory': 'Outros',
'packing.toast.saveError': 'Falha ao salvar',
'packing.toast.deleteError': 'Falha ao excluir',
'packing.toast.renameError': 'Falha ao renomear',
'packing.toast.addError': 'Falha ao adicionar',
'packing.suggestions.items': [
{
name: 'Passaporte',
category: 'Documentos',
},
{
name: 'Documento de identidade',
category: 'Documentos',
},
{
name: 'Seguro viagem',
category: 'Documentos',
},
{
name: 'Passagens aéreas',
category: 'Documentos',
},
{
name: 'Cartão de crédito',
category: 'Finanças',
},
{
name: 'Dinheiro',
category: 'Finanças',
},
{
name: 'Visto',
category: 'Documentos',
},
{
name: 'Camisetas',
category: 'Roupas',
},
{
name: 'Calças',
category: 'Roupas',
},
{
name: 'Roupa íntima',
category: 'Roupas',
},
{
name: 'Meias',
category: 'Roupas',
},
{
name: 'Jaqueta',
category: 'Roupas',
},
{
name: 'Pijama',
category: 'Roupas',
},
{
name: 'Traje de banho',
category: 'Roupas',
},
{
name: 'Capa de chuva',
category: 'Roupas',
},
{
name: 'Sapatos confortáveis',
category: 'Roupas',
},
{
name: 'Escova de dentes',
category: 'Higiene',
},
{
name: 'Creme dental',
category: 'Higiene',
},
{
name: 'Shampoo',
category: 'Higiene',
},
{
name: 'Desodorante',
category: 'Higiene',
},
{
name: 'Protetor solar',
category: 'Higiene',
},
{
name: 'Aparelho de barbear',
category: 'Higiene',
},
{
name: 'Carregador',
category: 'Eletrônicos',
},
{
name: 'Power bank',
category: 'Eletrônicos',
},
{
name: 'Fones de ouvido',
category: 'Eletrônicos',
},
{
name: 'Adaptador de viagem',
category: 'Eletrônicos',
},
{
name: 'Câmera',
category: 'Eletrônicos',
},
{
name: 'Medicamento para dor',
category: 'Saúde',
},
{
name: 'Curativos',
category: 'Saúde',
},
{
name: 'Desinfetante',
category: 'Saúde',
},
],
};
export default packing;
+10
View File
@@ -0,0 +1,10 @@
import type { TranslationStrings } from '../types';
const pdf: TranslationStrings = {
'pdf.travelPlan': 'Plano de viagem',
'pdf.planned': 'Planejado',
'pdf.costLabel': 'Custo (EUR)',
'pdf.preview': 'Pré-visualização do PDF',
'pdf.saveAsPdf': 'Salvar como PDF',
};
export default pdf;
+64
View File
@@ -0,0 +1,64 @@
import type { TranslationStrings } from '../types';
const perm: TranslationStrings = {
'perm.title': 'Configurações de Permissões',
'perm.subtitle': 'Controle quem pode realizar ações no aplicativo',
'perm.saved': 'Configurações de permissões salvas',
'perm.resetDefaults': 'Restaurar padrões',
'perm.customized': 'personalizado',
'perm.level.admin': 'Apenas administrador',
'perm.level.tripOwner': 'Dono da viagem',
'perm.level.tripMember': 'Membros da viagem',
'perm.level.everybody': 'Todos',
'perm.cat.trip': 'Gerenciamento de Viagens',
'perm.cat.members': 'Gerenciamento de Membros',
'perm.cat.files': 'Arquivos',
'perm.cat.content': 'Conteúdo e Cronograma',
'perm.cat.extras': 'Orçamento, Bagagem e Colaboração',
'perm.action.trip_create': 'Criar viagens',
'perm.action.trip_edit': 'Editar detalhes da viagem',
'perm.action.trip_delete': 'Excluir viagens',
'perm.action.trip_archive': 'Arquivar / desarquivar viagens',
'perm.action.trip_cover_upload': 'Enviar imagem de capa',
'perm.action.member_manage': 'Adicionar / remover membros',
'perm.action.file_upload': 'Enviar arquivos',
'perm.action.file_edit': 'Editar metadados do arquivo',
'perm.action.file_delete': 'Excluir arquivos',
'perm.action.place_edit': 'Adicionar / editar / excluir lugares',
'perm.action.day_edit': 'Editar dias, notas e atribuições',
'perm.action.reservation_edit': 'Gerenciar reservas',
'perm.action.budget_edit': 'Gerenciar orçamento',
'perm.action.packing_edit': 'Gerenciar listas de bagagem',
'perm.action.collab_edit': 'Colaboração (notas, enquetes, chat)',
'perm.action.share_manage': 'Gerenciar links de compartilhamento',
'perm.actionHint.trip_create': 'Quem pode criar novas viagens',
'perm.actionHint.trip_edit':
'Quem pode alterar nome, datas, descrição e moeda da viagem',
'perm.actionHint.trip_delete': 'Quem pode excluir permanentemente uma viagem',
'perm.actionHint.trip_archive':
'Quem pode arquivar ou desarquivar uma viagem',
'perm.actionHint.trip_cover_upload':
'Quem pode enviar ou alterar a imagem de capa',
'perm.actionHint.member_manage':
'Quem pode convidar ou remover membros da viagem',
'perm.actionHint.file_upload': 'Quem pode enviar arquivos para uma viagem',
'perm.actionHint.file_edit':
'Quem pode editar descrições e links dos arquivos',
'perm.actionHint.file_delete':
'Quem pode mover arquivos para a lixeira ou excluí-los permanentemente',
'perm.actionHint.place_edit':
'Quem pode adicionar, editar ou excluir lugares',
'perm.actionHint.day_edit':
'Quem pode editar dias, notas dos dias e atribuições de lugares',
'perm.actionHint.reservation_edit':
'Quem pode criar, editar ou excluir reservas',
'perm.actionHint.budget_edit':
'Quem pode criar, editar ou excluir itens do orçamento',
'perm.actionHint.packing_edit':
'Quem pode gerenciar itens de bagagem e malas',
'perm.actionHint.collab_edit':
'Quem pode criar notas, enquetes e enviar mensagens',
'perm.actionHint.share_manage':
'Quem pode criar ou excluir links de compartilhamento públicos',
};
export default perm;
+25
View File
@@ -0,0 +1,25 @@
import type { TranslationStrings } from '../types';
const photos: TranslationStrings = {
'photos.title': 'Fotos',
'photos.subtitle': '{count} fotos para {trip}',
'photos.dropHere': 'Arraste fotos aqui...',
'photos.dropHereActive': 'Arraste fotos aqui',
'photos.captionForAll': 'Legenda (para todos)',
'photos.captionPlaceholder': 'Legenda opcional...',
'photos.addCaption': 'Adicionar legenda...',
'photos.allDays': 'Todos os dias',
'photos.noPhotos': 'Nenhuma foto ainda',
'photos.uploadHint': 'Envie suas fotos de viagem',
'photos.clickToSelect': 'ou clique para selecionar',
'photos.linkPlace': 'Vincular lugar',
'photos.noPlace': 'Sem lugar',
'photos.uploadN': 'Enviar {n} foto(s)',
'photos.linkDay': 'Vincular dia',
'photos.noDay': 'Nenhum dia',
'photos.dayLabel': 'Dia {number}',
'photos.photoSelected': 'Foto selecionada',
'photos.photosSelected': 'Fotos selecionadas',
'photos.fileTypeHint': 'JPG, PNG, WebP · máx. 10 MB · até 30 fotos',
};
export default photos;
+97
View File
@@ -0,0 +1,97 @@
import type { TranslationStrings } from '../types';
const places: TranslationStrings = {
'places.addPlace': 'Adicionar lugar/atividade',
'places.importFile': 'Importar arquivo',
'places.sidebarDrop': 'Solte para importar',
'places.importFileHint':
'Importe arquivos .gpx, .kml ou .kmz de ferramentas como Google My Maps, Google Earth ou um rastreador GPS.',
'places.importFileDropHere':
'Clique para selecionar um arquivo ou arraste e solte aqui',
'places.importFileDropActive': 'Solte o arquivo para selecionar',
'places.importFileUnsupported':
'Tipo de arquivo não suportado. Use .gpx, .kml ou .kmz.',
'places.importFileTooLarge':
'O arquivo é muito grande. O tamanho máximo de upload é {maxMb} MB.',
'places.importFileError': 'Importação falhou',
'places.importAllSkipped': 'Todos os lugares já estavam na viagem.',
'places.gpxImported': '{count} lugares importados do GPX',
'places.gpxImportTypes': 'O que deseja importar?',
'places.gpxImportWaypoints': 'Pontos de caminho',
'places.gpxImportRoutes': 'Rotas',
'places.gpxImportTracks': 'Trilhas (com geometria de percurso)',
'places.gpxImportNoneSelected': 'Selecione pelo menos um tipo para importar.',
'places.kmlImportTypes': 'O que deseja importar?',
'places.kmlImportPoints': 'Pontos (Placemarks)',
'places.kmlImportPaths': 'Caminhos (LineStrings)',
'places.kmlImportNoneSelected': 'Selecione pelo menos um tipo.',
'places.selectionCount': '{count} selecionado(s)',
'places.deleteSelected': 'Excluir seleção',
'places.kmlKmzImported': '{count} lugares importados de KMZ/KML',
'places.urlResolved': 'Lugar importado da URL',
'places.importList': 'Importar lista',
'places.kmlKmzSummaryValues':
'Placemarks: {total} • Importados: {created} • Ignorados: {skipped}',
'places.importGoogleList': 'Lista Google',
'places.importNaverList': 'Lista Naver',
'places.googleListHint':
'Cole um link compartilhado de uma lista do Google Maps para importar todos os lugares.',
'places.googleListImported': '{count} lugares importados de "{list}"',
'places.googleListError': 'Falha ao importar lista do Google Maps',
'places.naverListHint':
'Cole um link compartilhado de uma lista do Naver Maps para importar todos os lugares.',
'places.naverListImported': '{count} lugares importados de "{list}"',
'places.naverListError': 'Falha ao importar lista do Naver Maps',
'places.viewDetails': 'Ver detalhes',
'places.assignToDay': 'Adicionar a qual dia?',
'places.all': 'Todos',
'places.unplanned': 'Não planejados',
'places.filterTracks': 'Trilhas',
'places.search': 'Buscar lugares...',
'places.allCategories': 'Todas as categorias',
'places.categoriesSelected': 'categorias',
'places.clearFilter': 'Limpar filtro',
'places.count': '{count} lugares',
'places.countSingular': '1 lugar',
'places.allPlanned': 'Todos os lugares estão planejados',
'places.noneFound': 'Nenhum lugar encontrado',
'places.editPlace': 'Editar lugar',
'places.formName': 'Nome',
'places.formNamePlaceholder': 'ex.: Torre Eiffel',
'places.formDescription': 'Descrição',
'places.formDescriptionPlaceholder': 'Breve descrição...',
'places.formAddress': 'Endereço',
'places.formAddressPlaceholder': 'Rua, cidade, país',
'places.formLat': 'Latitude (ex.: -23.5505)',
'places.formLng': 'Longitude (ex.: -46.6333)',
'places.formCategory': 'Categoria',
'places.noCategory': 'Sem categoria',
'places.categoryNamePlaceholder': 'Nome da categoria',
'places.formTime': 'Horário',
'places.startTime': 'Início',
'places.endTime': 'Fim',
'places.endTimeBeforeStart': 'O horário de fim é antes do início',
'places.timeCollision': 'Sobreposição de horário com:',
'places.formWebsite': 'Site',
'places.formNotes': 'Notas',
'places.formNotesPlaceholder': 'Notas pessoais...',
'places.formReservation': 'Reserva',
'places.reservationNotesPlaceholder':
'Notas da reserva, código de confirmação...',
'places.mapsSearchPlaceholder': 'Buscar lugares...',
'places.mapsSearchError': 'Falha na busca de lugares.',
'places.loadingDetails': 'Carregando detalhes do lugar…',
'places.osmHint':
'Busca via OpenStreetMap (sem fotos, horários ou avaliações). Adicione uma chave Google nas configurações para detalhes completos.',
'places.osmActive':
'Busca via OpenStreetMap (sem fotos, avaliações ou horário de funcionamento). Adicione uma chave Google em Configurações para mais dados.',
'places.categoryCreateError': 'Falha ao criar categoria',
'places.nameRequired': 'Digite um nome',
'places.saveError': 'Falha ao salvar',
'places.duplicateExists': "'{name}' já está nesta viagem.",
'places.addAnyway': 'Adicionar mesmo assim',
'places.enrichOnImport': 'Enriquecer lugares via Google',
'places.enrichOnImportHint':
'Busca cada lugar importado para adicionar fotos, endereço e contato. Usa sua chave do Google Maps.',
};
export default places;
+69
View File
@@ -0,0 +1,69 @@
import type { TranslationStrings } from '../types';
const planner: TranslationStrings = {
'planner.places': 'Lugares',
'planner.bookings': 'Reservas',
'planner.packingList': 'Lista de mala',
'planner.documents': 'Documentos',
'planner.dayPlan': 'Plano do dia',
'planner.reservations': 'Reservas',
'planner.minTwoPlaces':
'São necessários pelo menos 2 lugares com coordenadas',
'planner.noGeoPlaces': 'Nenhum lugar com coordenadas disponível',
'planner.routeCalculated': 'Rota calculada',
'planner.routeCalcFailed': 'Não foi possível calcular a rota',
'planner.routeError': 'Erro ao calcular a rota',
'planner.icsExportFailed': 'Falha ao exportar ICS',
'planner.routeOptimized': 'Rota otimizada',
'planner.reservationUpdated': 'Reserva atualizada',
'planner.reservationAdded': 'Reserva adicionada',
'planner.confirmDeleteReservation': 'Excluir reserva?',
'planner.reservationDeleted': 'Reserva excluída',
'planner.days': 'Dias',
'planner.allPlaces': 'Todos os lugares',
'planner.totalPlaces': '{n} lugares no total',
'planner.noDaysPlanned': 'Nenhum dia planejado ainda',
'planner.editTrip': 'Editar viagem →',
'planner.placeOne': '1 lugar',
'planner.placeN': '{n} lugares',
'planner.addNote': 'Adicionar nota',
'planner.noEntries': 'Nenhuma entrada neste dia',
'planner.addPlace': 'Adicionar lugar/atividade',
'planner.addPlaceShort': '+ Adicionar lugar/atividade',
'planner.resPending': 'Reserva pendente · ',
'planner.resConfirmed': 'Reserva confirmada · ',
'planner.notePlaceholder': 'Nota…',
'planner.noteTimePlaceholder': 'Horário (opcional)',
'planner.noteExamplePlaceholder':
'ex.: metrô às 14:30 da estação central, barco do cais 7, pausa para almoço…',
'planner.totalCost': 'Custo total',
'planner.searchPlaces': 'Buscar lugares…',
'planner.allCategories': 'Todas as categorias',
'planner.noPlacesFound': 'Nenhum lugar encontrado',
'planner.addFirstPlace': 'Adicionar primeiro lugar',
'planner.noReservations': 'Nenhuma reserva',
'planner.addFirstReservation': 'Adicionar primeira reserva',
'planner.new': 'Novo',
'planner.addToDay': '+ Dia',
'planner.calculating': 'Calculando…',
'planner.route': 'Rota',
'planner.optimize': 'Otimizar',
'planner.openGoogleMaps': 'Abrir no Google Maps',
'planner.selectDayHint':
'Selecione um dia na lista à esquerda para ver o plano do dia',
'planner.noPlacesForDay': 'Nenhum lugar neste dia ainda',
'planner.addPlacesLink': 'Adicionar lugares →',
'planner.minTotal': 'mín. total',
'planner.noReservation': 'Sem reserva',
'planner.removeFromDay': 'Remover do dia',
'planner.addToThisDay': 'Adicionar ao dia',
'planner.overview': 'Visão geral',
'planner.noDays': 'Nenhum dia ainda',
'planner.editTripToAddDays': 'Edite a viagem para adicionar dias',
'planner.dayCount': '{n} dias',
'planner.clickToUnlock': 'Clique para desbloquear',
'planner.keepPosition': 'Manter posição durante a otimização da rota',
'planner.dayDetails': 'Detalhes do dia',
'planner.dayN': 'Dia {n}',
};
export default planner;

Some files were not shown because too many files have changed in this diff Show More