mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 22:31:46 +00:00
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 commit67cf290cda. * Revert "fix(ci): move ACT guards to step level; add guards to security.yml" This reverts commitf92b95e054. * Revert "chore(ci): add ACT guards to skip DockerHub steps in local act runs" This reverts commit797183de08. * 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 commit0936103f04. * 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:
@@ -0,0 +1,402 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const admin: TranslationStrings = {
|
||||
'admin.notifications.title': 'Notificaciones',
|
||||
'admin.notifications.hint':
|
||||
'Elija un canal de notificación. Solo uno puede estar activo a la vez.',
|
||||
'admin.notifications.none': 'Desactivado',
|
||||
'admin.notifications.email': 'Correo (SMTP)',
|
||||
'admin.notifications.webhook': 'Webhook',
|
||||
'admin.notifications.save': 'Guardar configuración de notificaciones',
|
||||
'admin.notifications.saved': 'Configuración de notificaciones guardada',
|
||||
'admin.notifications.testWebhook': 'Enviar webhook de prueba',
|
||||
'admin.notifications.testWebhookSuccess':
|
||||
'Webhook de prueba enviado correctamente',
|
||||
'admin.notifications.testWebhookFailed': 'Error al enviar webhook de prueba',
|
||||
'admin.smtp.title': 'Correo y notificaciones',
|
||||
'admin.smtp.hint':
|
||||
'Configuración SMTP para el envío de notificaciones por correo.',
|
||||
'admin.smtp.testButton': 'Enviar correo de prueba',
|
||||
'admin.webhook.hint':
|
||||
'Enviar notificaciones a un webhook externo (Discord, Slack, etc.).',
|
||||
'admin.smtp.testSuccess': 'Correo de prueba enviado correctamente',
|
||||
'admin.smtp.testFailed': 'Error al enviar correo de prueba',
|
||||
'admin.title': 'Administración',
|
||||
'admin.subtitle': 'Gestión de usuarios y ajustes del sistema',
|
||||
'admin.tabs.users': 'Usuarios',
|
||||
'admin.tabs.categories': 'Categorías',
|
||||
'admin.tabs.backup': 'Copia de seguridad',
|
||||
'admin.tabs.audit': 'Auditoría',
|
||||
'admin.stats.users': 'Usuarios',
|
||||
'admin.stats.trips': 'Viajes',
|
||||
'admin.stats.places': 'Lugares',
|
||||
'admin.stats.photos': 'Fotos',
|
||||
'admin.stats.files': 'Archivos',
|
||||
'admin.table.user': 'Usuario',
|
||||
'admin.table.email': 'Correo',
|
||||
'admin.table.role': 'Rol',
|
||||
'admin.table.created': 'Creado',
|
||||
'admin.table.lastLogin': 'Último acceso',
|
||||
'admin.table.actions': 'Acciones',
|
||||
'admin.you': '(Tú)',
|
||||
'admin.editUser': 'Editar usuario',
|
||||
'admin.newPassword': 'Nueva contraseña',
|
||||
'admin.newPasswordHint': 'Déjalo vacío para mantener la contraseña actual',
|
||||
'admin.deleteUser':
|
||||
'¿Eliminar al usuario "{name}"? Todos sus viajes se borrarán permanentemente.',
|
||||
'admin.deleteUserTitle': 'Eliminar usuario',
|
||||
'admin.newPasswordPlaceholder': 'Introduce una nueva contraseña…',
|
||||
'admin.toast.loadError': 'No se pudieron cargar los datos de administración',
|
||||
'admin.toast.userUpdated': 'Usuario actualizado',
|
||||
'admin.toast.updateError': 'No se pudo actualizar',
|
||||
'admin.toast.userDeleted': 'Usuario eliminado',
|
||||
'admin.toast.deleteError': 'No se pudo eliminar',
|
||||
'admin.toast.cannotDeleteSelf': 'No puedes eliminar tu propia cuenta',
|
||||
'admin.toast.userCreated': 'Usuario creado',
|
||||
'admin.toast.createError': 'No se pudo crear el usuario',
|
||||
'admin.toast.fieldsRequired': 'Usuario, correo y contraseña son obligatorios',
|
||||
'admin.createUser': 'Crear usuario',
|
||||
'admin.invite.title': 'Enlaces de invitación',
|
||||
'admin.invite.subtitle': 'Crear enlaces de registro de un solo uso',
|
||||
'admin.invite.create': 'Crear enlace',
|
||||
'admin.invite.createAndCopy': 'Crear y copiar',
|
||||
'admin.invite.empty': 'No se han creado enlaces de invitación',
|
||||
'admin.invite.maxUses': 'Usos máx.',
|
||||
'admin.invite.expiry': 'Expira después de',
|
||||
'admin.invite.uses': 'usado(s)',
|
||||
'admin.invite.expiresAt': 'expira el',
|
||||
'admin.invite.createdBy': 'por',
|
||||
'admin.invite.active': 'Activo',
|
||||
'admin.invite.expired': 'Expirado',
|
||||
'admin.invite.usedUp': 'Agotado',
|
||||
'admin.invite.copied': 'Enlace de invitación copiado',
|
||||
'admin.invite.copyLink': 'Copiar enlace',
|
||||
'admin.invite.deleted': 'Enlace de invitación eliminado',
|
||||
'admin.invite.createError': 'Error al crear el enlace',
|
||||
'admin.invite.deleteError': 'Error al eliminar el enlace',
|
||||
'admin.tabs.settings': 'Ajustes',
|
||||
'admin.allowRegistration': 'Permitir el registro',
|
||||
'admin.allowRegistrationHint':
|
||||
'Los nuevos usuarios pueden registrarse por sí mismos',
|
||||
'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 autenticación en dos factores (2FA)',
|
||||
'admin.requireMfaHint':
|
||||
'Los usuarios sin 2FA deben completar la configuración en Ajustes antes de usar la aplicación.',
|
||||
'admin.apiKeys': 'Claves API',
|
||||
'admin.apiKeysHint':
|
||||
'Opcional. Activa datos ampliados de lugares, como fotos y previsión del tiempo.',
|
||||
'admin.mapsKey': 'Clave API de Google Maps',
|
||||
'admin.mapsKeyHint':
|
||||
'Obligatoria para buscar lugares. Consíguela en console.cloud.google.com',
|
||||
'admin.mapsKeyHintLong':
|
||||
'Sin una clave API, la búsqueda de lugares usa OpenStreetMap. Con una clave de Google también se pueden cargar fotos, valoraciones y horarios de apertura. Consíguela en console.cloud.google.com.',
|
||||
'admin.recommended': 'Recomendado',
|
||||
'admin.weatherKey': 'Clave API de OpenWeatherMap',
|
||||
'admin.weatherKeyHint':
|
||||
'Para datos meteorológicos. Gratis en openweathermap.org',
|
||||
'admin.validateKey': 'Probar',
|
||||
'admin.keyValid': 'Conectado',
|
||||
'admin.keyInvalid': 'No válida',
|
||||
'admin.keySaved': 'Claves API guardadas',
|
||||
'admin.oidcTitle': 'Inicio de sesión único (OIDC)',
|
||||
'admin.oidcSubtitle':
|
||||
'Permite iniciar sesión mediante proveedores externos como Google, Apple, Authentik o Keycloak.',
|
||||
'admin.oidcDisplayName': 'Nombre visible',
|
||||
'admin.oidcIssuer': 'URL del emisor',
|
||||
'admin.oidcIssuerHint':
|
||||
'La URL Issuer de OpenID Connect del proveedor. Ej.: https://accounts.google.com',
|
||||
'admin.oidcSaved': 'Configuración OIDC guardada',
|
||||
'admin.fileTypes': 'Tipos de archivo permitidos',
|
||||
'admin.fileTypesHint':
|
||||
'Configura qué tipos de archivo pueden subir los usuarios.',
|
||||
'admin.fileTypesFormat':
|
||||
'Extensiones separadas por comas (p. ej. jpg,png,pdf,doc). Usa * para permitir todos los tipos.',
|
||||
'admin.fileTypesSaved': 'Ajustes de tipos de archivo guardados',
|
||||
'admin.placesPhotos.title': 'Fotos de Lugares',
|
||||
'admin.placesPhotos.subtitle':
|
||||
'Obtiene fotos de la Google Places API. Desactiva para ahorrar cuota de API. Las fotos de Wikimedia no se ven afectadas.',
|
||||
'admin.placesAutocomplete.title': 'Autocompletado de Lugares',
|
||||
'admin.placesAutocomplete.subtitle':
|
||||
'Usa la Google Places API para sugerencias de búsqueda. Desactiva para ahorrar cuota de API.',
|
||||
'admin.placesDetails.title': 'Detalles del Lugar',
|
||||
'admin.placesDetails.subtitle':
|
||||
'Obtiene información detallada del lugar (horarios, valoración, web) de la Google Places API. Desactiva para ahorrar cuota de API.',
|
||||
'admin.bagTracking.title': 'Seguimiento de equipaje',
|
||||
'admin.bagTracking.subtitle':
|
||||
'Activar peso y asignación de equipaje para artículos de la lista',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
'admin.collab.chat.subtitle':
|
||||
'Mensajería en tiempo real para la colaboración',
|
||||
'admin.collab.notes.title': 'Notas',
|
||||
'admin.collab.notes.subtitle': 'Notas y documentos compartidos',
|
||||
'admin.collab.polls.title': 'Encuestas',
|
||||
'admin.collab.polls.subtitle': 'Encuestas y votaciones grupales',
|
||||
'admin.collab.whatsnext.title': 'Qué sigue',
|
||||
'admin.collab.whatsnext.subtitle':
|
||||
'Sugerencias de actividades y próximos pasos',
|
||||
'admin.tabs.config': 'Personalización',
|
||||
'admin.tabs.defaults': 'Valores predeterminados',
|
||||
'admin.defaultSettings.title': 'Configuración predeterminada de usuarios',
|
||||
'admin.defaultSettings.description':
|
||||
'Establece valores predeterminados para toda la instancia. Los usuarios que no hayan cambiado una opción verán estos valores. Sus propios cambios siempre tienen prioridad.',
|
||||
'admin.defaultSettings.saved': 'Predeterminado guardado',
|
||||
'admin.defaultSettings.reset': 'Restaurar al valor predeterminado integrado',
|
||||
'admin.defaultSettings.resetToBuiltIn': 'restaurar',
|
||||
'admin.tabs.templates': 'Plantillas de equipaje',
|
||||
'admin.packingTemplates.title': 'Plantillas de equipaje',
|
||||
'admin.packingTemplates.subtitle':
|
||||
'Crear listas de equipaje reutilizables para tus viajes',
|
||||
'admin.packingTemplates.create': 'Nueva plantilla',
|
||||
'admin.packingTemplates.namePlaceholder':
|
||||
'Nombre de la plantilla (ej. Vacaciones en la playa)',
|
||||
'admin.packingTemplates.empty': 'No se han creado plantillas aún',
|
||||
'admin.packingTemplates.items': 'artículos',
|
||||
'admin.packingTemplates.categories': 'categorías',
|
||||
'admin.packingTemplates.itemName': 'Nombre del artículo',
|
||||
'admin.packingTemplates.itemCategory': 'Categoría',
|
||||
'admin.packingTemplates.categoryName': 'Nombre de categoría (ej. Ropa)',
|
||||
'admin.packingTemplates.addCategory': 'Añadir categoría',
|
||||
'admin.packingTemplates.created': 'Plantilla creada',
|
||||
'admin.packingTemplates.deleted': 'Plantilla eliminada',
|
||||
'admin.packingTemplates.loadError': 'Error al cargar plantillas',
|
||||
'admin.packingTemplates.createError': 'Error al crear plantilla',
|
||||
'admin.packingTemplates.deleteError': 'Error al eliminar plantilla',
|
||||
'admin.packingTemplates.saveError': 'Error al guardar',
|
||||
'admin.tabs.addons': 'Complementos',
|
||||
'admin.addons.title': 'Complementos',
|
||||
'admin.addons.subtitle':
|
||||
'Activa o desactiva funciones para personalizar tu experiencia en TREK.',
|
||||
'admin.addons.subtitleBefore':
|
||||
'Activa o desactiva funciones para personalizar tu experiencia en ',
|
||||
'admin.addons.subtitleAfter': '.',
|
||||
'admin.addons.enabled': 'Activo',
|
||||
'admin.addons.disabled': 'Desactivado',
|
||||
'admin.addons.type.trip': 'Viaje',
|
||||
'admin.addons.type.global': 'Global',
|
||||
'admin.addons.type.integration': 'Integración',
|
||||
'admin.addons.tripHint': 'Disponible como pestaña dentro de cada viaje',
|
||||
'admin.addons.globalHint':
|
||||
'Disponible como sección independiente en la navegación principal',
|
||||
'admin.addons.integrationHint':
|
||||
'Servicios backend e integraciones de API sin página dedicada',
|
||||
'admin.addons.toast.updated': 'Complemento actualizado',
|
||||
'admin.addons.toast.error': 'No se pudo actualizar el complemento',
|
||||
'admin.addons.noAddons': 'No hay complementos disponibles',
|
||||
'admin.weather.title': 'Datos meteorológicos',
|
||||
'admin.weather.badge': 'Desde el 24 de marzo de 2026',
|
||||
'admin.weather.description':
|
||||
'TREK utiliza Open-Meteo como fuente de datos meteorológicos. Open-Meteo es un servicio meteorológico gratuito y de código abierto: no requiere clave API.',
|
||||
'admin.weather.forecast': 'Pronóstico de 16 días',
|
||||
'admin.weather.forecastDesc': 'Antes eran 5 días (OpenWeatherMap)',
|
||||
'admin.weather.climate': 'Datos climáticos históricos',
|
||||
'admin.weather.climateDesc':
|
||||
'Promedios de los últimos 85 años para fechas posteriores al pronóstico de 16 días',
|
||||
'admin.weather.requests': '10.000 solicitudes / día',
|
||||
'admin.weather.requestsDesc': 'Gratis, sin necesidad de clave API',
|
||||
'admin.weather.locationHint':
|
||||
'El tiempo se basa en el primer lugar con coordenadas de cada día. Si no hay ningún lugar asignado a un día, se usa como referencia cualquier lugar de la lista.',
|
||||
'admin.tabs.mcpTokens': 'Acceso MCP',
|
||||
'admin.mcpTokens.title': 'Acceso MCP',
|
||||
'admin.mcpTokens.subtitle':
|
||||
'Gestionar sesiones OAuth y tokens de API de todos los usuarios',
|
||||
'admin.mcpTokens.sectionTitle': 'Tokens de API',
|
||||
'admin.mcpTokens.owner': 'Propietario',
|
||||
'admin.mcpTokens.tokenName': 'Nombre del token',
|
||||
'admin.mcpTokens.created': 'Creado',
|
||||
'admin.mcpTokens.lastUsed': 'Último uso',
|
||||
'admin.mcpTokens.never': 'Nunca',
|
||||
'admin.mcpTokens.empty': 'Aún no se han creado tokens MCP',
|
||||
'admin.mcpTokens.deleteTitle': 'Eliminar token',
|
||||
'admin.mcpTokens.deleteMessage':
|
||||
'Este token se revocará inmediatamente. El usuario perderá el acceso MCP a través de este token.',
|
||||
'admin.mcpTokens.deleteSuccess': 'Token eliminado',
|
||||
'admin.mcpTokens.deleteError': 'No se pudo eliminar el token',
|
||||
'admin.mcpTokens.loadError': 'No se pudieron cargar los tokens',
|
||||
'admin.oauthSessions.sectionTitle': 'Sesiones OAuth',
|
||||
'admin.oauthSessions.clientName': 'Cliente',
|
||||
'admin.oauthSessions.owner': 'Propietario',
|
||||
'admin.oauthSessions.scopes': 'Permisos',
|
||||
'admin.oauthSessions.created': 'Creado',
|
||||
'admin.oauthSessions.empty': 'No hay sesiones OAuth activas',
|
||||
'admin.oauthSessions.revokeTitle': 'Revocar sesión',
|
||||
'admin.oauthSessions.revokeMessage':
|
||||
'Esto revocará la sesión OAuth inmediatamente. El cliente perderá el acceso MCP.',
|
||||
'admin.oauthSessions.revokeSuccess': 'Sesión revocada',
|
||||
'admin.oauthSessions.revokeError': 'No se pudo revocar la sesión',
|
||||
'admin.oauthSessions.loadError': 'No se pudieron cargar las sesiones OAuth',
|
||||
'admin.tabs.github': 'GitHub',
|
||||
'admin.audit.subtitle':
|
||||
'Eventos sensibles de seguridad y administración (copias de seguridad, usuarios, MFA, ajustes).',
|
||||
'admin.audit.empty': 'Aún no hay entradas de auditoría.',
|
||||
'admin.audit.refresh': 'Actualizar',
|
||||
'admin.audit.loadMore': 'Cargar más',
|
||||
'admin.audit.showing': '{count} cargados · {total} en total',
|
||||
'admin.audit.col.time': 'Fecha y hora',
|
||||
'admin.audit.col.user': 'Usuario',
|
||||
'admin.audit.col.action': 'Acción',
|
||||
'admin.audit.col.resource': 'Recurso',
|
||||
'admin.audit.col.ip': 'IP',
|
||||
'admin.audit.col.details': 'Detalles',
|
||||
'admin.github.title': 'Historial de versiones',
|
||||
'admin.github.subtitle': 'Últimas novedades de {repo}',
|
||||
'admin.github.latest': 'Última',
|
||||
'admin.github.prerelease': 'Prelanzamiento',
|
||||
'admin.github.showDetails': 'Mostrar detalles',
|
||||
'admin.github.hideDetails': 'Ocultar detalles',
|
||||
'admin.github.loadMore': 'Cargar más',
|
||||
'admin.github.loading': 'Cargando...',
|
||||
'admin.github.support': 'Ayuda a seguir desarrollando TREK',
|
||||
'admin.github.error': 'No se pudieron cargar las versiones',
|
||||
'admin.github.by': 'por',
|
||||
'admin.update.available': 'Actualización disponible',
|
||||
'admin.update.text':
|
||||
'TREK {version} está disponible. Estás usando {current}.',
|
||||
'admin.update.button': 'Ver en GitHub',
|
||||
'admin.update.install': 'Instalar actualización',
|
||||
'admin.update.confirmTitle': '¿Instalar actualización?',
|
||||
'admin.update.confirmText':
|
||||
'TREK se actualizará de {current} a {version}. Después, el servidor se reiniciará automáticamente.',
|
||||
'admin.update.dataInfo':
|
||||
'Todos tus datos (viajes, usuarios, claves API, subidas, Vacay, Atlas, presupuestos) se conservarán.',
|
||||
'admin.update.warning':
|
||||
'La app estará brevemente no disponible durante el reinicio.',
|
||||
'admin.update.confirm': 'Actualizar ahora',
|
||||
'admin.update.installing': 'Actualizando…',
|
||||
'admin.update.success':
|
||||
'¡Actualización instalada! El servidor se está reiniciando…',
|
||||
'admin.update.failed': 'La actualización falló',
|
||||
'admin.update.backupHint':
|
||||
'Recomendamos crear una copia de seguridad antes de actualizar.',
|
||||
'admin.update.backupLink': 'Ir a Copia de seguridad',
|
||||
'admin.update.howTo': 'Cómo actualizar',
|
||||
'admin.update.dockerText':
|
||||
'Tu instancia de TREK se ejecuta en Docker. Para actualizar a {version}, ejecuta los siguientes comandos en tu servidor:',
|
||||
'admin.update.reloadHint': 'Recarga la página en unos segundos.',
|
||||
'admin.addons.catalog.memories.name': 'Fotos (Immich)',
|
||||
'admin.addons.catalog.memories.description':
|
||||
'Comparte fotos de viaje a través de tu instancia de Immich',
|
||||
'admin.addons.catalog.mcp.name': 'MCP',
|
||||
'admin.addons.catalog.mcp.description':
|
||||
'Protocolo de contexto de modelo para integración con asistentes de IA',
|
||||
'admin.addons.catalog.packing.name': 'Listas',
|
||||
'admin.addons.catalog.packing.description':
|
||||
'Listas de equipaje y tareas pendientes para tus viajes',
|
||||
'admin.addons.catalog.budget.name': 'Presupuesto',
|
||||
'admin.addons.catalog.budget.description':
|
||||
'Controla los gastos y planifica el presupuesto del viaje',
|
||||
'admin.addons.catalog.documents.name': 'Documentos',
|
||||
'admin.addons.catalog.documents.description':
|
||||
'Guarda y gestiona la documentación del viaje',
|
||||
'admin.addons.catalog.vacay.name': 'Vacaciones',
|
||||
'admin.addons.catalog.vacay.description':
|
||||
'Planificador personal de vacaciones con vista de calendario',
|
||||
'admin.addons.catalog.atlas.name': 'Atlas',
|
||||
'admin.addons.catalog.atlas.description':
|
||||
'Mapa del mundo con los países visitados y estadísticas de viaje',
|
||||
'admin.addons.catalog.collab.name': 'Colaboración',
|
||||
'admin.addons.catalog.collab.description':
|
||||
'Notas, encuestas y chat en tiempo real para organizar el viaje',
|
||||
'admin.oidcOnlyMode': 'Desactivar autenticación por contraseña',
|
||||
'admin.oidcOnlyModeHint':
|
||||
'Si está activado, solo se permite el inicio de sesión con SSO. El inicio de sesión y registro con contraseña se bloquean.',
|
||||
'admin.tabs.permissions': 'Permisos',
|
||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||
'admin.notifications.inappPanel.title': 'In-App',
|
||||
'admin.notifications.inappPanel.hint':
|
||||
'Las notificaciones in-app siempre están activas y no se pueden desactivar globalmente.',
|
||||
'admin.notifications.adminWebhookPanel.title': 'Webhook de admin',
|
||||
'admin.notifications.adminWebhookPanel.hint':
|
||||
'Este webhook se usa exclusivamente para notificaciones de admin (ej. alertas de versión). Es independiente de los webhooks de usuario y se activa automáticamente si hay una URL configurada.',
|
||||
'admin.notifications.adminWebhookPanel.saved':
|
||||
'URL del webhook de admin guardada',
|
||||
'admin.notifications.adminWebhookPanel.testSuccess':
|
||||
'Webhook de prueba enviado correctamente',
|
||||
'admin.notifications.adminWebhookPanel.testFailed':
|
||||
'Error al enviar el webhook de prueba',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint':
|
||||
'El webhook de admin se activa automáticamente si hay una URL configurada',
|
||||
'admin.notifications.ntfy': 'Ntfy',
|
||||
'admin.ntfy.hint':
|
||||
'Permite a los usuarios configurar sus propios temas ntfy para notificaciones push. Establece el servidor predeterminado a continuación para rellenar automáticamente los ajustes del usuario.',
|
||||
'admin.notifications.testNtfy': 'Enviar Ntfy de prueba',
|
||||
'admin.notifications.testNtfySuccess': 'Ntfy de prueba enviado correctamente',
|
||||
'admin.notifications.testNtfyFailed': 'Error al enviar el Ntfy de prueba',
|
||||
'admin.notifications.adminNtfyPanel.title': 'Ntfy de admin',
|
||||
'admin.notifications.adminNtfyPanel.hint':
|
||||
'Este tema Ntfy se usa exclusivamente para notificaciones de admin (ej. alertas de versión). Es independiente de los temas por usuario y siempre se activa cuando está configurado.',
|
||||
'admin.notifications.adminNtfyPanel.serverLabel': 'URL del servidor Ntfy',
|
||||
'admin.notifications.adminNtfyPanel.serverHint':
|
||||
'También se usa como servidor predeterminado para las notificaciones ntfy de los usuarios. Déjalo en blanco para usar ntfy.sh. Los usuarios pueden cambiarlo en sus propios ajustes.',
|
||||
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||
'admin.notifications.adminNtfyPanel.topicLabel': 'Tema de admin',
|
||||
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||
'admin.notifications.adminNtfyPanel.tokenLabel': 'Token de acceso (opcional)',
|
||||
'admin.notifications.adminNtfyPanel.tokenCleared':
|
||||
'Token de acceso de admin eliminado',
|
||||
'admin.notifications.adminNtfyPanel.saved':
|
||||
'Configuración de Ntfy de admin guardada',
|
||||
'admin.notifications.adminNtfyPanel.test': 'Enviar Ntfy de prueba',
|
||||
'admin.notifications.adminNtfyPanel.testSuccess':
|
||||
'Ntfy de prueba enviado correctamente',
|
||||
'admin.notifications.adminNtfyPanel.testFailed':
|
||||
'Error al enviar el Ntfy de prueba',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint':
|
||||
'El Ntfy de admin siempre se activa cuando hay un tema configurado',
|
||||
'admin.notifications.adminNotificationsHint':
|
||||
'Configura qué canales entregan notificaciones de admin (ej. alertas de versión). El webhook se activa automáticamente si hay una URL de webhook de admin configurada.',
|
||||
'admin.notifications.tripReminders.title': 'Recordatorios de viaje',
|
||||
'admin.notifications.tripReminders.hint':
|
||||
'Envía una notificación de recordatorio antes de que comience un viaje (requiere días de recordatorio configurados en el viaje).',
|
||||
'admin.notifications.tripReminders.enabled':
|
||||
'Recordatorios de viaje activados',
|
||||
'admin.notifications.tripReminders.disabled':
|
||||
'Recordatorios de viaje desactivados',
|
||||
'admin.tabs.notifications': 'Notificaciones',
|
||||
'admin.addons.catalog.journey.name': 'Travesía',
|
||||
'admin.addons.catalog.journey.description':
|
||||
'Seguimiento de viajes y diario de viajero con registros de ubicación, fotos e historias diarias',
|
||||
'admin.passkey.title': 'Inicio de sesión con passkey',
|
||||
'admin.passkey.cardHint':
|
||||
'Permite que los usuarios inicien sesión con passkeys (WebAuthn). Desactivado de forma predeterminada.',
|
||||
'admin.passkey.login': 'Activar inicio de sesión con passkey',
|
||||
'admin.passkey.loginHint':
|
||||
'Muestra una opción "Iniciar sesión con una passkey" y permite a los usuarios registrar passkeys en sus ajustes.',
|
||||
'admin.passkey.notConfigured':
|
||||
'Aún no se resuelve ningún dominio de WebAuthn para esta instalación. Define APP_URL o el Relying Party ID a continuación: las passkeys permanecerán ocultas hasta entonces.',
|
||||
'admin.passkey.rpId': 'Relying Party ID (dominio)',
|
||||
'admin.passkey.rpIdHint':
|
||||
'El dominio puro al que se vinculan las passkeys, p. ej. trek.example.org. Déjalo vacío para derivarlo de APP_URL. Cambiarlo más adelante invalida las passkeys existentes.',
|
||||
'admin.passkey.origins': 'Orígenes permitidos',
|
||||
'admin.passkey.originsHint':
|
||||
'Orígenes completos separados por comas, p. ej. https://trek.example.org. Déjalo vacío para usar APP_URL.',
|
||||
'admin.passkey.reset': 'Restablecer passkeys',
|
||||
'admin.passkey.resetHint':
|
||||
'Elimina todas las passkeys de este usuario (p. ej. tras perder un dispositivo). Aún podrá iniciar sesión con su contraseña.',
|
||||
'admin.passkey.resetConfirm': '¿Eliminar todas las passkeys de {name}?',
|
||||
'admin.passkey.resetDone': 'Se eliminaron {count} passkey(s)',
|
||||
'admin.defaultSettings.mapProvider': 'Motor de mapas',
|
||||
'admin.defaultSettings.mapProviderHint': 'El mapa predeterminado para todos en esta instancia. Cada usuario puede cambiarlo en sus propios ajustes.',
|
||||
'admin.defaultSettings.providerLeaflet': 'Estándar (gratis)',
|
||||
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
|
||||
'admin.defaultSettings.mapboxToken': 'Token de Mapbox compartido',
|
||||
'admin.defaultSettings.mapboxTokenHint': 'Se usa para cada usuario que no haya introducido su propio token, de modo que toda la instancia obtenga Mapbox sin compartir la clave individualmente. Se almacena cifrado.',
|
||||
'admin.defaultSettings.mapboxStyle': 'Estilo de mapa',
|
||||
'admin.defaultSettings.mapboxStylePlaceholder': 'Elige un estilo…',
|
||||
'admin.defaultSettings.mapbox3d': 'Edificios y terreno en 3D',
|
||||
'admin.defaultSettings.mapboxQuality': 'Modo de alta calidad',
|
||||
};
|
||||
export default admin;
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const airport: TranslationStrings = {
|
||||
'airport.searchPlaceholder': 'Código o ciudad del aeropuerto (ej. FRA)',
|
||||
};
|
||||
export default airport;
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const atlas: TranslationStrings = {
|
||||
'atlas.subtitle': 'Tu huella viajera por el mundo',
|
||||
'atlas.countries': 'Países',
|
||||
'atlas.trips': 'Viajes',
|
||||
'atlas.places': 'Lugares',
|
||||
'atlas.days': 'Días',
|
||||
'atlas.visitedCountries': 'Países visitados',
|
||||
'atlas.cities': 'Ciudades',
|
||||
'atlas.noData': 'Aún no hay datos de viaje',
|
||||
'atlas.noDataHint':
|
||||
'Crea un viaje y añade lugares para ver tu mapa del mundo',
|
||||
'atlas.lastTrip': 'Último viaje',
|
||||
'atlas.nextTrip': 'Próximo viaje',
|
||||
'atlas.daysLeft': 'días restantes',
|
||||
'atlas.streak': 'Racha',
|
||||
'atlas.year': 'año',
|
||||
'atlas.years': 'años',
|
||||
'atlas.yearInRow': 'año seguido',
|
||||
'atlas.yearsInRow': 'años seguidos',
|
||||
'atlas.tripIn': 'viaje en',
|
||||
'atlas.tripsIn': 'viajes en',
|
||||
'atlas.since': 'desde',
|
||||
'atlas.europe': 'Europa',
|
||||
'atlas.asia': 'Asia',
|
||||
'atlas.northAmerica': 'América del Norte',
|
||||
'atlas.southAmerica': 'América del Sur',
|
||||
'atlas.africa': 'África',
|
||||
'atlas.oceania': 'Oceanía',
|
||||
'atlas.other': 'Otros',
|
||||
'atlas.firstVisit': 'Primer viaje',
|
||||
'atlas.lastVisitLabel': 'Último viaje',
|
||||
'atlas.tripSingular': 'Viaje',
|
||||
'atlas.tripPlural': 'Viajes',
|
||||
'atlas.placeVisited': 'Lugar visitado',
|
||||
'atlas.placesVisited': 'Lugares visitados',
|
||||
'atlas.statsTab': 'Estadísticas',
|
||||
'atlas.bucketTab': 'Lista de deseos',
|
||||
'atlas.addBucket': 'Añadir a lista de deseos',
|
||||
'atlas.bucketNamePlaceholder': 'Lugar o destino...',
|
||||
'atlas.bucketNotesPlaceholder': 'Notas (opcional)',
|
||||
'atlas.bucketEmpty': 'Tu lista de deseos está vacía',
|
||||
'atlas.bucketEmptyHint': 'Añade lugares que sueñas con visitar',
|
||||
'atlas.unmark': 'Eliminar',
|
||||
'atlas.confirmMark': '¿Marcar este país como visitado?',
|
||||
'atlas.confirmUnmark': '¿Eliminar este país de tu lista de visitados?',
|
||||
'atlas.confirmUnmarkRegion':
|
||||
'¿Eliminar esta región de tu lista de visitados?',
|
||||
'atlas.markVisited': 'Marcar como visitado',
|
||||
'atlas.markVisitedHint': 'Añadir este país a tu lista de visitados',
|
||||
'atlas.markRegionVisitedHint': 'Añadir esta región a tu lista de visitados',
|
||||
'atlas.addToBucket': 'Añadir a lista de deseos',
|
||||
'atlas.addPoi': 'Añadir lugar',
|
||||
'atlas.searchCountry': 'Buscar un país...',
|
||||
'atlas.month': 'Mes',
|
||||
'atlas.addToBucketHint': 'Guardar como lugar que quieres visitar',
|
||||
'atlas.bucketWhen': '¿Cuándo planeas visitarlo?',
|
||||
};
|
||||
export default atlas;
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const backup: TranslationStrings = {
|
||||
'backup.title': 'Copia de seguridad de datos',
|
||||
'backup.subtitle': 'Base de datos y todos los archivos subidos',
|
||||
'backup.refresh': 'Actualizar',
|
||||
'backup.upload': 'Subir copia de seguridad',
|
||||
'backup.uploading': 'Subiendo…',
|
||||
'backup.create': 'Crear copia',
|
||||
'backup.creating': 'Creando…',
|
||||
'backup.empty': 'Aún no hay copias',
|
||||
'backup.createFirst': 'Crear la primera copia',
|
||||
'backup.download': 'Descargar',
|
||||
'backup.restore': 'Restaurar',
|
||||
'backup.confirm.restore':
|
||||
'¿Restaurar la copia "{name}"?\n\nTodos los datos actuales serán reemplazados por la copia.',
|
||||
'backup.confirm.uploadRestore':
|
||||
'¿Subir y restaurar el archivo de copia "{name}"?\n\nTodos los datos actuales se sobrescribirán.',
|
||||
'backup.confirm.delete': '¿Eliminar la copia "{name}"?',
|
||||
'backup.toast.loadError': 'No se pudieron cargar las copias',
|
||||
'backup.toast.created': 'Copia de seguridad creada correctamente',
|
||||
'backup.toast.createError': 'No se pudo crear la copia',
|
||||
'backup.toast.restored': 'Copia restaurada. La página se recargará…',
|
||||
'backup.toast.restoreError': 'No se pudo restaurar',
|
||||
'backup.toast.uploadError': 'No se pudo subir',
|
||||
'backup.toast.deleted': 'Copia eliminada',
|
||||
'backup.toast.deleteError': 'No se pudo eliminar',
|
||||
'backup.toast.downloadError': 'La descarga falló',
|
||||
'backup.toast.settingsSaved': 'Ajustes de copia automática guardados',
|
||||
'backup.toast.settingsError': 'No se pudieron guardar los ajustes',
|
||||
'backup.auto.title': 'Copia automática',
|
||||
'backup.auto.subtitle':
|
||||
'Copia de seguridad automática según una programación',
|
||||
'backup.auto.enable': 'Activar copia automática',
|
||||
'backup.auto.enableHint':
|
||||
'Se crearán copias automáticamente según la frecuencia elegida',
|
||||
'backup.auto.interval': 'Intervalo',
|
||||
'backup.auto.hour': 'Ejecutar a la hora',
|
||||
'backup.auto.hourHint': 'Hora local del servidor (formato {format})',
|
||||
'backup.auto.dayOfWeek': 'Día de la semana',
|
||||
'backup.auto.dayOfMonth': 'Día del mes',
|
||||
'backup.auto.dayOfMonthHint':
|
||||
'Limitado a 1–28 para compatibilidad con todos los meses',
|
||||
'backup.auto.scheduleSummary': 'Programación',
|
||||
'backup.auto.summaryDaily': 'Todos los días a las {hour}:00',
|
||||
'backup.auto.summaryWeekly': 'Cada {day} a las {hour}:00',
|
||||
'backup.auto.summaryMonthly': 'El día {day} de cada mes a las {hour}:00',
|
||||
'backup.auto.envLocked': 'Docker',
|
||||
'backup.auto.envLockedHint':
|
||||
'La copia automática está configurada mediante variables de entorno Docker. Para cambiar estos ajustes, actualiza tu docker-compose.yml y reinicia el contenedor.',
|
||||
'backup.auto.copyEnv': 'Copiar variables de entorno Docker',
|
||||
'backup.auto.envCopied':
|
||||
'Variables de entorno Docker copiadas al portapapeles',
|
||||
'backup.auto.keepLabel': 'Eliminar copias antiguas después de',
|
||||
'backup.dow.sunday': 'Dom',
|
||||
'backup.dow.monday': 'Lun',
|
||||
'backup.dow.tuesday': 'Mar',
|
||||
'backup.dow.wednesday': 'Mié',
|
||||
'backup.dow.thursday': 'Jue',
|
||||
'backup.dow.friday': 'Vie',
|
||||
'backup.dow.saturday': 'Sáb',
|
||||
'backup.interval.hourly': 'Cada hora',
|
||||
'backup.interval.daily': 'Diaria',
|
||||
'backup.interval.weekly': 'Semanal',
|
||||
'backup.interval.monthly': 'Mensual',
|
||||
'backup.keep.1day': '1 día',
|
||||
'backup.keep.3days': '3 días',
|
||||
'backup.keep.7days': '7 días',
|
||||
'backup.keep.14days': '14 días',
|
||||
'backup.keep.30days': '30 días',
|
||||
'backup.keep.forever': 'Conservar para siempre',
|
||||
'backup.restoreConfirmTitle': '¿Restaurar copia?',
|
||||
'backup.restoreWarning':
|
||||
'Todos los datos actuales (viajes, lugares, usuarios, subidas) serán reemplazados permanentemente por la copia. Esta acción no se puede deshacer.',
|
||||
'backup.restoreTip':
|
||||
'Consejo: crea una copia del estado actual antes de restaurar.',
|
||||
'backup.restoreConfirm': 'Sí, restaurar',
|
||||
};
|
||||
export default backup;
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const budget: TranslationStrings = {
|
||||
'budget.title': 'Presupuesto',
|
||||
'budget.exportCsv': 'Exportar CSV',
|
||||
'budget.emptyTitle': 'Aún no se ha creado ningún presupuesto',
|
||||
'budget.emptyText':
|
||||
'Crea categorías y entradas para planificar el presupuesto de tu viaje',
|
||||
'budget.emptyPlaceholder': 'Introduce el nombre de la categoría...',
|
||||
'budget.createCategory': 'Crear categoría',
|
||||
'budget.category': 'Categoría',
|
||||
'budget.categoryName': 'Nombre de la categoría',
|
||||
'budget.table.name': 'Nombre',
|
||||
'budget.table.total': 'Total',
|
||||
'budget.table.persons': 'Personas',
|
||||
'budget.table.days': 'Días',
|
||||
'budget.table.perPerson': 'Por persona',
|
||||
'budget.table.perDay': 'Por día',
|
||||
'budget.table.perPersonDay': 'Por pers. / día',
|
||||
'budget.table.note': 'Nota',
|
||||
'budget.table.date': 'Fecha',
|
||||
'budget.newEntry': 'Nueva entrada',
|
||||
'budget.defaultEntry': 'Nueva entrada',
|
||||
'budget.defaultCategory': 'Nueva categoría',
|
||||
'budget.total': 'Total',
|
||||
'budget.totalBudget': 'Presupuesto total',
|
||||
'budget.byCategory': 'Por categoría',
|
||||
'budget.editTooltip': 'Haz clic para editar',
|
||||
'budget.linkedToReservation':
|
||||
'Vinculado a una reserva — edite el nombre allí',
|
||||
'budget.confirm.deleteCategory':
|
||||
'¿Seguro que quieres eliminar la categoría "{name}" con {count} entradas?',
|
||||
'budget.deleteCategory': 'Eliminar categoría',
|
||||
'budget.perPerson': 'Por persona',
|
||||
'budget.paid': 'Pagado',
|
||||
'budget.open': 'Abrir',
|
||||
'budget.noMembers': 'No hay miembros asignados',
|
||||
'budget.settlement': 'Liquidación',
|
||||
'budget.settlementInfo':
|
||||
'Haz clic en el avatar de un miembro en una partida del presupuesto para marcarlo en verde — esto significa que ha pagado. La liquidación muestra quién debe cuánto a quién.',
|
||||
'budget.netBalances': 'Saldos netos',
|
||||
'budget.categoriesLabel': 'categorías',
|
||||
"costs.you": "Tú",
|
||||
"costs.youShort": "Tú",
|
||||
"costs.youLower": "tú",
|
||||
"costs.youOwe": "Debes",
|
||||
"costs.youOweSub": "Deberías pagar a otros",
|
||||
"costs.youreOwed": "Te deben",
|
||||
"costs.youreOwedSub": "Otros deberían pagarte",
|
||||
"costs.totalSpend": "Gasto total del viaje",
|
||||
"costs.totalSpendSub": "Entre todos los viajeros",
|
||||
"costs.to": "Para",
|
||||
"costs.from": "De",
|
||||
"costs.allSettled": "Estás al día con todo",
|
||||
"costs.nothingOwed": "Nadie te debe nada",
|
||||
"costs.yourShare": "Tu parte",
|
||||
"costs.youPaid": "Pagaste",
|
||||
"costs.expenses": "Gastos",
|
||||
"costs.entries": "{count} entradas",
|
||||
"costs.searchPlaceholder": "Buscar gastos…",
|
||||
"costs.filter.all": "Todos",
|
||||
"costs.filter.mine": "Pagados por mí",
|
||||
"costs.filter.owed": "Me deben",
|
||||
"costs.addExpense": "Añadir gasto",
|
||||
"costs.editExpense": "Editar gasto",
|
||||
"costs.noMatch": "Ningún gasto coincide con tu búsqueda.",
|
||||
"costs.emptyText": "Aún no hay gastos. Añade el primero.",
|
||||
"costs.spent": "{amount} gastados",
|
||||
"costs.noDate": "Sin fecha",
|
||||
"costs.noOnePaid": "Nadie ha pagado aún",
|
||||
"costs.youLent": "prestaste {amount}",
|
||||
"costs.youBorrowed": "tomaste prestado {amount}",
|
||||
"costs.settleUp": "Saldar cuentas",
|
||||
"costs.history": "Historial",
|
||||
"costs.everyoneSquare": "Todos están en paz",
|
||||
"costs.nothingOutstanding": "No hay pagos pendientes ahora mismo.",
|
||||
"costs.pay": "paga",
|
||||
"costs.pays": "paga",
|
||||
"costs.settle": "Saldar",
|
||||
"costs.balances": "Saldos",
|
||||
"costs.byCategory": "Por categoría",
|
||||
"costs.noCategories": "Aún no hay gastos.",
|
||||
"costs.settleHistory": "Historial de pagos",
|
||||
"costs.noSettlements": "Aún no hay pagos saldados.",
|
||||
"costs.paymentsSettled": "{count} pagos saldados",
|
||||
"costs.paid": "pagado",
|
||||
"costs.undo": "Deshacer",
|
||||
"costs.whatFor": "¿Para qué fue?",
|
||||
"costs.namePlaceholder": "p. ej. Cena, souvenirs, gasolina…",
|
||||
"costs.totalAmount": "Importe total",
|
||||
"costs.currency": "Moneda",
|
||||
"costs.day": "Día",
|
||||
"costs.rateLabel": "1 {from} en {to}",
|
||||
"costs.category": "Categoría",
|
||||
"costs.whoPaid": "¿Quién pagó?",
|
||||
"costs.splitBetween": "Dividir a partes iguales entre",
|
||||
"costs.pickSomeone": "Elige al menos una persona con quien dividir.",
|
||||
"costs.splitSummary": "Dividido entre {count} · {amount} cada uno",
|
||||
"costs.cat.accommodation": "Alojamiento",
|
||||
"costs.cat.food": "Comida y bebida",
|
||||
"costs.cat.groceries": "Compras de comida",
|
||||
"costs.cat.transport": "Transporte",
|
||||
"costs.cat.flights": "Vuelos",
|
||||
"costs.cat.activities": "Actividades",
|
||||
"costs.cat.sightseeing": "Turismo",
|
||||
"costs.cat.shopping": "Compras",
|
||||
"costs.cat.fees": "Tasas y entradas",
|
||||
"costs.cat.health": "Salud",
|
||||
"costs.cat.tips": "Propinas",
|
||||
"costs.cat.other": "Otros",
|
||||
"costs.daysCount": "{count} días",
|
||||
"costs.travelers": "{count} viajeros",
|
||||
"costs.liveRate": "tasa en vivo",
|
||||
"costs.settleAll": "Saldar todo",
|
||||
};
|
||||
|
||||
export default budget;
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const categories: TranslationStrings = {
|
||||
'categories.title': 'Categorías',
|
||||
'categories.subtitle': 'Gestiona categorías para lugares',
|
||||
'categories.new': 'Nueva categoría',
|
||||
'categories.empty': 'Aún no hay categorías',
|
||||
'categories.namePlaceholder': 'Nombre de la categoría',
|
||||
'categories.icon': 'Icono',
|
||||
'categories.color': 'Color',
|
||||
'categories.customColor': 'Elegir color personalizado',
|
||||
'categories.preview': 'Vista previa',
|
||||
'categories.defaultName': 'Categoría',
|
||||
'categories.update': 'Actualizar',
|
||||
'categories.create': 'Crear',
|
||||
'categories.confirm.delete':
|
||||
'¿Eliminar la categoría? Los lugares de esta categoría no se eliminarán.',
|
||||
'categories.toast.loadError': 'No se pudieron cargar las categorías',
|
||||
'categories.toast.nameRequired': 'Introduce un nombre',
|
||||
'categories.toast.updated': 'Categoría actualizada',
|
||||
'categories.toast.created': 'Categoría creada',
|
||||
'categories.toast.saveError': 'No se pudo guardar',
|
||||
'categories.toast.deleted': 'Categoría eliminada',
|
||||
'categories.toast.deleteError': 'No se pudo eliminar',
|
||||
};
|
||||
export default categories;
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const collab: TranslationStrings = {
|
||||
'collab.tabs.chat': 'Mensajes',
|
||||
'collab.tabs.notes': 'Notas',
|
||||
'collab.tabs.polls': 'Encuestas',
|
||||
'collab.whatsNext.title': 'Qué viene ahora',
|
||||
'collab.whatsNext.today': 'Hoy',
|
||||
'collab.whatsNext.tomorrow': 'Mañana',
|
||||
'collab.whatsNext.empty': 'No hay actividades próximas',
|
||||
'collab.whatsNext.until': 'hasta',
|
||||
'collab.whatsNext.emptyHint': 'Las actividades con hora aparecerán aquí',
|
||||
'collab.chat.send': 'Enviar',
|
||||
'collab.chat.placeholder': 'Escribe un mensaje...',
|
||||
'collab.chat.empty': 'Empieza la conversación',
|
||||
'collab.chat.emptyHint':
|
||||
'Los mensajes se comparten con todos los miembros del viaje',
|
||||
'collab.chat.emptyDesc':
|
||||
'Comparte ideas, planes y novedades con tu grupo de viaje',
|
||||
'collab.chat.today': 'Hoy',
|
||||
'collab.chat.yesterday': 'Ayer',
|
||||
'collab.chat.deletedMessage': 'eliminó un mensaje',
|
||||
'collab.chat.reply': 'Responder',
|
||||
'collab.chat.loadMore': 'Cargar mensajes anteriores',
|
||||
'collab.chat.justNow': 'justo ahora',
|
||||
'collab.chat.minutesAgo': 'hace {n} min',
|
||||
'collab.chat.hoursAgo': 'hace {n} h',
|
||||
'collab.notes.title': 'Notas',
|
||||
'collab.notes.new': 'Nueva nota',
|
||||
'collab.notes.empty': 'Aún no hay notas',
|
||||
'collab.notes.emptyHint': 'Empieza a capturar ideas y planes',
|
||||
'collab.notes.all': 'Todas',
|
||||
'collab.notes.titlePlaceholder': 'Título de la nota',
|
||||
'collab.notes.contentPlaceholder': 'Escribe algo...',
|
||||
'collab.notes.categoryPlaceholder': 'Categoría',
|
||||
'collab.notes.newCategory': 'Nueva categoría...',
|
||||
'collab.notes.category': 'Categoría',
|
||||
'collab.notes.noCategory': 'Sin categoría',
|
||||
'collab.notes.color': 'Color',
|
||||
'collab.notes.save': 'Guardar',
|
||||
'collab.notes.cancel': 'Cancelar',
|
||||
'collab.notes.edit': 'Editar',
|
||||
'collab.notes.delete': 'Eliminar',
|
||||
'collab.notes.confirmDeleteTitle': '¿Eliminar nota?',
|
||||
'collab.notes.confirmDeleteBody': 'Esta nota se eliminará de forma permanente.',
|
||||
'collab.notes.pin': 'Fijar',
|
||||
'collab.notes.unpin': 'Desfijar',
|
||||
'collab.notes.daysAgo': 'hace {n} d',
|
||||
'collab.notes.categorySettings': 'Gestionar categorías',
|
||||
'collab.notes.create': 'Crear',
|
||||
'collab.notes.website': 'Sitio web',
|
||||
'collab.notes.websitePlaceholder': 'https://...',
|
||||
'collab.notes.attachFiles': 'Adjuntar archivos',
|
||||
'collab.notes.noCategoriesYet': 'Aún no hay categorías',
|
||||
'collab.notes.emptyDesc': 'Crea una nota para empezar',
|
||||
'collab.polls.title': 'Encuestas',
|
||||
'collab.polls.new': 'Nueva encuesta',
|
||||
'collab.polls.empty': 'Aún no hay encuestas',
|
||||
'collab.polls.emptyHint': 'Pregunta al grupo y votad juntos',
|
||||
'collab.polls.question': 'Pregunta',
|
||||
'collab.polls.questionPlaceholder': '¿Qué deberíamos hacer?',
|
||||
'collab.polls.addOption': '+ Añadir opción',
|
||||
'collab.polls.optionPlaceholder': 'Opción {n}',
|
||||
'collab.polls.create': 'Crear encuesta',
|
||||
'collab.polls.close': 'Cerrar',
|
||||
'collab.polls.closed': 'Cerrada',
|
||||
'collab.polls.votes': '{n} votos',
|
||||
'collab.polls.vote': '{n} voto',
|
||||
'collab.polls.multipleChoice': 'Selección múltiple',
|
||||
'collab.polls.multiChoice': 'Selección múltiple',
|
||||
'collab.polls.deadline': 'Fecha límite',
|
||||
'collab.polls.option': 'Opción',
|
||||
'collab.polls.options': 'Opciones',
|
||||
'collab.polls.delete': 'Eliminar',
|
||||
'collab.polls.closedSection': 'Cerradas',
|
||||
};
|
||||
export default collab;
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const common: TranslationStrings = {
|
||||
'common.save': 'Guardar',
|
||||
'common.showMore': 'Ver más',
|
||||
'common.showLess': 'Ver menos',
|
||||
'common.cancel': 'Cancelar',
|
||||
'common.clear': 'Borrar',
|
||||
'common.delete': 'Eliminar',
|
||||
'common.edit': 'Editar',
|
||||
'common.add': 'Añadir',
|
||||
'common.loading': 'Cargando...',
|
||||
'common.import': 'Importar',
|
||||
'common.select': 'Seleccionar',
|
||||
'common.selectAll': 'Seleccionar todo',
|
||||
'common.deselectAll': 'Deseleccionar todo',
|
||||
'common.error': 'Error',
|
||||
'common.unknownError': 'Error desconocido',
|
||||
'common.tooManyAttempts':
|
||||
'Demasiados intentos. Inténtelo de nuevo más tarde.',
|
||||
'common.back': 'Atrás',
|
||||
'common.all': 'Todo',
|
||||
'common.close': 'Cerrar',
|
||||
'common.open': 'Abrir',
|
||||
'common.upload': 'Subir',
|
||||
'common.search': 'Buscar',
|
||||
'common.confirm': 'Confirmar',
|
||||
'common.ok': 'Aceptar',
|
||||
'common.yes': 'Sí',
|
||||
'common.no': 'No',
|
||||
'common.or': 'o',
|
||||
'common.none': 'Ninguno',
|
||||
'common.date': 'Fecha',
|
||||
'common.rename': 'Renombrar',
|
||||
'common.discardChanges': 'Descartar cambios',
|
||||
'common.discard': 'Descartar',
|
||||
'common.name': 'Nombre',
|
||||
'common.email': 'Correo',
|
||||
'common.password': 'Contraseña',
|
||||
'common.saving': 'Guardando...',
|
||||
'common.saved': 'Guardado',
|
||||
'common.expand': 'Expandir',
|
||||
'common.collapse': 'Contraer',
|
||||
'common.update': 'Actualizar',
|
||||
'common.change': 'Cambiar',
|
||||
'common.uploading': 'Subiendo…',
|
||||
'common.backToPlanning': 'Volver a la planificación',
|
||||
'common.reset': 'Restablecer',
|
||||
'common.copy': 'Copiar',
|
||||
'common.copied': 'Copiado',
|
||||
'common.justNow': 'justo ahora',
|
||||
'common.hoursAgo': 'hace {count}h',
|
||||
'common.daysAgo': 'hace {count}d',
|
||||
};
|
||||
export default common;
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const dashboard: TranslationStrings = {
|
||||
'dashboard.title': 'Mis viajes',
|
||||
'dashboard.subtitle.loading': 'Cargando viajes...',
|
||||
'dashboard.subtitle.trips': '{count} viajes ({archived} archivados)',
|
||||
'dashboard.subtitle.empty': 'Empieza tu primer viaje',
|
||||
'dashboard.subtitle.activeOne': '{count} viaje activo',
|
||||
'dashboard.subtitle.activeMany': '{count} viajes activos',
|
||||
'dashboard.subtitle.archivedSuffix': ' · {count} archivados',
|
||||
'dashboard.newTrip': 'Nuevo viaje',
|
||||
'dashboard.newTripSub': 'Planifica un nuevo viaje desde cero',
|
||||
'dashboard.gridView': 'Vista de cuadrícula',
|
||||
'dashboard.listView': 'Vista de lista',
|
||||
'dashboard.currency': 'Divisa',
|
||||
'dashboard.timezone': 'Zonas horarias',
|
||||
'dashboard.localTime': 'Hora local',
|
||||
'dashboard.timezoneCustomTitle': 'Zona horaria personalizada',
|
||||
'dashboard.timezoneCustomLabelPlaceholder': 'Nombre (opcional)',
|
||||
'dashboard.timezoneCustomTzPlaceholder': 'ej. America/New_York',
|
||||
'dashboard.timezoneCustomAdd': 'Añadir',
|
||||
'dashboard.timezoneCustomErrorEmpty': 'Introduce una zona horaria',
|
||||
'dashboard.timezoneCustomErrorInvalid':
|
||||
'Zona horaria no válida. Usa formato como Europe/Madrid',
|
||||
'dashboard.timezoneCustomErrorDuplicate': 'Ya añadida',
|
||||
'dashboard.emptyTitle': 'Aún no hay viajes',
|
||||
'dashboard.emptyText': 'Crea tu primer viaje y empieza a planificar',
|
||||
'dashboard.emptyButton': 'Crear primer viaje',
|
||||
'dashboard.nextTrip': 'Próximo viaje',
|
||||
'dashboard.shared': 'Compartido',
|
||||
'dashboard.sharedBy': 'Compartido por {name}',
|
||||
'dashboard.days': 'Días',
|
||||
'dashboard.places': 'Lugares',
|
||||
'dashboard.members': 'Compañeros de viaje',
|
||||
'dashboard.archive': 'Archivar',
|
||||
'dashboard.copyTrip': 'Copiar',
|
||||
'dashboard.copySuffix': 'copia',
|
||||
'dashboard.restore': 'Restaurar',
|
||||
'dashboard.archived': 'Archivado',
|
||||
'dashboard.status.ongoing': 'En curso',
|
||||
'dashboard.status.today': 'Hoy',
|
||||
'dashboard.status.tomorrow': 'Mañana',
|
||||
'dashboard.status.past': 'Pasado',
|
||||
'dashboard.status.daysLeft': 'Quedan {count} días',
|
||||
'dashboard.toast.loadError': 'No se pudieron cargar los viajes',
|
||||
'dashboard.toast.created': '¡Viaje creado correctamente!',
|
||||
'dashboard.toast.createError': 'No se pudo crear el viaje',
|
||||
'dashboard.toast.updated': '¡Viaje actualizado!',
|
||||
'dashboard.toast.updateError': 'No se pudo actualizar el viaje',
|
||||
'dashboard.toast.deleted': 'Viaje eliminado',
|
||||
'dashboard.toast.deleteError': 'No se pudo eliminar el viaje',
|
||||
'dashboard.toast.archived': 'Viaje archivado',
|
||||
'dashboard.toast.archiveError': 'No se pudo archivar el viaje',
|
||||
'dashboard.toast.restored': 'Viaje restaurado',
|
||||
'dashboard.toast.restoreError': 'No se pudo restaurar el viaje',
|
||||
'dashboard.toast.copied': '¡Viaje copiado!',
|
||||
'dashboard.toast.copyError': 'No se pudo copiar el viaje',
|
||||
'dashboard.confirm.delete':
|
||||
'¿Eliminar el viaje "{title}"? Todos los lugares y planes se borrarán permanentemente.',
|
||||
'dashboard.editTrip': 'Editar viaje',
|
||||
'dashboard.createTrip': 'Crear nuevo viaje',
|
||||
'dashboard.tripTitle': 'Título',
|
||||
'dashboard.tripTitlePlaceholder': 'p. ej. Verano en Japón',
|
||||
'dashboard.tripDescription': 'Descripción',
|
||||
'dashboard.tripDescriptionPlaceholder': '¿De qué trata este viaje?',
|
||||
'dashboard.startDate': 'Fecha de inicio',
|
||||
'dashboard.endDate': 'Fecha de fin',
|
||||
'dashboard.dayCount': 'Número de días',
|
||||
'dashboard.dayCountHint':
|
||||
'Cuántos días planificar cuando no se han establecido fechas de viaje.',
|
||||
'dashboard.noDateHint':
|
||||
'Sin fecha definida: se crearán 7 días por defecto. Puedes cambiarlo cuando quieras.',
|
||||
'dashboard.coverImage': 'Imagen de portada',
|
||||
'dashboard.addCoverImage': 'Añadir imagen de portada',
|
||||
'dashboard.addMembers': 'Compañeros de viaje',
|
||||
'dashboard.addMember': 'Añadir miembro',
|
||||
'dashboard.coverSaved': 'Imagen de portada guardada',
|
||||
'dashboard.coverUploadError': 'Error al subir la imagen',
|
||||
'dashboard.coverRemoveError': 'Error al eliminar la imagen',
|
||||
'dashboard.titleRequired': 'El título es obligatorio',
|
||||
'dashboard.endDateError': 'La fecha de fin debe ser posterior a la de inicio',
|
||||
'dashboard.greeting.morning': 'Buenos días,',
|
||||
'dashboard.greeting.afternoon': 'Buenas tardes,',
|
||||
'dashboard.greeting.evening': 'Buenas noches,',
|
||||
'dashboard.mobile.liveNow': 'En vivo ahora',
|
||||
'dashboard.mobile.tripProgress': 'Progreso del viaje',
|
||||
'dashboard.mobile.daysLeft': '{count} días restantes',
|
||||
'dashboard.mobile.places': 'Lugares',
|
||||
'dashboard.mobile.buddies': 'Compañeros',
|
||||
'dashboard.mobile.newTrip': 'Nuevo viaje',
|
||||
'dashboard.mobile.currency': 'Moneda',
|
||||
'dashboard.mobile.timezone': 'Zona horaria',
|
||||
'dashboard.mobile.upcomingTrips': 'Próximos viajes',
|
||||
'dashboard.mobile.yourTrips': 'Tus viajes',
|
||||
'dashboard.mobile.trips': 'viajes',
|
||||
'dashboard.mobile.starts': 'Comienza',
|
||||
'dashboard.mobile.duration': 'Duración',
|
||||
'dashboard.mobile.day': 'día',
|
||||
'dashboard.mobile.days': 'días',
|
||||
'dashboard.mobile.ongoing': 'En curso',
|
||||
'dashboard.mobile.startsToday': 'Comienza hoy',
|
||||
'dashboard.mobile.tomorrow': 'Mañana',
|
||||
'dashboard.mobile.inDays': 'En {count} días',
|
||||
'dashboard.mobile.inMonths': 'En {count} meses',
|
||||
'dashboard.mobile.completed': 'Completado',
|
||||
'dashboard.mobile.currencyConverter': 'Conversor de monedas',
|
||||
'dashboard.filter.planned': 'Planificados',
|
||||
'dashboard.hero.badgeLive': 'EN VIVO AHORA',
|
||||
'dashboard.hero.badgeToday': 'EMPIEZA HOY',
|
||||
'dashboard.hero.badgeTomorrow': 'MAÑANA',
|
||||
'dashboard.hero.badgeNext': 'SIGUIENTE',
|
||||
'dashboard.hero.badgeRecent': 'RECIENTE',
|
||||
'dashboard.hero.tripDates': 'Fechas del viaje',
|
||||
'dashboard.hero.noDates': 'Sin fechas',
|
||||
'dashboard.hero.travelerOne': '{count} viajero',
|
||||
'dashboard.hero.travelerMany': '{count} viajeros',
|
||||
'dashboard.hero.destinationOne': '{count} destino',
|
||||
'dashboard.hero.destinationMany': '{count} destinos',
|
||||
'dashboard.hero.dayUnitOne': 'día',
|
||||
'dashboard.hero.dayUnitMany': 'días',
|
||||
'dashboard.hero.dayLeft': 'Día restante',
|
||||
'dashboard.hero.daysLeft': 'Días restantes',
|
||||
'dashboard.hero.lastDay': 'Último día',
|
||||
'dashboard.hero.untilStart': 'Hasta el inicio',
|
||||
'dashboard.hero.startsIn': 'Empieza en',
|
||||
'dashboard.atlas.countriesVisited': 'Atlas · Países visitados',
|
||||
'dashboard.atlas.ofTotal': 'de {total}',
|
||||
'dashboard.atlas.tripsTotal': 'Viajes en total',
|
||||
'dashboard.atlas.placesMapped': '{count} lugares en el mapa',
|
||||
'dashboard.atlas.daysTraveled': 'Días de viaje',
|
||||
'dashboard.atlas.daysUnit': 'días',
|
||||
'dashboard.atlas.acrossAllTrips': 'en todos los viajes',
|
||||
'dashboard.atlas.distanceFlown': 'Distancia volada',
|
||||
'dashboard.atlas.kmUnit': 'km',
|
||||
'dashboard.atlas.aroundEquator': '≈ {count}× alrededor del ecuador',
|
||||
'dashboard.card.idea': 'Idea',
|
||||
'dashboard.card.buddyOne': 'Compañero',
|
||||
'dashboard.fx.from': 'De',
|
||||
'dashboard.fx.to': 'A',
|
||||
'dashboard.fx.unavailable': 'Tipo de cambio no disponible',
|
||||
'dashboard.tz.searchPlaceholder': 'Buscar zona horaria…',
|
||||
'dashboard.tz.empty': 'Aún no hay otras zonas horarias — añade una con +',
|
||||
'dashboard.upcoming.title': 'Próximas reservas',
|
||||
'dashboard.upcoming.empty': 'Aún no hay nada reservado.',
|
||||
'dashboard.confirm.copy.title': '¿Copiar este viaje?',
|
||||
'dashboard.confirm.copy.willCopy': 'Se copiará',
|
||||
'dashboard.confirm.copy.will1': 'Días, lugares y asignaciones por día',
|
||||
'dashboard.confirm.copy.will2': 'Alojamientos y reservas',
|
||||
'dashboard.confirm.copy.will3':
|
||||
'Partidas de presupuesto y orden de categorías',
|
||||
'dashboard.confirm.copy.will4': 'Listas de equipaje (sin marcar)',
|
||||
'dashboard.confirm.copy.will5': 'Tareas (sin asignar ni marcar)',
|
||||
'dashboard.confirm.copy.will6': 'Notas del día',
|
||||
'dashboard.confirm.copy.wontCopy': 'No se copiará',
|
||||
'dashboard.confirm.copy.wont1': 'Colaboradores y asignaciones de miembros',
|
||||
'dashboard.confirm.copy.wont2': 'Notas, encuestas y mensajes compartidos',
|
||||
'dashboard.confirm.copy.wont3': 'Archivos y fotos',
|
||||
'dashboard.confirm.copy.wont4': 'Tokens de uso compartido',
|
||||
'dashboard.confirm.copy.confirm': 'Copiar viaje',
|
||||
'dashboard.aria.toggleView': 'Cambiar vista',
|
||||
'dashboard.aria.filter': 'Filtrar',
|
||||
'dashboard.aria.duplicate': 'Duplicar',
|
||||
'dashboard.aria.refreshRates': 'Actualizar tipos de cambio',
|
||||
'dashboard.aria.swapCurrencies': 'Intercambiar monedas',
|
||||
'dashboard.aria.addTimezone': 'Añadir zona horaria',
|
||||
'dashboard.aria.removeTimezone': 'Eliminar {city}',
|
||||
'dashboard.dayCountRequired': 'El número de días es obligatorio',
|
||||
};
|
||||
export default dashboard;
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const day: TranslationStrings = {
|
||||
'day.precipProb': 'Probabilidad de lluvia',
|
||||
'day.precipitation': 'Precipitación',
|
||||
'day.wind': 'Viento',
|
||||
'day.sunrise': 'Amanecer',
|
||||
'day.sunset': 'Atardecer',
|
||||
'day.hourlyForecast': 'Pronóstico por horas',
|
||||
'day.climateHint':
|
||||
'Promedios históricos: el pronóstico real está disponible dentro de los 16 días previos a la fecha.',
|
||||
'day.noWeather':
|
||||
'No hay datos meteorológicos disponibles. Añade un lugar con coordenadas.',
|
||||
'day.overview': 'Resumen diario',
|
||||
'day.accommodation': 'Alojamiento',
|
||||
'day.addAccommodation': 'Añadir alojamiento',
|
||||
'day.hotelDayRange': 'Aplicar a los días',
|
||||
'day.noPlacesForHotel': 'Añade primero lugares al viaje',
|
||||
'day.allDays': 'Todos',
|
||||
'day.checkIn': 'Registro de entrada',
|
||||
'day.checkInUntil': 'Hasta',
|
||||
'day.checkOut': 'Registro de salida',
|
||||
'day.confirmation': 'Confirmación',
|
||||
'day.editAccommodation': 'Editar alojamiento',
|
||||
'day.reservations': 'Reservas',
|
||||
};
|
||||
export default day;
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const dayplan: TranslationStrings = {
|
||||
'dayplan.icsTooltip': 'Exportar calendario (ICS)',
|
||||
'dayplan.emptyDay': 'No hay lugares planificados para este día',
|
||||
'dayplan.addNote': 'Añadir nota',
|
||||
'dayplan.editNote': 'Editar nota',
|
||||
'dayplan.noteAdd': 'Añadir nota',
|
||||
'dayplan.noteEdit': 'Editar nota',
|
||||
'dayplan.noteTitle': 'Nota',
|
||||
'dayplan.noteSubtitle': 'Nota diaria',
|
||||
'dayplan.totalCost': 'Coste total',
|
||||
'dayplan.days': 'Días',
|
||||
'dayplan.dayN': 'Día {n}',
|
||||
'dayplan.calculating': 'Calculando...',
|
||||
'dayplan.route': 'Ruta',
|
||||
'dayplan.optimize': 'Optimizar',
|
||||
'dayplan.optimized': 'Ruta optimizada',
|
||||
'dayplan.routeError': 'No se pudo calcular la ruta',
|
||||
'dayplan.toast.needTwoPlaces':
|
||||
'Se necesitan al menos dos lugares para optimizar la ruta',
|
||||
'dayplan.toast.routeOptimized': 'Ruta optimizada',
|
||||
'dayplan.toast.routeOptimizedFromHotel': 'Ruta optimizada desde tu alojamiento',
|
||||
'dayplan.toast.noGeoPlaces':
|
||||
'No se encontraron lugares con coordenadas para calcular la ruta',
|
||||
'dayplan.confirmed': 'Confirmado',
|
||||
'dayplan.pendingRes': 'Pendiente',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Exportar plan diario como PDF',
|
||||
'dayplan.pdfError': 'No se pudo exportar el PDF',
|
||||
'dayplan.cannotReorderTransport':
|
||||
'Las reservas con hora fija no se pueden reordenar',
|
||||
'dayplan.confirmRemoveTimeTitle': '¿Eliminar hora?',
|
||||
'dayplan.confirmRemoveTimeBody':
|
||||
'Este lugar tiene una hora fija ({time}). Al moverlo se eliminará la hora y se permitirá el orden libre.',
|
||||
'dayplan.confirmRemoveTimeAction': 'Eliminar hora y mover',
|
||||
'dayplan.confirmDeleteNoteTitle': '¿Eliminar nota?',
|
||||
'dayplan.confirmDeleteNoteBody': 'Esta nota se eliminará de forma permanente.',
|
||||
'dayplan.cannotDropOnTimed':
|
||||
'No se pueden colocar elementos entre entradas con hora fija',
|
||||
'dayplan.cannotBreakChronology':
|
||||
'Esto rompería el orden cronológico de los elementos y reservas programados',
|
||||
'dayplan.mobile.addPlace': 'Añadir lugar',
|
||||
'dayplan.mobile.searchPlaces': 'Buscar lugares...',
|
||||
'dayplan.mobile.allAssigned': 'Todos los lugares asignados',
|
||||
'dayplan.mobile.noMatch': 'Sin coincidencias',
|
||||
'dayplan.mobile.createNew': 'Crear nuevo lugar',
|
||||
'dayplan.expandAll': 'Expand all days', // en-fallback
|
||||
'dayplan.collapseAll': 'Collapse all days', // en-fallback
|
||||
'dayplan.reorderDays': 'Reordenar días',
|
||||
'dayplan.reorderTitle': 'Reordenar días',
|
||||
'dayplan.reorderHint': 'Los lugares, las notas y las reservas de un día se mueven con él.',
|
||||
'dayplan.addDay': 'Añadir día',
|
||||
'dayplan.moveUp': 'Subir',
|
||||
'dayplan.moveDown': 'Bajar',
|
||||
'dayplan.reorderUndo': 'Reordenar días',
|
||||
'dayplan.reorderError': 'No se pudieron reordenar los días',
|
||||
'dayplan.addDayError': 'No se pudo añadir el día',
|
||||
};
|
||||
export default dayplan;
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { NotificationLocale } from '../externalNotifications/types';
|
||||
|
||||
const es: NotificationLocale = {
|
||||
email: {
|
||||
footer:
|
||||
'Recibiste esto porque tienes las notificaciones activadas en TREK.',
|
||||
manage: 'Gestionar preferencias',
|
||||
madeWith: 'Made with',
|
||||
openTrek: 'Abrir TREK',
|
||||
},
|
||||
events: {
|
||||
trip_invite: (p) => ({
|
||||
title: `Invitación a "${p.trip}"`,
|
||||
body: `${p.actor} invitó a ${p.invitee || 'un miembro'} al viaje "${p.trip}".`,
|
||||
}),
|
||||
booking_change: (p) => ({
|
||||
title: `Nueva reserva: ${p.booking}`,
|
||||
body: `${p.actor} añadió una reserva "${p.booking}" (${p.type}) a "${p.trip}".`,
|
||||
}),
|
||||
trip_reminder: (p) => ({
|
||||
title: `Recordatorio: ${p.trip}`,
|
||||
body: `¡Tu viaje "${p.trip}" se acerca!`,
|
||||
}),
|
||||
todo_due: (p) => ({
|
||||
title: `Tarea pendiente: ${p.todo}`,
|
||||
body: `"${p.todo}" en "${p.trip}" vence el ${p.due}.`,
|
||||
}),
|
||||
vacay_invite: (p) => ({
|
||||
title: 'Invitación Vacay Fusion',
|
||||
body: `${p.actor} te invitó a fusionar planes de vacaciones. Abre TREK para aceptar o rechazar.`,
|
||||
}),
|
||||
photos_shared: (p) => ({
|
||||
title: `${p.count} fotos compartidas`,
|
||||
body: `${p.actor} compartió ${p.count} foto(s) en "${p.trip}".`,
|
||||
}),
|
||||
collab_message: (p) => ({
|
||||
title: `Nuevo mensaje en "${p.trip}"`,
|
||||
body: `${p.actor}: ${p.preview}`,
|
||||
}),
|
||||
packing_tagged: (p) => ({
|
||||
title: `Equipaje: ${p.category}`,
|
||||
body: `${p.actor} te asignó a la categoría "${p.category}" en "${p.trip}".`,
|
||||
}),
|
||||
version_available: (p) => ({
|
||||
title: 'Nueva versión de TREK disponible',
|
||||
body: `TREK ${p.version} ya está disponible. Visita el panel de administración para actualizar.`,
|
||||
}),
|
||||
synology_session_cleared: () => ({
|
||||
title: 'Sesión de Synology cerrada',
|
||||
body: 'Tu cuenta o URL de Synology ha cambiado. Has cerrado sesión en Synology Photos.',
|
||||
}),
|
||||
},
|
||||
passwordReset: {
|
||||
subject: 'Restablecer tu contraseña',
|
||||
greeting: 'Hola',
|
||||
body: 'Recibimos una solicitud para restablecer la contraseña de tu cuenta de TREK. Haz clic en el botón de abajo para establecer una nueva contraseña.',
|
||||
ctaIntro: 'Restablecer contraseña',
|
||||
expiry: 'Este enlace caduca en 60 minutos.',
|
||||
ignore:
|
||||
'Si no solicitaste esto, puedes ignorar este correo — tu contraseña no cambiará.',
|
||||
},
|
||||
};
|
||||
|
||||
export default es;
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const files: TranslationStrings = {
|
||||
'files.title': 'Archivos',
|
||||
'files.pageTitle': 'Archivos y documentos',
|
||||
'files.subtitle': '{count} archivos para {trip}',
|
||||
'files.download': 'Descargar',
|
||||
'files.openError': 'No se pudo abrir el archivo',
|
||||
'files.downloadPdf': 'Descargar PDF',
|
||||
'files.count': '{count} archivos',
|
||||
'files.countSingular': '1 archivo',
|
||||
'files.uploaded': '{count} archivos subidos',
|
||||
'files.uploadError': 'La subida falló',
|
||||
'files.dropzone': 'Arrastra aquí los archivos',
|
||||
'files.dropzoneHint': 'o haz clic para explorar',
|
||||
'files.allowedTypes':
|
||||
'Imágenes, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Máx. 50 MB',
|
||||
'files.uploading': 'Subiendo...',
|
||||
'files.filterAll': 'Todo',
|
||||
'files.filterPdf': 'PDF',
|
||||
'files.filterImages': 'Imágenes',
|
||||
'files.filterDocs': 'Documentos',
|
||||
'files.filterCollab': 'Notas de colaboración',
|
||||
'files.sourceCollab': 'Desde notas de colaboración',
|
||||
'files.empty': 'Aún no hay archivos',
|
||||
'files.emptyHint': 'Sube archivos para adjuntarlos a tu viaje',
|
||||
'files.openTab': 'Abrir en una pestaña nueva',
|
||||
'files.confirm.delete': '¿Seguro que quieres eliminar este archivo?',
|
||||
'files.toast.deleted': 'Archivo eliminado',
|
||||
'files.toast.deleteError': 'No se pudo eliminar el archivo',
|
||||
'files.sourcePlan': 'Plan diario',
|
||||
'files.sourceBooking': 'Reserva',
|
||||
'files.sourceTransport': 'Transporte',
|
||||
'files.attach': 'Adjuntar',
|
||||
'files.pasteHint':
|
||||
'También puedes pegar imágenes desde el portapapeles (Ctrl+V)',
|
||||
'files.trash': 'Papelera',
|
||||
'files.trashEmpty': 'La papelera está vacía',
|
||||
'files.emptyTrash': 'Vaciar papelera',
|
||||
'files.restore': 'Restaurar',
|
||||
'files.star': 'Destacar',
|
||||
'files.unstar': 'Quitar destacado',
|
||||
'files.assign': 'Asignar',
|
||||
'files.assignTitle': 'Asignar archivo',
|
||||
'files.assignPlace': 'Lugar',
|
||||
'files.assignBooking': 'Reserva',
|
||||
'files.assignTransport': 'Transporte',
|
||||
'files.unassigned': 'Sin asignar',
|
||||
'files.unlink': 'Eliminar vínculo',
|
||||
'files.noteLabel': 'Nota',
|
||||
'files.notePlaceholder': 'Añadir una nota...',
|
||||
'files.toast.trashed': 'Movido a la papelera',
|
||||
'files.toast.restored': 'Archivo restaurado',
|
||||
'files.toast.trashEmptied': 'Papelera vaciada',
|
||||
'files.toast.assigned': 'Archivo asignado',
|
||||
'files.toast.assignError': 'Error al asignar',
|
||||
'files.toast.restoreError': 'Error al restaurar',
|
||||
'files.confirm.permanentDelete':
|
||||
'Eliminar este archivo permanentemente? No se puede deshacer.',
|
||||
'files.confirm.emptyTrash':
|
||||
'Eliminar todos los archivos de la papelera? No se puede deshacer.',
|
||||
};
|
||||
export default files;
|
||||
@@ -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,
|
||||
...budget,
|
||||
...files,
|
||||
...packing,
|
||||
...members,
|
||||
...categories,
|
||||
...backup,
|
||||
...photos,
|
||||
...pdf,
|
||||
...planner,
|
||||
...stats,
|
||||
...day,
|
||||
...memories,
|
||||
...collab,
|
||||
...airport,
|
||||
...map,
|
||||
...perm,
|
||||
...undo,
|
||||
...notifications,
|
||||
...todo,
|
||||
...notif,
|
||||
...journey,
|
||||
...oauth,
|
||||
...system_notice,
|
||||
...transport,
|
||||
};
|
||||
export default locale;
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const inspector: TranslationStrings = {
|
||||
'inspector.opened': 'Abierto',
|
||||
'inspector.closed': 'Cerrado',
|
||||
'inspector.openingHours': 'Horario de apertura',
|
||||
'inspector.showHours': 'Mostrar horario',
|
||||
'inspector.files': 'Archivos',
|
||||
'inspector.filesCount': '{count} archivos',
|
||||
'inspector.removeFromDay': 'Quitar del día',
|
||||
'inspector.remove': 'Eliminar',
|
||||
'inspector.addToDay': 'Añadir al día',
|
||||
'inspector.confirmedRes': 'Reserva confirmada',
|
||||
'inspector.pendingRes': 'Reserva pendiente',
|
||||
'inspector.google': 'Abrir en Google Maps',
|
||||
'inspector.website': 'Abrir la web',
|
||||
'inspector.addRes': 'Reserva',
|
||||
'inspector.editRes': 'Editar reserva',
|
||||
'inspector.participants': 'Participantes',
|
||||
'inspector.trackStats': 'Datos de la ruta',
|
||||
};
|
||||
export default inspector;
|
||||
@@ -0,0 +1,245 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const journey: TranslationStrings = {
|
||||
'journey.search.placeholder': 'Buscar viajes…',
|
||||
'journey.search.noResults': 'Ningún viaje coincide con "{query}"',
|
||||
'journey.title': 'Travesía',
|
||||
'journey.subtitle': 'Registra tus viajes en tiempo real',
|
||||
'journey.new': 'Nueva travesía',
|
||||
'journey.create': 'Crear',
|
||||
'journey.titlePlaceholder': '¿A dónde vas?',
|
||||
'journey.empty': 'Aún no hay travesías',
|
||||
'journey.emptyHint': 'Empieza a documentar tu próximo viaje',
|
||||
'journey.deleted': 'Travesía eliminada',
|
||||
'journey.createError': 'No se pudo crear la travesía',
|
||||
'journey.deleteError': 'No se pudo eliminar la travesía',
|
||||
'journey.deleteConfirmTitle': 'Eliminar',
|
||||
'journey.deleteConfirmMessage':
|
||||
'¿Eliminar "{title}"? Esta acción no se puede deshacer.',
|
||||
'journey.deleteConfirmGeneric': '¿Estás seguro de que quieres eliminar esto?',
|
||||
'journey.notFound': 'Travesía no encontrada',
|
||||
'journey.photos': 'Fotos',
|
||||
'journey.timelineEmpty': 'Aún no hay paradas',
|
||||
'journey.timelineEmptyHint':
|
||||
'Añade un registro de ubicación o escribe una entrada de diario para empezar',
|
||||
'journey.status.draft': 'Borrador',
|
||||
'journey.status.active': 'Activa',
|
||||
'journey.status.completed': 'Completada',
|
||||
'journey.status.upcoming': 'Próxima',
|
||||
'journey.status.archived': 'Archivado',
|
||||
'journey.checkin.add': 'Registrar ubicación',
|
||||
'journey.checkin.namePlaceholder': 'Nombre del lugar',
|
||||
'journey.checkin.notesPlaceholder': 'Notas (opcional)',
|
||||
'journey.checkin.save': 'Guardar',
|
||||
'journey.checkin.error': 'No se pudo guardar el registro',
|
||||
'journey.entry.add': 'Diario',
|
||||
'journey.entry.edit': 'Editar entrada',
|
||||
'journey.entry.titlePlaceholder': 'Título (opcional)',
|
||||
'journey.entry.bodyPlaceholder': '¿Qué pasó hoy?',
|
||||
'journey.entry.save': 'Guardar',
|
||||
'journey.entry.error': 'No se pudo guardar la entrada',
|
||||
'journey.photo.add': 'Foto',
|
||||
'journey.photo.uploadError': 'Error al subir',
|
||||
'journey.share.share': 'Compartir',
|
||||
'journey.share.public': 'Público',
|
||||
'journey.share.linkCopied': 'Enlace público copiado',
|
||||
'journey.share.disabled': 'Compartir público desactivado',
|
||||
'journey.editor.titlePlaceholder': 'Dale un nombre a este momento...',
|
||||
'journey.editor.bodyPlaceholder': 'Cuenta la historia de este día...',
|
||||
'journey.editor.placePlaceholder': 'Ubicación (opcional)',
|
||||
'journey.editor.tagsPlaceholder':
|
||||
'Etiquetas: joya oculta, mejor comida, hay que volver...',
|
||||
'journey.visibility.private': 'Privado',
|
||||
'journey.visibility.shared': 'Compartido',
|
||||
'journey.visibility.public': 'Público',
|
||||
'journey.emptyState.title': 'Tu historia empieza aquí',
|
||||
'journey.emptyState.subtitle':
|
||||
'Registra una ubicación o escribe tu primera entrada de diario',
|
||||
'journey.frontpage.subtitle':
|
||||
'Convierte tus viajes en historias que nunca olvidarás',
|
||||
'journey.frontpage.createJourney': 'Crear travesía',
|
||||
'journey.frontpage.activeJourney': 'Travesía activa',
|
||||
'journey.frontpage.allJourneys': 'Todas las travesías',
|
||||
'journey.frontpage.journeys': 'travesías',
|
||||
'journey.frontpage.createNew': 'Crear una nueva travesía',
|
||||
'journey.frontpage.createNewSub':
|
||||
'Elige viajes, escribe historias, comparte tus aventuras',
|
||||
'journey.frontpage.live': 'En vivo',
|
||||
'journey.frontpage.synced': 'Sincronizado',
|
||||
'journey.frontpage.continueWriting': 'Seguir escribiendo',
|
||||
'journey.frontpage.updated': 'Actualizado {time}',
|
||||
'journey.frontpage.suggestionLabel': 'El viaje acaba de terminar',
|
||||
'journey.frontpage.suggestionText':
|
||||
'Convierte <strong>{title}</strong> en una travesía',
|
||||
'journey.frontpage.dismiss': 'Descartar',
|
||||
'journey.frontpage.journeyName': 'Nombre de la travesía',
|
||||
'journey.frontpage.namePlaceholder': 'p. ej. Sudeste Asiático 2026',
|
||||
'journey.frontpage.selectTrips': 'Seleccionar viajes',
|
||||
'journey.frontpage.tripsSelected': 'viajes seleccionados',
|
||||
'journey.frontpage.trips': 'viajes',
|
||||
'journey.frontpage.placesImported': 'lugares serán importados',
|
||||
'journey.frontpage.places': 'lugares',
|
||||
'journey.detail.backToJourney': 'Volver a la travesía',
|
||||
'journey.detail.syncedWithTrips': 'Sincronizado con viajes',
|
||||
'journey.detail.addEntry': 'Añadir entrada',
|
||||
'journey.detail.newEntry': 'Nueva entrada',
|
||||
'journey.detail.editEntry': 'Editar entrada',
|
||||
'journey.detail.noEntries': 'Aún no hay entradas',
|
||||
'journey.detail.noEntriesHint':
|
||||
'Añade un viaje para empezar con entradas preliminares',
|
||||
'journey.detail.noPhotos': 'Aún no hay fotos',
|
||||
'journey.detail.noPhotosHint':
|
||||
'Sube fotos a las entradas o explora tu biblioteca de Immich/Synology',
|
||||
'journey.detail.journeyStats': 'Estadísticas de la travesía',
|
||||
'journey.detail.syncedTrips': 'Viajes sincronizados',
|
||||
'journey.detail.noTripsLinked': 'Aún no hay viajes vinculados',
|
||||
'journey.detail.contributors': 'Colaboradores',
|
||||
'journey.detail.readMore': 'Leer más',
|
||||
'journey.detail.prosCons': 'Pros y contras',
|
||||
'journey.detail.photos': 'fotos',
|
||||
'journey.detail.day': 'Día {number}',
|
||||
'journey.detail.places': 'lugares',
|
||||
'journey.stats.days': 'Días',
|
||||
'journey.stats.cities': 'Ciudades',
|
||||
'journey.stats.entries': 'Entradas',
|
||||
'journey.stats.photos': 'Fotos',
|
||||
'journey.stats.places': 'Lugares',
|
||||
'journey.skeletons.show': 'Mostrar sugerencias',
|
||||
'journey.skeletons.hide': 'Ocultar sugerencias',
|
||||
'journey.verdict.lovedIt': 'Me encantó',
|
||||
'journey.verdict.couldBeBetter': 'Podría mejorar',
|
||||
'journey.synced.places': 'lugares',
|
||||
'journey.synced.synced': 'sincronizado',
|
||||
'journey.editor.discardChangesConfirm':
|
||||
'Tienes cambios sin guardar. ¿Descartarlos?',
|
||||
'journey.editor.uploadFailed': 'Error al subir fotos',
|
||||
'journey.editor.uploadPhotos': 'Subir fotos',
|
||||
'journey.editor.uploading': 'Subiendo...',
|
||||
'journey.editor.uploadingProgress': 'Subiendo {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed':
|
||||
'{failed} de {total} fotos fallaron — guarda de nuevo para reintentar',
|
||||
'journey.editor.fromGallery': 'Desde galería',
|
||||
'journey.editor.allPhotosAdded': 'Todas las fotos ya fueron añadidas',
|
||||
'journey.editor.writeStory': 'Escribe tu historia...',
|
||||
'journey.editor.prosCons': 'Pros y contras',
|
||||
'journey.editor.pros': 'Pros',
|
||||
'journey.editor.cons': 'Contras',
|
||||
'journey.editor.proPlaceholder': 'Algo genial...',
|
||||
'journey.editor.conPlaceholder': 'No tan genial...',
|
||||
'journey.editor.addAnother': 'Añadir otro',
|
||||
'journey.editor.date': 'Fecha',
|
||||
'journey.editor.location': 'Ubicación',
|
||||
'journey.editor.searchLocation': 'Buscar ubicación...',
|
||||
'journey.editor.mood': 'Estado de ánimo',
|
||||
'journey.editor.weather': 'Clima',
|
||||
'journey.editor.photoFirst': '1º',
|
||||
'journey.editor.makeFirst': 'Hacer 1º',
|
||||
'journey.editor.searching': 'Buscando...',
|
||||
'journey.mood.amazing': 'Increíble',
|
||||
'journey.mood.good': 'Bien',
|
||||
'journey.mood.neutral': 'Neutral',
|
||||
'journey.mood.rough': 'Difícil',
|
||||
'journey.weather.sunny': 'Soleado',
|
||||
'journey.weather.partly': 'Parcialmente nublado',
|
||||
'journey.weather.cloudy': 'Nublado',
|
||||
'journey.weather.rainy': 'Lluvioso',
|
||||
'journey.weather.stormy': 'Tormentoso',
|
||||
'journey.weather.cold': 'Nevado',
|
||||
'journey.trips.linkTrip': 'Vincular viaje',
|
||||
'journey.trips.searchTrip': 'Buscar viaje',
|
||||
'journey.trips.searchPlaceholder': 'Nombre del viaje o destino...',
|
||||
'journey.trips.noTripsAvailable': 'No hay viajes disponibles',
|
||||
'journey.trips.link': 'Vincular',
|
||||
'journey.trips.tripLinked': 'Viaje vinculado',
|
||||
'journey.trips.linkFailed': 'No se pudo vincular el viaje',
|
||||
'journey.trips.addTrip': 'Añadir viaje',
|
||||
'journey.trips.unlinkTrip': 'Desvincular viaje',
|
||||
'journey.trips.unlinkMessage':
|
||||
'¿Desvincular "{title}"? Todas las entradas y fotos sincronizadas de este viaje se eliminarán permanentemente. Esta acción no se puede deshacer.',
|
||||
'journey.trips.unlink': 'Desvincular',
|
||||
'journey.trips.tripUnlinked': 'Viaje desvinculado',
|
||||
'journey.trips.unlinkFailed': 'No se pudo desvincular el viaje',
|
||||
'journey.trips.noTripsLinkedSettings': 'No hay viajes vinculados',
|
||||
'journey.contributors.invite': 'Invitar colaborador',
|
||||
'journey.contributors.searchUser': 'Buscar usuario',
|
||||
'journey.contributors.searchPlaceholder': 'Nombre de usuario o correo...',
|
||||
'journey.contributors.noUsers': 'No se encontraron usuarios',
|
||||
'journey.contributors.role': 'Rol',
|
||||
'journey.contributors.added': 'Colaborador añadido',
|
||||
'journey.contributors.addFailed': 'No se pudo añadir al colaborador',
|
||||
'journey.share.publicShare': 'Compartir público',
|
||||
'journey.share.createLink': 'Crear enlace para compartir',
|
||||
'journey.share.linkCreated': 'Enlace para compartir creado',
|
||||
'journey.share.createFailed': 'No se pudo crear el enlace',
|
||||
'journey.share.copy': 'Copiar',
|
||||
'journey.share.copied': '¡Copiado!',
|
||||
'journey.share.timeline': 'Cronología',
|
||||
'journey.share.gallery': 'Galería',
|
||||
'journey.share.map': 'Mapa',
|
||||
'journey.share.removeLink': 'Eliminar enlace para compartir',
|
||||
'journey.share.linkDeleted': 'Enlace para compartir eliminado',
|
||||
'journey.share.deleteFailed': 'No se pudo eliminar',
|
||||
'journey.share.updateFailed': 'No se pudo actualizar',
|
||||
'journey.invite.role': 'Rol',
|
||||
'journey.invite.viewer': 'Lector',
|
||||
'journey.invite.editor': 'Editor',
|
||||
'journey.invite.invite': 'Invitar',
|
||||
'journey.invite.inviting': 'Invitando...',
|
||||
'journey.settings.title': 'Ajustes de la travesía',
|
||||
'journey.settings.coverImage': 'Imagen de portada',
|
||||
'journey.settings.changeCover': 'Cambiar portada',
|
||||
'journey.settings.addCover': 'Añadir imagen de portada',
|
||||
'journey.settings.name': 'Nombre',
|
||||
'journey.settings.subtitle': 'Subtítulo',
|
||||
'journey.settings.subtitlePlaceholder': 'p. ej. Tailandia, Vietnam y Camboya',
|
||||
'journey.settings.endJourney': 'Archivar viaje',
|
||||
'journey.settings.reopenJourney': 'Restaurar viaje',
|
||||
'journey.settings.archived': 'Viaje archivado',
|
||||
'journey.settings.reopened': 'Viaje reabierto',
|
||||
'journey.settings.endDescription':
|
||||
'Oculta la insignia En Vivo. Puedes reabrirlo en cualquier momento.',
|
||||
'journey.settings.delete': 'Eliminar',
|
||||
'journey.settings.deleteJourney': 'Eliminar travesía',
|
||||
'journey.settings.deleteMessage':
|
||||
'¿Eliminar "{title}"? Todas las entradas y fotos se perderán.',
|
||||
'journey.settings.saved': 'Ajustes guardados',
|
||||
'journey.settings.saveFailed': 'No se pudo guardar',
|
||||
'journey.settings.coverUpdated': 'Portada actualizada',
|
||||
'journey.settings.coverFailed': 'Error al subir',
|
||||
'journey.settings.failedToDelete': 'Error al eliminar',
|
||||
'journey.entries.deleteTitle': 'Eliminar entrada',
|
||||
'journey.photosUploaded': '{count} fotos subidas',
|
||||
'journey.photosUploadFailed': 'Algunas fotos no se pudieron subir',
|
||||
'journey.photosAdded': '{count} fotos añadidas',
|
||||
'journey.public.notFound': 'No encontrado',
|
||||
'journey.public.notFoundMessage':
|
||||
'Esta travesía no existe o el enlace ha expirado.',
|
||||
'journey.public.readOnly': 'Solo lectura · Travesía pública',
|
||||
'journey.public.tagline': 'Kit de recursos y exploración de viajes',
|
||||
'journey.public.sharedVia': 'Compartido mediante',
|
||||
'journey.public.madeWith': 'Hecho con',
|
||||
'journey.pdf.journeyBook': 'Libro de travesía',
|
||||
'journey.pdf.madeWith': 'Hecho con TREK',
|
||||
'journey.pdf.day': 'Día',
|
||||
'journey.pdf.theEnd': 'Fin',
|
||||
'journey.pdf.saveAsPdf': 'Guardar como PDF',
|
||||
'journey.pdf.pages': 'páginas',
|
||||
'journey.picker.tripPeriod': 'Período del viaje',
|
||||
'journey.picker.dateRange': 'Rango de fechas',
|
||||
'journey.picker.allPhotos': 'Todas las fotos',
|
||||
'journey.picker.albums': 'Álbumes',
|
||||
'journey.picker.selected': 'seleccionados',
|
||||
'journey.picker.addTo': 'Añadir a',
|
||||
'journey.picker.newGallery': 'Nueva galería',
|
||||
'journey.picker.selectAll': 'Seleccionar todo',
|
||||
'journey.picker.deselectAll': 'Deseleccionar todo',
|
||||
'journey.picker.noAlbums': 'No se encontraron álbumes',
|
||||
'journey.picker.selectDate': 'Seleccionar fecha',
|
||||
'journey.picker.search': 'Buscar',
|
||||
'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;
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const login: TranslationStrings = {
|
||||
'login.error': 'Inicio de sesión fallido. Revisa tus credenciales.',
|
||||
'login.tagline': 'Tus viajes.\nTu plan.',
|
||||
'login.description':
|
||||
'Planifica viajes en colaboración con mapas interactivos, presupuestos y sincronización en tiempo real.',
|
||||
'login.features.maps': 'Mapas interactivos',
|
||||
'login.features.mapsDesc': 'Google Places, rutas y agrupación',
|
||||
'login.features.realtime': 'Sincronización en tiempo real',
|
||||
'login.features.realtimeDesc': 'Planificad juntos mediante WebSocket',
|
||||
'login.features.budget': 'Control de presupuesto',
|
||||
'login.features.budgetDesc': 'Categorías, gráficos y costes por persona',
|
||||
'login.features.collab': 'Colaboración',
|
||||
'login.features.collabDesc': 'Multiusuario con viajes compartidos',
|
||||
'login.features.packing': 'Listas de equipaje',
|
||||
'login.features.packingDesc': 'Categorías, progreso y sugerencias',
|
||||
'login.features.bookings': 'Reservas',
|
||||
'login.features.bookingsDesc': 'Vuelos, hoteles, restaurantes y más',
|
||||
'login.features.files': 'Documentos',
|
||||
'login.features.filesDesc': 'Sube y gestiona documentos',
|
||||
'login.features.routes': 'Rutas inteligentes',
|
||||
'login.features.routesDesc':
|
||||
'Optimización automática y exportación a Google Maps',
|
||||
'login.selfHosted':
|
||||
'Autoalojado · Código abierto · Tus datos siguen siendo tuyos',
|
||||
'login.title': 'Iniciar sesión',
|
||||
'login.subtitle': 'Bienvenido de nuevo',
|
||||
'login.signingIn': 'Iniciando sesión…',
|
||||
'login.signIn': 'Entrar',
|
||||
'login.createAdmin': 'Crear cuenta de administrador',
|
||||
'login.createAdminHint':
|
||||
'Configura la primera cuenta administradora de TREK.',
|
||||
'login.setNewPassword': 'Establecer nueva contraseña',
|
||||
'login.setNewPasswordHint': 'Debe cambiar su contraseña antes de continuar.',
|
||||
'login.createAccount': 'Crear cuenta',
|
||||
'login.createAccountHint': 'Crea una cuenta nueva.',
|
||||
'login.creating': 'Creando…',
|
||||
'login.noAccount': '¿No tienes cuenta?',
|
||||
'login.hasAccount': '¿Ya tienes cuenta?',
|
||||
'login.register': 'Registrarse',
|
||||
'login.emailPlaceholder': 'tu@correo.com',
|
||||
'login.username': 'Usuario',
|
||||
'login.oidc.registrationDisabled':
|
||||
'El registro está desactivado. Contacta con tu administrador.',
|
||||
'login.oidc.noEmail': 'No se recibió ningún correo del proveedor.',
|
||||
'login.mfaTitle': 'Autenticación de dos factores',
|
||||
'login.mfaSubtitle':
|
||||
'Introduce el código de 6 dígitos de tu app de autenticación.',
|
||||
'login.mfaCodeLabel': 'Código de verificación',
|
||||
'login.mfaCodeRequired': 'Introduce el código de tu app de autenticación.',
|
||||
'login.mfaHint': 'Abre Google Authenticator, Authy u otra app TOTP.',
|
||||
'login.mfaBack': '← Volver al inicio de sesión',
|
||||
'login.mfaVerify': 'Verificar',
|
||||
'login.invalidInviteLink': 'Enlace de invitación inválido o expirado',
|
||||
'login.oidcFailed': 'Error de inicio de sesión OIDC',
|
||||
'login.usernameRequired': 'El nombre de usuario es obligatorio',
|
||||
'login.passwordMinLength': 'La contraseña debe tener al menos 8 caracteres',
|
||||
'login.forgotPassword': '¿Olvidaste tu contraseña?',
|
||||
'login.rememberMe': 'Recuérdame',
|
||||
'login.forgotPasswordTitle': 'Restablecer tu contraseña',
|
||||
'login.forgotPasswordBody':
|
||||
'Introduce la dirección de correo con la que te registraste. Si existe una cuenta, enviaremos un enlace.',
|
||||
'login.forgotPasswordSubmit': 'Enviar enlace',
|
||||
'login.forgotPasswordSentTitle': 'Revisa tu correo',
|
||||
'login.forgotPasswordSentBody':
|
||||
'Si existe una cuenta con ese correo, el enlace de restablecimiento está en camino. Caduca en 60 minutos.',
|
||||
'login.forgotPasswordSmtpHintOff':
|
||||
'Nota: tu administrador no ha configurado SMTP, así que el enlace de restablecimiento se escribirá en la consola del servidor en lugar de enviarse por correo.',
|
||||
'login.backToLogin': 'Volver al inicio de sesión',
|
||||
'login.newPassword': 'Nueva contraseña',
|
||||
'login.confirmPassword': 'Confirmar nueva contraseña',
|
||||
'login.passwordsDontMatch': 'Las contraseñas no coinciden',
|
||||
'login.mfaCode': 'Código 2FA',
|
||||
'login.resetPasswordTitle': 'Establecer una nueva contraseña',
|
||||
'login.resetPasswordBody':
|
||||
'Elige una contraseña segura que no hayas usado aquí antes. Mínimo 8 caracteres.',
|
||||
'login.resetPasswordMfaBody':
|
||||
'Introduce tu código 2FA o un código de respaldo para completar el restablecimiento.',
|
||||
'login.resetPasswordSubmit': 'Restablecer contraseña',
|
||||
'login.resetPasswordVerify': 'Verificar y restablecer',
|
||||
'login.resetPasswordSuccessTitle': 'Contraseña actualizada',
|
||||
'login.resetPasswordSuccessBody':
|
||||
'Ya puedes iniciar sesión con tu nueva contraseña.',
|
||||
'login.resetPasswordInvalidLink': 'Enlace de restablecimiento no válido',
|
||||
'login.resetPasswordInvalidLinkBody':
|
||||
'Este enlace falta o está roto. Solicita uno nuevo para continuar.',
|
||||
'login.resetPasswordFailed':
|
||||
'Restablecimiento fallido. El enlace puede haber caducado.',
|
||||
'login.oidc.tokenFailed': 'La autenticación falló.',
|
||||
'login.oidc.invalidState': 'Sesión no válida. Inténtalo de nuevo.',
|
||||
'login.demoFailed': 'Falló el acceso a la demo',
|
||||
'login.oidcSignIn': 'Entrar con {name}',
|
||||
'login.demoHint': 'Prueba la demo: no necesitas registrarte',
|
||||
'login.oidcOnly':
|
||||
'La autenticación por contraseña está desactivada. Por favor, inicia sesión con tu proveedor SSO.',
|
||||
'login.oidcLoggedOut':
|
||||
'Has cerrado sesión. Vuelve a iniciar sesión con tu proveedor SSO.',
|
||||
'login.passkey.signIn': 'Iniciar sesión con una passkey',
|
||||
'login.passkey.failed':
|
||||
'Error al iniciar sesión con la passkey. Inténtalo de nuevo.',
|
||||
};
|
||||
export default login;
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const map: TranslationStrings = {
|
||||
'map.connections': 'Conexiones',
|
||||
'map.showConnections': 'Mostrar rutas de reservas',
|
||||
'map.hideConnections': 'Ocultar rutas de reservas',
|
||||
'poi.searchThisArea': 'Buscar en esta zona',
|
||||
'poi.cat.restaurants': 'Restaurantes',
|
||||
'poi.cat.cafes': 'Cafés',
|
||||
'poi.cat.bars': 'Bares y ocio nocturno',
|
||||
'poi.cat.hotels': 'Alojamiento',
|
||||
'poi.cat.sights': 'Lugares de interés',
|
||||
'poi.cat.museums': 'Museos y cultura',
|
||||
'poi.cat.nature': 'Naturaleza y parques',
|
||||
'poi.cat.activities': 'Actividades',
|
||||
};
|
||||
export default map;
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const members: TranslationStrings = {
|
||||
'members.shareTrip': 'Compartir viaje',
|
||||
'members.inviteUser': 'Invitar usuario',
|
||||
'members.selectUser': 'Seleccionar usuario…',
|
||||
'members.invite': 'Invitar',
|
||||
'members.allHaveAccess': 'Todos los usuarios ya tienen acceso.',
|
||||
'members.access': 'Acceso',
|
||||
'members.person': 'persona',
|
||||
'members.persons': 'personas',
|
||||
'members.you': 'tú',
|
||||
'members.owner': 'Propietario',
|
||||
'members.leaveTrip': 'Abandonar viaje',
|
||||
'members.removeAccess': 'Quitar acceso',
|
||||
'members.confirmLeave': '¿Abandonar el viaje? Perderás el acceso.',
|
||||
'members.confirmRemove': '¿Quitar el acceso de este usuario?',
|
||||
'members.loadError': 'No se pudieron cargar los miembros',
|
||||
'members.added': 'añadido',
|
||||
'members.addError': 'No se pudo añadir',
|
||||
'members.removed': 'Miembro eliminado',
|
||||
'members.removeError': 'No se pudo eliminar',
|
||||
};
|
||||
export default members;
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const memories: TranslationStrings = {
|
||||
'memories.title': 'Fotos',
|
||||
'memories.notConnected': 'Immich no conectado',
|
||||
'memories.notConnectedHint':
|
||||
'Conecta tu instancia de Immich en Ajustes para ver tus fotos de viaje aquí.',
|
||||
'memories.notConnectedMultipleHint':
|
||||
'Conecta alguno de estos proveedores de fotos: {provider_names} en Configuración para poder añadir fotos a este viaje.',
|
||||
'memories.noDates': 'Añade fechas a tu viaje para cargar fotos.',
|
||||
'memories.noPhotos': 'No se encontraron fotos',
|
||||
'memories.noPhotosHint':
|
||||
'No se encontraron fotos en Immich para el rango de fechas de este viaje.',
|
||||
'memories.photosFound': 'fotos',
|
||||
'memories.fromOthers': 'de otros',
|
||||
'memories.sharePhotos': 'Compartir fotos',
|
||||
'memories.sharing': 'Compartiendo',
|
||||
'memories.reviewTitle': 'Revisar tus fotos',
|
||||
'memories.reviewHint': 'Haz clic en las fotos para excluirlas de compartir.',
|
||||
'memories.shareCount': 'Compartir {count} fotos',
|
||||
'memories.providerUrl': 'URL del servidor',
|
||||
'memories.providerApiKey': 'Clave API',
|
||||
'memories.providerUsername': 'Nombre de usuario',
|
||||
'memories.providerPassword': 'Contraseña',
|
||||
'memories.providerOTP': 'Código MFA (si está habilitado)',
|
||||
'memories.skipSSLVerification': 'Omitir verificación del certificado SSL',
|
||||
'memories.immichAutoUpload':
|
||||
'Duplicar las fotos del journey en Immich al subirlas',
|
||||
'memories.providerUrlHintSynology':
|
||||
'Incluye la ruta de la aplicación Photos en la URL, p.ej. https://nas:5001/photo',
|
||||
'memories.testConnection': 'Probar conexión',
|
||||
'memories.testShort': 'Probar',
|
||||
'memories.testFirst': 'Probar conexión primero',
|
||||
'memories.connected': 'Conectado',
|
||||
'memories.disconnected': 'No conectado',
|
||||
'memories.connectionSuccess': 'Conectado a Immich',
|
||||
'memories.connectionError': 'No se pudo conectar a Immich',
|
||||
'memories.saved': 'Configuración de {provider_name} guardada',
|
||||
'memories.providerDisconnectedBanner':
|
||||
'Se perdió la conexión con {provider_name}. Vuelve a conectar en Configuración para ver las fotos.',
|
||||
'memories.saveError': 'No se pudieron guardar los ajustes de {provider_name}',
|
||||
'memories.saveRouteNotConfigured':
|
||||
'La ruta de guardado no está configurada para este proveedor',
|
||||
'memories.testRouteNotConfigured':
|
||||
'La ruta de prueba no está configurada para este proveedor',
|
||||
'memories.fillRequiredFields':
|
||||
'Por favor complete todos los campos requeridos',
|
||||
'memories.oldest': 'Más antiguas',
|
||||
'memories.newest': 'Más recientes',
|
||||
'memories.allLocations': 'Todas las ubicaciones',
|
||||
'memories.addPhotos': 'Añadir fotos',
|
||||
'memories.linkAlbum': 'Vincular álbum',
|
||||
'memories.selectAlbum': 'Seleccionar álbum de Immich',
|
||||
'memories.selectAlbumMultiple': 'Seleccionar álbum',
|
||||
'memories.noAlbums': 'No se encontraron álbumes',
|
||||
'memories.syncAlbum': 'Sincronizar álbum',
|
||||
'memories.unlinkAlbum': 'Desvincular',
|
||||
'memories.photos': 'fotos',
|
||||
'memories.selectPhotos': 'Seleccionar fotos de Immich',
|
||||
'memories.selectPhotosMultiple': 'Seleccionar fotos',
|
||||
'memories.selectHint': 'Toca las fotos para seleccionarlas.',
|
||||
'memories.selected': 'seleccionado(s)',
|
||||
'memories.addSelected': 'Añadir {count} fotos',
|
||||
'memories.alreadyAdded': 'Añadido',
|
||||
'memories.private': 'Privado',
|
||||
'memories.stopSharing': 'Dejar de compartir',
|
||||
'memories.tripDates': 'Fechas del viaje',
|
||||
'memories.allPhotos': 'Todas las fotos',
|
||||
'memories.confirmShareTitle': '¿Compartir con los miembros del viaje?',
|
||||
'memories.confirmShareHint':
|
||||
'{count} fotos serán visibles para todos los miembros de este viaje. Puedes hacer fotos individuales privadas más tarde.',
|
||||
'memories.confirmShareButton': 'Compartir fotos',
|
||||
'memories.error.loadAlbums': 'Error al cargar los álbumes',
|
||||
'memories.error.linkAlbum': 'Error al vincular el álbum',
|
||||
'memories.error.unlinkAlbum': 'Error al desvincular el álbum',
|
||||
'memories.error.syncAlbum': 'Error al sincronizar el álbum',
|
||||
'memories.error.loadPhotos': 'Error al cargar las fotos',
|
||||
'memories.error.addPhotos': 'Error al agregar las fotos',
|
||||
'memories.error.removePhoto': 'Error al eliminar la foto',
|
||||
'memories.error.toggleSharing': 'Error al actualizar el uso compartido',
|
||||
};
|
||||
export default memories;
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const nav: TranslationStrings = {
|
||||
'nav.trip': 'Viaje',
|
||||
'nav.share': 'Compartir',
|
||||
'nav.settings': 'Ajustes',
|
||||
'nav.admin': 'Administración',
|
||||
'nav.logout': 'Cerrar sesión',
|
||||
'nav.lightMode': 'Modo claro',
|
||||
'nav.darkMode': 'Modo oscuro',
|
||||
'nav.autoMode': 'Modo automático',
|
||||
'nav.administrator': 'Administrador',
|
||||
'nav.myTrips': 'Mis viajes',
|
||||
'nav.profile': 'Perfil',
|
||||
'nav.bottomSettings': 'Ajustes',
|
||||
'nav.bottomAdmin': 'Administración',
|
||||
'nav.bottomLogout': 'Cerrar sesión',
|
||||
'nav.bottomAdminBadge': 'Admin',
|
||||
};
|
||||
export default nav;
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const notif: TranslationStrings = {
|
||||
'notif.test.title': '[Test] Notificación',
|
||||
'notif.test.simple.text': 'Esta es una notificación de prueba simple.',
|
||||
'notif.test.boolean.text': '¿Aceptas esta notificación de prueba?',
|
||||
'notif.test.navigate.text': 'Haz clic abajo para ir al panel de control.',
|
||||
'notif.trip_invite.title': 'Invitación al viaje',
|
||||
'notif.trip_invite.text': '{actor} te invitó a {trip}',
|
||||
'notif.booking_change.title': 'Reserva actualizada',
|
||||
'notif.booking_change.text': '{actor} actualizó una reserva en {trip}',
|
||||
'notif.trip_reminder.title': 'Recordatorio de viaje',
|
||||
'notif.trip_reminder.text': '¡Tu viaje {trip} se acerca!',
|
||||
'notif.todo_due.title': 'Tarea pendiente',
|
||||
'notif.todo_due.text': '{todo} en {trip} vence el {due}',
|
||||
'notif.vacay_invite.title': 'Invitación Vacay Fusion',
|
||||
'notif.vacay_invite.text':
|
||||
'{actor} te invitó a fusionar planes de vacaciones',
|
||||
'notif.photos_shared.title': 'Fotos compartidas',
|
||||
'notif.photos_shared.text': '{actor} compartió {count} foto(s) en {trip}',
|
||||
'notif.collab_message.title': 'Nuevo mensaje',
|
||||
'notif.collab_message.text': '{actor} envió un mensaje en {trip}',
|
||||
'notif.packing_tagged.title': 'Asignación de equipaje',
|
||||
'notif.packing_tagged.text': '{actor} te asignó a {category} en {trip}',
|
||||
'notif.version_available.title': 'Nueva versión disponible',
|
||||
'notif.version_available.text': 'TREK {version} ya está disponible',
|
||||
'notif.action.view_trip': 'Ver viaje',
|
||||
'notif.action.view_collab': 'Ver mensajes',
|
||||
'notif.action.view_packing': 'Ver equipaje',
|
||||
'notif.action.view_photos': 'Ver fotos',
|
||||
'notif.action.view_vacay': 'Ver Vacay',
|
||||
'notif.action.view_admin': 'Ir al admin',
|
||||
'notif.action.view': 'Ver',
|
||||
'notif.action.accept': 'Aceptar',
|
||||
'notif.action.decline': 'Rechazar',
|
||||
'notif.generic.title': 'Notificación',
|
||||
'notif.generic.text': 'Tienes una nueva notificación',
|
||||
'notif.dev.unknown_event.title': '[DEV] Evento desconocido',
|
||||
'notif.dev.unknown_event.text':
|
||||
'El tipo de evento "{event}" no está registrado en EVENT_NOTIFICATION_CONFIG',
|
||||
};
|
||||
export default notif;
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const notifications: TranslationStrings = {
|
||||
'notifications.title': 'Notificaciones',
|
||||
'notifications.markAllRead': 'Marcar todo como leído',
|
||||
'notifications.deleteAll': 'Eliminar todo',
|
||||
'notifications.showAll': 'Ver todas las notificaciones',
|
||||
'notifications.empty': 'Sin notificaciones',
|
||||
'notifications.emptyDescription': '¡Estás al día!',
|
||||
'notifications.all': 'Todas',
|
||||
'notifications.unreadOnly': 'No leídas',
|
||||
'notifications.markRead': 'Marcar como leída',
|
||||
'notifications.markUnread': 'Marcar como no leída',
|
||||
'notifications.delete': 'Eliminar',
|
||||
'notifications.system': 'Sistema',
|
||||
'notifications.synologySessionCleared.title': 'Synology Photos desconectado',
|
||||
'notifications.synologySessionCleared.text':
|
||||
'Tu servidor o cuenta ha cambiado — ve a Configuración para probar la conexión de nuevo.',
|
||||
'notifications.test.title': 'Notificación de prueba de {actor}',
|
||||
'notifications.test.text': 'Esta es una notificación de prueba simple.',
|
||||
'notifications.test.booleanTitle': '{actor} solicita tu aprobación',
|
||||
'notifications.test.booleanText': 'Notificación de prueba booleana.',
|
||||
'notifications.test.accept': 'Aprobar',
|
||||
'notifications.test.decline': 'Rechazar',
|
||||
'notifications.test.navigateTitle': 'Mira esto',
|
||||
'notifications.test.navigateText': 'Notificación de prueba de navegación.',
|
||||
'notifications.test.goThere': 'Ir allí',
|
||||
'notifications.test.adminTitle': 'Difusión de administrador',
|
||||
'notifications.test.adminText':
|
||||
'{actor} envió una notificación de prueba a todos los administradores.',
|
||||
'notifications.test.tripTitle': '{actor} publicó en tu viaje',
|
||||
'notifications.test.tripText':
|
||||
'Notificación de prueba para el viaje "{trip}".',
|
||||
'notifications.versionAvailable.title': 'Actualización disponible',
|
||||
'notifications.versionAvailable.text': 'TREK {version} ya está disponible.',
|
||||
'notifications.versionAvailable.button': 'Ver detalles',
|
||||
};
|
||||
export default notifications;
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const oauth: TranslationStrings = {
|
||||
'oauth.scope.group.trips': 'Viajes',
|
||||
'oauth.scope.group.places': 'Lugares',
|
||||
'oauth.scope.group.atlas': 'Atlas',
|
||||
'oauth.scope.group.packing': 'Equipaje',
|
||||
'oauth.scope.group.todos': 'Tareas',
|
||||
'oauth.scope.group.budget': 'Presupuesto',
|
||||
'oauth.scope.group.reservations': 'Reservas',
|
||||
'oauth.scope.group.collab': 'Colaboración',
|
||||
'oauth.scope.group.notifications': 'Notificaciones',
|
||||
'oauth.scope.group.vacay': 'Vacaciones',
|
||||
'oauth.scope.group.geo': 'Geo',
|
||||
'oauth.scope.group.weather': 'Clima',
|
||||
'oauth.scope.group.journey': 'Travesía',
|
||||
'oauth.scope.trips:read.label': 'Ver viajes e itinerarios',
|
||||
'oauth.scope.trips:read.description': 'Leer viajes, días, notas y miembros',
|
||||
'oauth.scope.trips:write.label': 'Editar viajes e itinerarios',
|
||||
'oauth.scope.trips:write.description':
|
||||
'Crear y actualizar viajes, días, notas y gestionar miembros',
|
||||
'oauth.scope.trips:delete.label': 'Eliminar viajes',
|
||||
'oauth.scope.trips:delete.description':
|
||||
'Eliminar viajes permanentemente — esta acción es irreversible',
|
||||
'oauth.scope.trips:share.label': 'Gestionar enlaces de compartir',
|
||||
'oauth.scope.trips:share.description':
|
||||
'Crear, actualizar y revocar enlaces públicos de viaje',
|
||||
'oauth.scope.places:read.label': 'Ver lugares y datos del mapa',
|
||||
'oauth.scope.places:read.description':
|
||||
'Leer lugares, asignaciones de días, etiquetas y categorías',
|
||||
'oauth.scope.places:write.label': 'Gestionar lugares',
|
||||
'oauth.scope.places:write.description':
|
||||
'Crear, actualizar y eliminar lugares, asignaciones y etiquetas',
|
||||
'oauth.scope.atlas:read.label': 'Ver Atlas',
|
||||
'oauth.scope.atlas:read.description':
|
||||
'Leer países visitados, regiones y lista de deseos',
|
||||
'oauth.scope.atlas:write.label': 'Gestionar Atlas',
|
||||
'oauth.scope.atlas:write.description':
|
||||
'Marcar países y regiones como visitados, gestionar lista de deseos',
|
||||
'oauth.scope.packing:read.label': 'Ver listas de equipaje',
|
||||
'oauth.scope.packing:read.description':
|
||||
'Leer artículos, maletas y responsables de categoría',
|
||||
'oauth.scope.packing:write.label': 'Gestionar listas de equipaje',
|
||||
'oauth.scope.packing:write.description':
|
||||
'Agregar, actualizar, eliminar, marcar y reordenar artículos y maletas',
|
||||
'oauth.scope.todos:read.label': 'Ver listas de tareas',
|
||||
'oauth.scope.todos:read.description':
|
||||
'Leer tareas del viaje y responsables de categoría',
|
||||
'oauth.scope.todos:write.label': 'Gestionar listas de tareas',
|
||||
'oauth.scope.todos:write.description':
|
||||
'Crear, actualizar, marcar, eliminar y reordenar tareas',
|
||||
'oauth.scope.budget:read.label': 'Ver presupuesto',
|
||||
'oauth.scope.budget:read.description':
|
||||
'Leer partidas de presupuesto y desglose de gastos',
|
||||
'oauth.scope.budget:write.label': 'Gestionar presupuesto',
|
||||
'oauth.scope.budget:write.description':
|
||||
'Crear, actualizar y eliminar partidas de presupuesto',
|
||||
'oauth.scope.reservations:read.label': 'Ver reservas',
|
||||
'oauth.scope.reservations:read.description':
|
||||
'Leer reservas y detalles de alojamiento',
|
||||
'oauth.scope.reservations:write.label': 'Gestionar reservas',
|
||||
'oauth.scope.reservations:write.description':
|
||||
'Crear, actualizar, eliminar y reordenar reservas',
|
||||
'oauth.scope.collab:read.label': 'Ver colaboración',
|
||||
'oauth.scope.collab:read.description':
|
||||
'Leer notas colaborativas, encuestas y mensajes',
|
||||
'oauth.scope.collab:write.label': 'Gestionar colaboración',
|
||||
'oauth.scope.collab:write.description':
|
||||
'Crear, actualizar y eliminar notas, encuestas y mensajes',
|
||||
'oauth.scope.notifications:read.label': 'Ver notificaciones',
|
||||
'oauth.scope.notifications:read.description':
|
||||
'Leer notificaciones y conteos no leídos',
|
||||
'oauth.scope.notifications:write.label': 'Gestionar notificaciones',
|
||||
'oauth.scope.notifications:write.description':
|
||||
'Marcar notificaciones como leídas y responderlas',
|
||||
'oauth.scope.vacay:read.label': 'Ver planes de vacaciones',
|
||||
'oauth.scope.vacay:read.description':
|
||||
'Leer datos de planificación, entradas y estadísticas de vacaciones',
|
||||
'oauth.scope.vacay:write.label': 'Gestionar planes de vacaciones',
|
||||
'oauth.scope.vacay:write.description':
|
||||
'Crear y gestionar entradas de vacaciones, festivos y planes de equipo',
|
||||
'oauth.scope.geo:read.label': 'Mapas y geocodificación',
|
||||
'oauth.scope.geo:read.description':
|
||||
'Buscar lugares, resolver URLs de mapa y geocodificar coordenadas',
|
||||
'oauth.scope.weather:read.label': 'Previsiones meteorológicas',
|
||||
'oauth.scope.weather:read.description':
|
||||
'Obtener previsiones meteorológicas para lugares y fechas del viaje',
|
||||
'oauth.scope.journey:read.label': 'Ver travesías',
|
||||
'oauth.scope.journey:read.description':
|
||||
'Leer travesías, entradas y lista de colaboradores',
|
||||
'oauth.scope.journey:write.label': 'Gestionar travesías',
|
||||
'oauth.scope.journey:write.description':
|
||||
'Crear, actualizar y eliminar travesías y sus entradas',
|
||||
'oauth.scope.journey:share.label': 'Gestionar enlaces de travesías',
|
||||
'oauth.scope.journey:share.description':
|
||||
'Crear, actualizar y revocar enlaces públicos de compartir para travesías',
|
||||
'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;
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const packing: TranslationStrings = {
|
||||
'packing.title': 'Lista de equipaje',
|
||||
'packing.empty': 'La lista de equipaje está vacía',
|
||||
'packing.import': 'Importar',
|
||||
'packing.importTitle': 'Importar lista de equipaje',
|
||||
'packing.importHint':
|
||||
'Un elemento por línea. Categoría y cantidad opcionales separadas por coma, punto y coma o tabulación: Nombre, Categoría, Cantidad',
|
||||
'packing.importPlaceholder':
|
||||
'Cepillo de dientes\nProtector solar, Higiene\nCamisetas, Ropa, 5\nPasaporte, Documentos',
|
||||
'packing.importCsv': 'Cargar CSV/TXT',
|
||||
'packing.importAction': 'Importar {count}',
|
||||
'packing.importSuccess': '{count} elementos importados',
|
||||
'packing.importError': 'Error al importar',
|
||||
'packing.importEmpty': 'Sin elementos para importar',
|
||||
'packing.progress': '{packed} de {total} preparados ({percent}%)',
|
||||
'packing.clearChecked': 'Eliminar {count} marcados',
|
||||
'packing.clearCheckedShort': 'Eliminar {count}',
|
||||
'packing.suggestions': 'Sugerencias',
|
||||
'packing.suggestionsTitle': 'Añadir sugerencias',
|
||||
'packing.allSuggested': 'Todas las sugerencias añadidas',
|
||||
'packing.allPacked': '¡Todo preparado!',
|
||||
'packing.addPlaceholder': 'Añadir nuevo elemento...',
|
||||
'packing.categoryPlaceholder': 'Categoría...',
|
||||
'packing.filterAll': 'Todo',
|
||||
'packing.filterOpen': 'Pendientes',
|
||||
'packing.filterDone': 'Hecho',
|
||||
'packing.emptyTitle': 'La lista de equipaje está vacía',
|
||||
'packing.emptyHint': 'Añade elementos o usa las sugerencias',
|
||||
'packing.emptyFiltered': 'Ningún elemento coincide con este filtro',
|
||||
'packing.menuRename': 'Renombrar',
|
||||
'packing.menuCheckAll': 'Marcar todo',
|
||||
'packing.menuUncheckAll': 'Desmarcar todo',
|
||||
'packing.menuDeleteCat': 'Eliminar categoría',
|
||||
'packing.addItem': 'Añadir artículo',
|
||||
'packing.addItemPlaceholder': 'Nombre del artículo...',
|
||||
'packing.addCategory': 'Añadir categoría',
|
||||
'packing.newCategoryPlaceholder': 'Nombre de categoría (ej. Ropa)',
|
||||
'packing.applyTemplate': 'Aplicar plantilla',
|
||||
'packing.template': 'Plantilla',
|
||||
'packing.templateApplied': '{count} artículos añadidos desde plantilla',
|
||||
'packing.templateError': 'Error al aplicar plantilla',
|
||||
'packing.saveAsTemplate': 'Guardar como plantilla',
|
||||
'packing.templateName': 'Nombre de la plantilla',
|
||||
'packing.templateSaved': 'Lista de equipaje guardada como plantilla',
|
||||
'packing.noMembers': 'Sin miembros',
|
||||
'packing.bags': 'Equipaje',
|
||||
'packing.noBag': 'Sin asignar',
|
||||
'packing.totalWeight': 'Peso total',
|
||||
'packing.bagName': 'Nombre...',
|
||||
'packing.addBag': 'Añadir equipaje',
|
||||
'packing.changeCategory': 'Cambiar categoría',
|
||||
'packing.confirm.clearChecked':
|
||||
'¿Seguro que quieres eliminar {count} elementos marcados?',
|
||||
'packing.confirm.deleteCat':
|
||||
'¿Seguro que quieres eliminar la categoría "{name}" con {count} elementos?',
|
||||
'packing.defaultCategory': 'Otros',
|
||||
'packing.toast.saveError': 'No se pudo guardar',
|
||||
'packing.toast.deleteError': 'No se pudo eliminar',
|
||||
'packing.toast.renameError': 'No se pudo renombrar',
|
||||
'packing.toast.addError': 'No se pudo añadir',
|
||||
'packing.suggestions.items': [
|
||||
{
|
||||
name: 'Pasaporte',
|
||||
category: 'Documentos',
|
||||
},
|
||||
{
|
||||
name: 'Documento de identidad',
|
||||
category: 'Documentos',
|
||||
},
|
||||
{
|
||||
name: 'Seguro de viaje',
|
||||
category: 'Documentos',
|
||||
},
|
||||
{
|
||||
name: 'Billetes de vuelo',
|
||||
category: 'Documentos',
|
||||
},
|
||||
{
|
||||
name: 'Tarjeta de crédito',
|
||||
category: 'Finanzas',
|
||||
},
|
||||
{
|
||||
name: 'Efectivo',
|
||||
category: 'Finanzas',
|
||||
},
|
||||
{
|
||||
name: 'Visado',
|
||||
category: 'Documentos',
|
||||
},
|
||||
{
|
||||
name: 'Camisetas',
|
||||
category: 'Ropa',
|
||||
},
|
||||
{
|
||||
name: 'Pantalones',
|
||||
category: 'Ropa',
|
||||
},
|
||||
{
|
||||
name: 'Ropa interior',
|
||||
category: 'Ropa',
|
||||
},
|
||||
{
|
||||
name: 'Calcetines',
|
||||
category: 'Ropa',
|
||||
},
|
||||
{
|
||||
name: 'Chaqueta',
|
||||
category: 'Ropa',
|
||||
},
|
||||
{
|
||||
name: 'Pijama',
|
||||
category: 'Ropa',
|
||||
},
|
||||
{
|
||||
name: 'Ropa de baño',
|
||||
category: 'Ropa',
|
||||
},
|
||||
{
|
||||
name: 'Impermeable',
|
||||
category: 'Ropa',
|
||||
},
|
||||
{
|
||||
name: 'Zapatos cómodos',
|
||||
category: 'Ropa',
|
||||
},
|
||||
{
|
||||
name: 'Cepillo de dientes',
|
||||
category: 'Aseo',
|
||||
},
|
||||
{
|
||||
name: 'Pasta de dientes',
|
||||
category: 'Aseo',
|
||||
},
|
||||
{
|
||||
name: 'Champú',
|
||||
category: 'Aseo',
|
||||
},
|
||||
{
|
||||
name: 'Desodorante',
|
||||
category: 'Aseo',
|
||||
},
|
||||
{
|
||||
name: 'Protector solar',
|
||||
category: 'Aseo',
|
||||
},
|
||||
{
|
||||
name: 'Maquinilla de afeitar',
|
||||
category: 'Aseo',
|
||||
},
|
||||
{
|
||||
name: 'Cargador',
|
||||
category: 'Electrónica',
|
||||
},
|
||||
{
|
||||
name: 'Batería externa',
|
||||
category: 'Electrónica',
|
||||
},
|
||||
{
|
||||
name: 'Auriculares',
|
||||
category: 'Electrónica',
|
||||
},
|
||||
{
|
||||
name: 'Adaptador de viaje',
|
||||
category: 'Electrónica',
|
||||
},
|
||||
{
|
||||
name: 'Cámara',
|
||||
category: 'Electrónica',
|
||||
},
|
||||
{
|
||||
name: 'Analgésicos',
|
||||
category: 'Salud',
|
||||
},
|
||||
{
|
||||
name: 'Tiritas',
|
||||
category: 'Salud',
|
||||
},
|
||||
{
|
||||
name: 'Desinfectante',
|
||||
category: 'Salud',
|
||||
},
|
||||
],
|
||||
};
|
||||
export default packing;
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const pdf: TranslationStrings = {
|
||||
'pdf.travelPlan': 'Plan de viaje',
|
||||
'pdf.planned': 'Planificado',
|
||||
'pdf.costLabel': 'Coste EUR',
|
||||
'pdf.preview': 'Vista previa PDF',
|
||||
'pdf.saveAsPdf': 'Guardar como PDF',
|
||||
};
|
||||
export default pdf;
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const perm: TranslationStrings = {
|
||||
'perm.title': 'Configuración de permisos',
|
||||
'perm.subtitle': 'Controla quién puede realizar acciones en la aplicación',
|
||||
'perm.saved': 'Configuración de permisos guardada',
|
||||
'perm.resetDefaults': 'Restablecer valores predeterminados',
|
||||
'perm.customized': 'personalizado',
|
||||
'perm.level.admin': 'Solo administrador',
|
||||
'perm.level.tripOwner': 'Propietario del viaje',
|
||||
'perm.level.tripMember': 'Miembros del viaje',
|
||||
'perm.level.everybody': 'Todos',
|
||||
'perm.cat.trip': 'Gestión de viajes',
|
||||
'perm.cat.members': 'Gestión de miembros',
|
||||
'perm.cat.files': 'Archivos',
|
||||
'perm.cat.content': 'Contenido y horario',
|
||||
'perm.cat.extras': 'Presupuesto, equipaje y colaboración',
|
||||
'perm.action.trip_create': 'Crear viajes',
|
||||
'perm.action.trip_edit': 'Editar detalles del viaje',
|
||||
'perm.action.trip_delete': 'Eliminar viajes',
|
||||
'perm.action.trip_archive': 'Archivar / desarchivar viajes',
|
||||
'perm.action.trip_cover_upload': 'Subir imagen de portada',
|
||||
'perm.action.member_manage': 'Añadir / eliminar miembros',
|
||||
'perm.action.file_upload': 'Subir archivos',
|
||||
'perm.action.file_edit': 'Editar metadatos del archivo',
|
||||
'perm.action.file_delete': 'Eliminar archivos',
|
||||
'perm.action.place_edit': 'Añadir / editar / eliminar lugares',
|
||||
'perm.action.day_edit': 'Editar días, notas y asignaciones',
|
||||
'perm.action.reservation_edit': 'Gestionar reservas',
|
||||
'perm.action.budget_edit': 'Gestionar presupuesto',
|
||||
'perm.action.packing_edit': 'Gestionar listas de equipaje',
|
||||
'perm.action.collab_edit': 'Colaboración (notas, encuestas, chat)',
|
||||
'perm.action.share_manage': 'Gestionar enlaces compartidos',
|
||||
'perm.actionHint.trip_create': 'Quién puede crear nuevos viajes',
|
||||
'perm.actionHint.trip_edit':
|
||||
'Quién puede cambiar el nombre, fechas, descripción y moneda del viaje',
|
||||
'perm.actionHint.trip_delete':
|
||||
'Quién puede eliminar permanentemente un viaje',
|
||||
'perm.actionHint.trip_archive': 'Quién puede archivar o desarchivar un viaje',
|
||||
'perm.actionHint.trip_cover_upload':
|
||||
'Quién puede subir o cambiar la imagen de portada',
|
||||
'perm.actionHint.member_manage':
|
||||
'Quién puede invitar o eliminar miembros del viaje',
|
||||
'perm.actionHint.file_upload': 'Quién puede subir archivos a un viaje',
|
||||
'perm.actionHint.file_edit':
|
||||
'Quién puede editar descripciones y enlaces de archivos',
|
||||
'perm.actionHint.file_delete':
|
||||
'Quién puede mover archivos a la papelera o eliminarlos permanentemente',
|
||||
'perm.actionHint.place_edit': 'Quién puede añadir, editar o eliminar lugares',
|
||||
'perm.actionHint.day_edit':
|
||||
'Quién puede editar días, notas de días y asignaciones de lugares',
|
||||
'perm.actionHint.reservation_edit':
|
||||
'Quién puede crear, editar o eliminar reservas',
|
||||
'perm.actionHint.budget_edit':
|
||||
'Quién puede crear, editar o eliminar partidas del presupuesto',
|
||||
'perm.actionHint.packing_edit':
|
||||
'Quién puede gestionar artículos de equipaje y bolsas',
|
||||
'perm.actionHint.collab_edit':
|
||||
'Quién puede crear notas, encuestas y enviar mensajes',
|
||||
'perm.actionHint.share_manage':
|
||||
'Quién puede crear o eliminar enlaces compartidos públicos',
|
||||
};
|
||||
export default perm;
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const photos: TranslationStrings = {
|
||||
'photos.title': 'Fotos',
|
||||
'photos.subtitle': '{count} fotos para {trip}',
|
||||
'photos.dropHere': 'Suelta fotos aquí...',
|
||||
'photos.dropHereActive': 'Suelta fotos aquí',
|
||||
'photos.captionForAll': 'Leyenda (para todos)',
|
||||
'photos.captionPlaceholder': 'Leyenda opcional...',
|
||||
'photos.addCaption': 'Añadir leyenda...',
|
||||
'photos.allDays': 'Todos los días',
|
||||
'photos.noPhotos': 'Aún no hay fotos',
|
||||
'photos.uploadHint': 'Sube y organiza las fotos compartidas de este viaje',
|
||||
'photos.clickToSelect': 'o haz clic para seleccionar',
|
||||
'photos.linkPlace': 'Vincular lugar',
|
||||
'photos.noPlace': 'Sin lugar',
|
||||
'photos.uploadN': 'Subida de {n} foto(s)',
|
||||
'photos.linkDay': 'Vincular día',
|
||||
'photos.noDay': 'Ningún día',
|
||||
'photos.dayLabel': 'Día {number}',
|
||||
'photos.photoSelected': 'Foto seleccionada',
|
||||
'photos.photosSelected': 'Fotos seleccionadas',
|
||||
'photos.fileTypeHint': 'JPG, PNG, WebP · máx. 10 MB · hasta 30 fotos',
|
||||
};
|
||||
export default photos;
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const places: TranslationStrings = {
|
||||
'places.addPlace': 'Añadir lugar/actividad',
|
||||
'places.importFile': 'Importar archivo',
|
||||
'places.sidebarDrop': 'Soltar para importar',
|
||||
'places.importFileHint':
|
||||
'Importa archivos .gpx, .kml o .kmz de herramientas como Google My Maps, Google Earth o un rastreador GPS.',
|
||||
'places.importFileDropHere':
|
||||
'Haz clic para seleccionar un archivo o arrástralo aquí',
|
||||
'places.importFileDropActive': 'Suelta el archivo para seleccionarlo',
|
||||
'places.importFileUnsupported':
|
||||
'Tipo de archivo no compatible. Usa .gpx, .kml o .kmz.',
|
||||
'places.importFileTooLarge':
|
||||
'El archivo es demasiado grande. El tamaño máximo de carga es {maxMb} MB.',
|
||||
'places.importFileError': 'Importación fallida',
|
||||
'places.importAllSkipped': 'Todos los lugares ya estaban en el viaje.',
|
||||
'places.gpxImported': '{count} lugares importados desde GPX',
|
||||
'places.gpxImportTypes': '¿Qué deseas importar?',
|
||||
'places.gpxImportWaypoints': 'Puntos de ruta',
|
||||
'places.gpxImportRoutes': 'Rutas',
|
||||
'places.gpxImportTracks': 'Tracks (con geometría de ruta)',
|
||||
'places.gpxImportNoneSelected': 'Selecciona al menos un tipo para importar.',
|
||||
'places.kmlImportTypes': '¿Qué deseas importar?',
|
||||
'places.kmlImportPoints': 'Puntos (Placemarks)',
|
||||
'places.kmlImportPaths': 'Rutas (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Selecciona al menos un tipo.',
|
||||
'places.selectionCount': '{count} seleccionado(s)',
|
||||
'places.deleteSelected': 'Eliminar selección',
|
||||
'places.kmlKmzImported': '{count} lugares importados desde KMZ/KML',
|
||||
'places.urlResolved': 'Lugar importado desde URL',
|
||||
'places.importList': 'Importar lista',
|
||||
'places.kmlKmzSummaryValues':
|
||||
'Placemarks: {total} • Importados: {created} • Omitidos: {skipped}',
|
||||
'places.importGoogleList': 'Lista Google',
|
||||
'places.importNaverList': 'Lista Naver',
|
||||
'places.googleListHint':
|
||||
'Pega un enlace compartido de una lista de Google Maps para importar todos los lugares.',
|
||||
'places.googleListImported': '{count} lugares importados de "{list}"',
|
||||
'places.googleListError': 'Error al importar la lista de Google Maps',
|
||||
'places.naverListHint':
|
||||
'Pega un enlace compartido de una lista de Naver Maps para importar todos los lugares.',
|
||||
'places.naverListImported': '{count} lugares importados de "{list}"',
|
||||
'places.naverListError': 'Error al importar la lista de Naver Maps',
|
||||
'places.viewDetails': 'Ver detalles',
|
||||
'places.assignToDay': '¿A qué día añadirlo?',
|
||||
'places.all': 'Todo',
|
||||
'places.unplanned': 'Sin planificar',
|
||||
'places.filterTracks': 'Rutas',
|
||||
'places.search': 'Buscar lugares...',
|
||||
'places.allCategories': 'Todas las categorías',
|
||||
'places.categoriesSelected': 'categorías',
|
||||
'places.clearFilter': 'Borrar filtro',
|
||||
'places.count': '{count} lugares',
|
||||
'places.countSingular': '1 lugar',
|
||||
'places.allPlanned': 'Todos los lugares están planificados',
|
||||
'places.noneFound': 'No se encontraron lugares',
|
||||
'places.editPlace': 'Editar lugar',
|
||||
'places.formName': 'Nombre',
|
||||
'places.formNamePlaceholder': 'p. ej. Torre Eiffel',
|
||||
'places.formDescription': 'Descripción',
|
||||
'places.formDescriptionPlaceholder': 'Descripción breve...',
|
||||
'places.formAddress': 'Dirección',
|
||||
'places.formAddressPlaceholder': 'Calle, ciudad, país',
|
||||
'places.formLat': 'Latitud (p. ej. 48.8566)',
|
||||
'places.formLng': 'Longitud (p. ej. 2.3522)',
|
||||
'places.formCategory': 'Categoría',
|
||||
'places.noCategory': 'Sin categoría',
|
||||
'places.categoryNamePlaceholder': 'Nombre de la categoría',
|
||||
'places.formTime': 'Hora',
|
||||
'places.startTime': 'Inicio',
|
||||
'places.endTime': 'Fin',
|
||||
'places.endTimeBeforeStart': 'La hora de fin es anterior a la de inicio',
|
||||
'places.timeCollision': 'Solapamiento horario con:',
|
||||
'places.formWebsite': 'Página web',
|
||||
'places.formNotes': 'Notas',
|
||||
'places.formNotesPlaceholder': 'Notas personales...',
|
||||
'places.formReservation': 'Reserva',
|
||||
'places.reservationNotesPlaceholder':
|
||||
'Notas de reserva, número de confirmación...',
|
||||
'places.mapsSearchPlaceholder': 'Buscar lugares...',
|
||||
'places.mapsSearchError': 'La búsqueda de lugares falló.',
|
||||
'places.loadingDetails': 'Cargando detalles del lugar…',
|
||||
'places.osmHint':
|
||||
'Usando búsqueda con OpenStreetMap (sin fotos, horarios ni valoraciones). Añade una clave API de Google en Ajustes para obtener todos los detalles.',
|
||||
'places.osmActive':
|
||||
'Búsqueda mediante OpenStreetMap (sin fotos, valoraciones ni horarios). Añade una clave API de Google en Ajustes para datos ampliados.',
|
||||
'places.categoryCreateError': 'No se pudo crear la categoría',
|
||||
'places.nameRequired': 'Introduce un nombre',
|
||||
'places.saveError': 'No se pudo guardar',
|
||||
'places.duplicateExists': "'{name}' ya está en este viaje.",
|
||||
'places.addAnyway': 'Añadir de todos modos',
|
||||
'places.enrichOnImport': 'Enriquecer lugares con Google',
|
||||
'places.enrichOnImportHint':
|
||||
'Busca cada lugar importado para añadir fotos, dirección y datos de contacto. Usa tu clave de Google Maps.',
|
||||
};
|
||||
export default places;
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const planner: TranslationStrings = {
|
||||
'planner.places': 'Lugares',
|
||||
'planner.bookings': 'Reservas',
|
||||
'planner.packingList': 'Lista de equipaje',
|
||||
'planner.documents': 'Documentos',
|
||||
'planner.dayPlan': 'Plan por días',
|
||||
'planner.reservations': 'Reservas',
|
||||
'planner.minTwoPlaces': 'Se necesitan al menos 2 lugares con coordenadas',
|
||||
'planner.noGeoPlaces': 'No hay lugares con coordenadas disponibles',
|
||||
'planner.routeCalculated': 'Ruta calculada',
|
||||
'planner.routeCalcFailed': 'No se pudo calcular la ruta',
|
||||
'planner.routeError': 'Error al calcular la ruta',
|
||||
'planner.icsExportFailed': 'Error al exportar ICS',
|
||||
'planner.routeOptimized': 'Ruta optimizada',
|
||||
'planner.reservationUpdated': 'Reserva actualizada',
|
||||
'planner.reservationAdded': 'Reserva añadida',
|
||||
'planner.confirmDeleteReservation': '¿Eliminar reserva?',
|
||||
'planner.reservationDeleted': 'Reserva eliminada',
|
||||
'planner.days': 'Días',
|
||||
'planner.allPlaces': 'Todos los lugares',
|
||||
'planner.totalPlaces': '{n} lugares en total',
|
||||
'planner.noDaysPlanned': 'Aún no hay días planificados',
|
||||
'planner.editTrip': 'Editar viaje →',
|
||||
'planner.placeOne': '1 lugar',
|
||||
'planner.placeN': '{n} lugares',
|
||||
'planner.addNote': 'Añadir nota',
|
||||
'planner.noEntries': 'No hay entradas para este día',
|
||||
'planner.addPlace': 'Añadir lugar/actividad',
|
||||
'planner.addPlaceShort': '+ Añadir lugar/actividad',
|
||||
'planner.resPending': 'Reserva pendiente · ',
|
||||
'planner.resConfirmed': 'Reserva confirmada · ',
|
||||
'planner.notePlaceholder': 'Nota…',
|
||||
'planner.noteTimePlaceholder': 'Hora (opcional)',
|
||||
'planner.noteExamplePlaceholder':
|
||||
'p. ej. S3 a las 14:30 desde la estación central, ferry desde el muelle 7, pausa para comer…',
|
||||
'planner.totalCost': 'Coste total',
|
||||
'planner.searchPlaces': 'Buscar lugares…',
|
||||
'planner.allCategories': 'Todas las categorías',
|
||||
'planner.noPlacesFound': 'No se encontraron lugares',
|
||||
'planner.addFirstPlace': 'Añadir el primer lugar',
|
||||
'planner.noReservations': 'Sin reservas',
|
||||
'planner.addFirstReservation': 'Añadir la primera reserva',
|
||||
'planner.new': 'Nuevo',
|
||||
'planner.addToDay': '+ Día',
|
||||
'planner.calculating': 'Calculando…',
|
||||
'planner.route': 'Ruta',
|
||||
'planner.optimize': 'Optimizar',
|
||||
'planner.openGoogleMaps': 'Abrir en Google Maps',
|
||||
'planner.selectDayHint':
|
||||
'Selecciona un día de la lista izquierda para ver su plan',
|
||||
'planner.noPlacesForDay': 'Aún no hay lugares para este día',
|
||||
'planner.addPlacesLink': 'Añadir lugares →',
|
||||
'planner.minTotal': 'min en total',
|
||||
'planner.noReservation': 'Sin reserva',
|
||||
'planner.removeFromDay': 'Quitar del día',
|
||||
'planner.addToThisDay': 'Añadir al día',
|
||||
'planner.overview': 'Vista general',
|
||||
'planner.noDays': 'No hay días todavía',
|
||||
'planner.editTripToAddDays': 'Edita el viaje para añadir días',
|
||||
'planner.dayCount': '{n} días',
|
||||
'planner.clickToUnlock': 'Haz clic para desbloquear',
|
||||
'planner.keepPosition': 'Mantener posición durante la optimización de ruta',
|
||||
'planner.dayDetails': 'Detalles del día',
|
||||
'planner.dayN': 'Día {n}',
|
||||
};
|
||||
export default planner;
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const register: TranslationStrings = {
|
||||
'register.passwordMismatch': 'Las contraseñas no coinciden',
|
||||
'register.passwordTooShort': 'La contraseña debe tener al menos 8 caracteres',
|
||||
'register.failed': 'Falló el registro',
|
||||
'register.getStarted': 'Empezar',
|
||||
'register.subtitle': 'Crea una cuenta y empieza a planificar tus viajes.',
|
||||
'register.feature1': 'Planes de viaje ilimitados',
|
||||
'register.feature2': 'Vista de mapa interactiva',
|
||||
'register.feature3': 'Gestiona lugares y categorías',
|
||||
'register.feature4': 'Haz seguimiento de las reservas',
|
||||
'register.feature5': 'Crea listas de equipaje',
|
||||
'register.feature6': 'Guarda fotos y archivos',
|
||||
'register.createAccount': 'Crear cuenta',
|
||||
'register.startPlanning': 'Empieza a planificar tu viaje',
|
||||
'register.minChars': 'Mín. 6 caracteres',
|
||||
'register.confirmPassword': 'Confirmar contraseña',
|
||||
'register.repeatPassword': 'Repetir contraseña',
|
||||
'register.registering': 'Registrando...',
|
||||
'register.register': 'Registrarse',
|
||||
'register.hasAccount': '¿Ya tienes cuenta?',
|
||||
'register.signIn': 'Iniciar sesión',
|
||||
};
|
||||
export default register;
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const reservations: TranslationStrings = {
|
||||
'reservations.title': 'Reservas',
|
||||
'reservations.empty': 'Aún no hay reservas',
|
||||
'reservations.emptyHint': 'Añade reservas de vuelos, hoteles y más',
|
||||
'reservations.add': 'Añadir reserva',
|
||||
'reservations.addManual': 'Reserva manual',
|
||||
'reservations.placeHint':
|
||||
'Consejo: es mejor crear las reservas directamente desde un lugar para vincularlas con el plan del día.',
|
||||
'reservations.confirmed': 'Confirmada',
|
||||
'reservations.pending': 'Pendiente',
|
||||
'reservations.summary': '{confirmed} confirmadas, {pending} pendientes',
|
||||
'reservations.fromPlan': 'Del plan',
|
||||
'reservations.showFiles': 'Mostrar archivos',
|
||||
'reservations.editTitle': 'Editar reserva',
|
||||
'reservations.status': 'Estado',
|
||||
'reservations.datetime': 'Fecha y hora',
|
||||
'reservations.startTime': 'Hora de inicio',
|
||||
'reservations.endTime': 'Hora de fin',
|
||||
'reservations.date': 'Fecha',
|
||||
'reservations.time': 'Hora',
|
||||
'reservations.timeAlt': 'Hora (alternativa, p. ej. 19:30)',
|
||||
'reservations.notes': 'Notas',
|
||||
'reservations.notesPlaceholder': 'Notas adicionales...',
|
||||
'reservations.type.flight': 'Vuelo',
|
||||
'reservations.type.hotel': 'Alojamiento',
|
||||
'reservations.type.restaurant': 'Restaurante',
|
||||
'reservations.type.train': 'Tren',
|
||||
'reservations.type.car': 'Coche',
|
||||
'reservations.type.cruise': 'Crucero',
|
||||
'reservations.type.event': 'Evento',
|
||||
'reservations.type.tour': 'Excursión',
|
||||
'reservations.type.other': 'Otro',
|
||||
'reservations.type.bus': 'Autobús',
|
||||
'reservations.type.ferry': 'Ferry',
|
||||
'reservations.type.bicycle': 'Bicicleta',
|
||||
'reservations.type.taxi': 'Taxi',
|
||||
'reservations.type.transport_other': 'Otro',
|
||||
'reservations.confirm.delete':
|
||||
'¿Seguro que quieres eliminar la reserva "{name}"?',
|
||||
'reservations.confirm.deleteTitle': '¿Eliminar reserva?',
|
||||
'reservations.confirm.deleteBody': '« {name} » se eliminará permanentemente.',
|
||||
'reservations.toast.updated': 'Reserva actualizada',
|
||||
'reservations.toast.removed': 'Reserva eliminada',
|
||||
'reservations.toast.fileUploaded': 'Archivo subido',
|
||||
'reservations.toast.uploadError': 'No se pudo subir',
|
||||
'reservations.newTitle': 'Nueva reserva',
|
||||
'reservations.bookingType': 'Tipo de reserva',
|
||||
'reservations.titleLabel': 'Título',
|
||||
'reservations.titlePlaceholder': 'p. ej. Lufthansa LH123, Hotel Adlon, ...',
|
||||
'reservations.locationAddress': 'Ubicación / dirección',
|
||||
'reservations.locationPlaceholder': 'Dirección, aeropuerto, hotel...',
|
||||
'reservations.confirmationCode': 'Código de reserva',
|
||||
'reservations.confirmationPlaceholder': 'p. ej. ABC12345',
|
||||
'reservations.day': 'Día',
|
||||
'reservations.noDay': 'Sin día',
|
||||
'reservations.place': 'Lugar',
|
||||
'reservations.noPlace': 'Sin lugar',
|
||||
'reservations.pendingSave': 'se guardará…',
|
||||
'reservations.uploading': 'Subiendo...',
|
||||
'reservations.attachFile': 'Adjuntar archivo',
|
||||
'reservations.linkExisting': 'Vincular archivo existente',
|
||||
'reservations.toast.saveError': 'No se pudo guardar',
|
||||
'reservations.toast.updateError': 'No se pudo actualizar',
|
||||
'reservations.toast.deleteError': 'No se pudo eliminar',
|
||||
'reservations.confirm.remove': '¿Eliminar la reserva de "{name}"?',
|
||||
'reservations.linkAssignment': 'Vincular a una asignación del día',
|
||||
'reservations.pickAssignment': 'Selecciona una asignación de tu plan...',
|
||||
'reservations.noAssignment': 'Sin vínculo (independiente)',
|
||||
'reservations.price': 'Precio',
|
||||
'reservations.budgetCategory': 'Categoría de presupuesto',
|
||||
'reservations.budgetCategoryPlaceholder': 'ej. Transporte, Alojamiento',
|
||||
'reservations.budgetCategoryAuto': 'Automático (según tipo de reserva)',
|
||||
'reservations.budgetHint':
|
||||
'Se creará automáticamente una entrada presupuestaria al guardar.',
|
||||
'reservations.departureDate': 'Salida',
|
||||
'reservations.arrivalDate': 'Llegada',
|
||||
'reservations.departureTime': 'Hora salida',
|
||||
'reservations.arrivalTime': 'Hora llegada',
|
||||
'reservations.pickupDate': 'Recogida',
|
||||
'reservations.returnDate': 'Devolución',
|
||||
'reservations.pickupTime': 'Hora recogida',
|
||||
'reservations.returnTime': 'Hora devolución',
|
||||
'reservations.endDate': 'Fecha fin',
|
||||
'reservations.meta.departureTimezone': 'TZ salida',
|
||||
'reservations.meta.arrivalTimezone': 'TZ llegada',
|
||||
'reservations.span.departure': 'Salida',
|
||||
'reservations.span.arrival': 'Llegada',
|
||||
'reservations.span.inTransit': 'En tránsito',
|
||||
'reservations.span.pickup': 'Recogida',
|
||||
'reservations.span.return': 'Devolución',
|
||||
'reservations.span.active': 'Activo',
|
||||
'reservations.span.start': 'Inicio',
|
||||
'reservations.span.end': 'Fin',
|
||||
'reservations.span.ongoing': 'En curso',
|
||||
'reservations.validation.endBeforeStart':
|
||||
'La fecha/hora de fin debe ser posterior a la de inicio',
|
||||
'reservations.addBooking': 'Añadir reserva',
|
||||
'reservations.meta.airline': 'Aerolínea',
|
||||
'reservations.meta.flightNumber': 'N° de vuelo',
|
||||
'reservations.meta.from': 'Desde',
|
||||
'reservations.meta.to': 'Hasta',
|
||||
'reservations.layover.route': 'Ruta',
|
||||
'reservations.layover.stop': 'Escala',
|
||||
'reservations.layover.addStop': 'Añadir escala',
|
||||
'reservations.layover.connection': 'Conexión',
|
||||
'reservations.layover.layover': 'Escala',
|
||||
'reservations.needsReview': 'Revisar',
|
||||
'reservations.needsReviewHint':
|
||||
'No se pudo identificar el aeropuerto automáticamente — por favor confirma la ubicación.',
|
||||
'reservations.searchLocation': 'Buscar estación, puerto, dirección...',
|
||||
'reservations.meta.trainNumber': 'N° de tren',
|
||||
'reservations.meta.platform': 'Andén',
|
||||
'reservations.meta.seat': 'Asiento',
|
||||
'reservations.meta.checkIn': 'Registro de entrada',
|
||||
'reservations.meta.checkInUntil': 'Check-in hasta',
|
||||
'reservations.meta.checkOut': 'Registro de salida',
|
||||
'reservations.meta.linkAccommodation': 'Alojamiento',
|
||||
'reservations.meta.pickAccommodation': 'Vincular con alojamiento',
|
||||
'reservations.meta.noAccommodation': 'Ninguno',
|
||||
'reservations.meta.hotelPlace': 'Alojamiento',
|
||||
'reservations.meta.pickHotel': 'Seleccionar alojamiento',
|
||||
'reservations.meta.fromDay': 'Desde',
|
||||
'reservations.meta.toDay': 'Hasta',
|
||||
'reservations.meta.selectDay': 'Seleccionar día',
|
||||
'reservations.import.title': 'Importar confirmaciones de reserva',
|
||||
'reservations.import.cta': 'Importar desde archivo',
|
||||
'reservations.import.dropHere': 'Suelta los archivos de confirmación de reserva aquí o haz clic para seleccionar',
|
||||
'reservations.import.dropActive': 'Suelta los archivos para importar',
|
||||
'reservations.import.acceptedFormats': 'Aceptados: EML, PDF, PKPass, HTML, TXT (máx. 10 MB por archivo, hasta 5 archivos)',
|
||||
'reservations.import.parsing': 'Analizando archivos…',
|
||||
'reservations.import.previewHeading': '{count} reserva(s) encontrada(s)',
|
||||
'reservations.import.previewEmpty': 'No se pudieron extraer reservas de los archivos subidos.',
|
||||
'reservations.import.removeItem': 'Eliminar',
|
||||
'reservations.import.confirm': 'Importar {count} reserva(s)',
|
||||
'reservations.import.back': 'Atrás',
|
||||
'reservations.import.success': '{count} reserva(s) importada(s)',
|
||||
'reservations.import.partialFailure': '{created} importada(s), {failed} fallida(s)',
|
||||
'reservations.import.error': 'Error al analizar. Asegúrate de que el archivo sea una confirmación de reserva válida.',
|
||||
'reservations.import.unavailable': 'La importación de reservas no está disponible en este servidor.',
|
||||
'reservations.import.unsupportedFormat': 'Formato de archivo no compatible. Usa EML, PDF, PKPass, HTML o TXT.',
|
||||
'reservations.import.fileTooLarge': 'El archivo «{name}» supera el límite de 10 MB.',
|
||||
'reservations.airtrail.title': 'Importar desde AirTrail',
|
||||
'reservations.airtrail.cta': 'AirTrail',
|
||||
'reservations.airtrail.synced': 'AirTrail',
|
||||
'reservations.airtrail.syncedHint': 'Sincronizado desde AirTrail: las ediciones se mantienen sincronizadas en ambos sentidos.',
|
||||
'reservations.airtrail.notSynced': 'No sincronizado',
|
||||
'reservations.airtrail.notSyncedHint': 'Este vuelo se eliminó en AirTrail y ya no se sincroniza.',
|
||||
'reservations.airtrail.loadError': 'No se pudieron cargar tus vuelos de AirTrail.',
|
||||
'reservations.airtrail.imported': '{count} vuelo(s) importado(s)',
|
||||
'reservations.airtrail.skippedDuplicate': '{count} ya en este viaje, omitido(s)',
|
||||
'reservations.airtrail.nothingImported': 'No hay nada que importar.',
|
||||
'reservations.airtrail.importError': 'Error al importar. Inténtalo de nuevo.',
|
||||
'reservations.airtrail.undo': 'Importar desde AirTrail',
|
||||
'reservations.airtrail.alreadyImported': 'Importado',
|
||||
'reservations.airtrail.duringTrip': 'Durante este viaje',
|
||||
'reservations.airtrail.otherFlights': 'Otros vuelos',
|
||||
'reservations.airtrail.empty': 'No se encontraron vuelos en tu cuenta de AirTrail.',
|
||||
'reservations.airtrail.importCta': 'Importar {count}',
|
||||
};
|
||||
export default reservations;
|
||||
@@ -0,0 +1,346 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const settings: TranslationStrings = {
|
||||
'settings.title': 'Ajustes',
|
||||
'settings.subtitle': 'Configura tus ajustes personales',
|
||||
'settings.tabs.display': 'Pantalla',
|
||||
'settings.tabs.map': 'Mapa',
|
||||
'settings.tabs.notifications': 'Notificaciones',
|
||||
'settings.tabs.integrations': 'Integraciones',
|
||||
'settings.tabs.account': 'Cuenta',
|
||||
'settings.tabs.offline': 'Offline',
|
||||
'settings.tabs.about': 'Acerca de',
|
||||
'settings.map': 'Mapa',
|
||||
'settings.mapTemplate': 'Plantilla del mapa',
|
||||
'settings.mapTemplatePlaceholder.select': 'Seleccionar plantilla...',
|
||||
'settings.mapDefaultHint': 'Déjalo vacío para OpenStreetMap (por defecto)',
|
||||
'settings.mapTemplatePlaceholder':
|
||||
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
'settings.mapHint': 'Plantilla de URL para los mosaicos del mapa',
|
||||
'settings.mapProvider': 'Proveedor de mapa',
|
||||
'settings.mapProviderHint':
|
||||
'Afecta a los mapas de Trip Planner y Journey. Atlas siempre usa Leaflet.',
|
||||
'settings.mapLeafletSubtitle': 'Clásico 2D, cualquier mosaico raster',
|
||||
'settings.mapMapboxSubtitle': 'Mosaicos vectoriales, edificios 3D y terreno',
|
||||
'settings.mapExperimental': 'Experimental',
|
||||
'settings.mapMapboxToken': 'Token de acceso de Mapbox',
|
||||
'settings.mapMapboxTokenHint': 'Token público (pk.*) de',
|
||||
'settings.mapMapboxTokenLink': 'mapbox.com → Tokens de acceso',
|
||||
'settings.mapStyle': 'Estilo de mapa',
|
||||
'settings.mapStylePlaceholder': 'Seleccionar un estilo de Mapbox',
|
||||
'settings.mapStyleHint': 'Preset o tu propia URL mapbox://styles/USER/ID',
|
||||
'settings.map3dBuildings': 'Edificios 3D y terreno',
|
||||
'settings.map3dHint':
|
||||
'Inclinación + extrusiones 3D reales de edificios — funciona con todos los estilos, incluyendo satélite.',
|
||||
'settings.mapHighQuality': 'Modo de alta calidad',
|
||||
'settings.mapHighQualityHint':
|
||||
'Antialiasing + proyección global para bordes más nítidos y una vista realista del mundo.',
|
||||
'settings.mapHighQualityWarning':
|
||||
'Puede afectar el rendimiento en dispositivos menos potentes.',
|
||||
'settings.mapTipLabel': 'Consejo:',
|
||||
'settings.mapTip':
|
||||
'Clic derecho y arrastrar para rotar/inclinar el mapa. Clic central para añadir un lugar (el clic derecho está reservado para la rotación).',
|
||||
'settings.latitude': 'Latitud',
|
||||
'settings.longitude': 'Longitud',
|
||||
'settings.saveMap': 'Guardar mapa',
|
||||
'settings.apiKeys': 'Claves API',
|
||||
'settings.mapsKey': 'Clave API de Google Maps',
|
||||
'settings.mapsKeyHint':
|
||||
'Necesaria para buscar lugares. Consíguela en console.cloud.google.com',
|
||||
'settings.weatherKey': 'Clave API de OpenWeatherMap',
|
||||
'settings.weatherKeyHint':
|
||||
'Para datos meteorológicos. Gratis en openweathermap.org/api',
|
||||
'settings.keyPlaceholder': 'Introduce la clave...',
|
||||
'settings.configured': 'Configurado',
|
||||
'settings.saveKeys': 'Guardar claves',
|
||||
'settings.display': 'Visualización',
|
||||
'settings.colorMode': 'Modo de color',
|
||||
'settings.light': 'Claro',
|
||||
'settings.dark': 'Oscuro',
|
||||
'settings.auto': 'Automático',
|
||||
'settings.language': 'Idioma',
|
||||
'settings.temperature': 'Unidad de temperatura',
|
||||
'settings.timeFormat': 'Formato de hora',
|
||||
'settings.blurBookingCodes': 'Difuminar códigos de reserva',
|
||||
'settings.optimizeFromAccommodation': 'Optimizar la ruta desde el alojamiento',
|
||||
'settings.optimizeFromAccommodationHint':
|
||||
'Al optimizar un día, comienza la ruta en el hotel donde despiertas y termínala en aquel en el que te registras esa noche.',
|
||||
'settings.notifications': 'Notificaciones',
|
||||
'settings.notifyTripInvite': 'Invitaciones de viaje',
|
||||
'settings.notifyBookingChange': 'Cambios en reservas',
|
||||
'settings.notifyTripReminder': 'Recordatorios de viaje',
|
||||
'settings.notifyTodoDue': 'Tarea próxima',
|
||||
'settings.notifyVacayInvite': 'Invitaciones de fusión Vacay',
|
||||
'settings.notifyPhotosShared': 'Fotos compartidas (Immich)',
|
||||
'settings.notifyCollabMessage': 'Mensajes de chat (Collab)',
|
||||
'settings.notifyPackingTagged': 'Lista de equipaje: asignaciones',
|
||||
'settings.notifyWebhook': 'Notificaciones webhook',
|
||||
'settings.notificationsDisabled':
|
||||
'Las notificaciones no están configuradas. Pida a un administrador que active las notificaciones por correo o webhook.',
|
||||
'settings.notificationsActive': 'Canal activo',
|
||||
'settings.notificationsManagedByAdmin':
|
||||
'Los eventos de notificación son configurados por el administrador.',
|
||||
'settings.on': 'Activado',
|
||||
'settings.off': 'Desactivado',
|
||||
'settings.mcp.title': 'Configuración MCP',
|
||||
'settings.mcp.endpoint': 'Endpoint MCP',
|
||||
'settings.mcp.clientConfig': 'Configuración del cliente',
|
||||
'settings.mcp.clientConfigHint':
|
||||
'Reemplaza <your_token> con un token de la lista de abajo. Es posible que debas ajustar la ruta de npx según tu sistema (p. ej. C:\\PROGRA~1\\nodejs\\npx.cmd en Windows).',
|
||||
'settings.mcp.clientConfigHintOAuth':
|
||||
'Reemplaza <your_client_id> y <your_client_secret> con las credenciales del cliente OAuth 2.1 que creaste arriba. mcp-remote abrirá el navegador para completar la autorización la primera vez que te conectes. Es posible que debas ajustar la ruta de npx según tu sistema (p. ej. C:\\PROGRA~1\\nodejs\\npx.cmd en Windows).',
|
||||
'settings.mcp.copy': 'Copiar',
|
||||
'settings.mcp.copied': '¡Copiado!',
|
||||
'settings.mcp.apiTokens': 'Tokens de API',
|
||||
'settings.mcp.createToken': 'Crear nuevo token',
|
||||
'settings.mcp.noTokens':
|
||||
'Sin tokens aún. Crea uno para conectar clientes MCP.',
|
||||
'settings.mcp.tokenCreatedAt': 'Creado',
|
||||
'settings.mcp.tokenUsedAt': 'Usado',
|
||||
'settings.mcp.deleteTokenTitle': 'Eliminar token',
|
||||
'settings.mcp.deleteTokenMessage':
|
||||
'Este token dejará de funcionar de inmediato. Cualquier cliente MCP que lo use perderá el acceso.',
|
||||
'settings.mcp.modal.createTitle': 'Crear token de API',
|
||||
'settings.mcp.modal.tokenName': 'Nombre del token',
|
||||
'settings.mcp.modal.tokenNamePlaceholder':
|
||||
'p. ej. Claude Desktop, Portátil de trabajo',
|
||||
'settings.mcp.modal.creating': 'Creando…',
|
||||
'settings.mcp.modal.create': 'Crear token',
|
||||
'settings.mcp.modal.createdTitle': 'Token creado',
|
||||
'settings.mcp.modal.createdWarning':
|
||||
'Este token solo se mostrará una vez. Cópialo y guárdalo ahora — no se podrá recuperar.',
|
||||
'settings.mcp.modal.done': 'Listo',
|
||||
'settings.mcp.toast.created': 'Token creado',
|
||||
'settings.mcp.toast.createError': 'Error al crear el token',
|
||||
'settings.mcp.toast.deleted': 'Token eliminado',
|
||||
'settings.mcp.toast.deleteError': 'Error al eliminar el token',
|
||||
'settings.mcp.apiTokensDeprecated':
|
||||
'Los tokens de API están obsoletos y se eliminarán en una versión futura. Utilice los clientes OAuth 2.1 en su lugar.',
|
||||
'settings.oauth.clients': 'Clientes OAuth 2.1',
|
||||
'settings.oauth.clientsHint':
|
||||
'Registre clientes OAuth 2.1 para que las aplicaciones MCP de terceros (Claude Web, Cursor, etc.) puedan conectarse sin tokens estáticos.',
|
||||
'settings.oauth.createClient': 'Nuevo cliente',
|
||||
'settings.oauth.noClients': 'No hay clientes OAuth registrados.',
|
||||
'settings.oauth.clientId': 'ID de cliente',
|
||||
'settings.oauth.clientSecret': 'Secreto de cliente',
|
||||
'settings.oauth.deleteClient': 'Eliminar cliente',
|
||||
'settings.oauth.deleteClientMessage':
|
||||
'Este cliente y todas las sesiones activas se eliminarán permanentemente. Cualquier aplicación que lo use perderá el acceso inmediatamente.',
|
||||
'settings.oauth.rotateSecret': 'Renovar secreto',
|
||||
'settings.oauth.rotateSecretMessage':
|
||||
'Se generará un nuevo secreto de cliente y todas las sesiones existentes se invalidarán de inmediato. Actualice su aplicación antes de cerrar este diálogo.',
|
||||
'settings.oauth.rotateSecretConfirm': 'Renovar',
|
||||
'settings.oauth.rotateSecretConfirming': 'Renovando…',
|
||||
'settings.oauth.rotateSecretDoneTitle': 'Nuevo secreto generado',
|
||||
'settings.oauth.rotateSecretDoneWarning':
|
||||
'Este secreto solo se muestra una vez. Cópielo ahora y actualice su aplicación — todas las sesiones anteriores han sido invalidadas.',
|
||||
'settings.oauth.activeSessions': 'Sesiones OAuth activas',
|
||||
'settings.oauth.sessionScopes': 'Ámbitos',
|
||||
'settings.oauth.sessionExpires': 'Expira',
|
||||
'settings.oauth.revoke': 'Revocar',
|
||||
'settings.oauth.revokeSession': 'Revocar sesión',
|
||||
'settings.oauth.revokeSessionMessage':
|
||||
'Esto revocará inmediatamente el acceso de esta sesión OAuth.',
|
||||
'settings.oauth.modal.createTitle': 'Registrar cliente OAuth',
|
||||
'settings.oauth.modal.presets': 'Ajustes rápidos',
|
||||
'settings.oauth.modal.clientName': 'Nombre de la aplicación',
|
||||
'settings.oauth.modal.clientNamePlaceholder': 'ej. Claude Web, Mi app MCP',
|
||||
'settings.oauth.modal.redirectUris': 'URIs de redirección',
|
||||
'settings.oauth.modal.redirectUrisPlaceholder':
|
||||
'https://your-app.com/callback\nhttps://your-app.com/auth',
|
||||
'settings.oauth.modal.redirectUrisHint':
|
||||
'Un URI por línea. HTTPS obligatorio (localhost exento). Coincidencia exacta.',
|
||||
'settings.oauth.modal.scopes': 'Ámbitos permitidos',
|
||||
'settings.oauth.modal.scopesHint':
|
||||
'list_trips y get_trip_summary siempre están disponibles — sin ámbito requerido. Permiten a la IA descubrir los IDs de viaje necesarios.',
|
||||
'settings.oauth.modal.selectAll': 'Seleccionar todo',
|
||||
'settings.oauth.modal.deselectAll': 'Deseleccionar todo',
|
||||
'settings.oauth.modal.creating': 'Registrando…',
|
||||
'settings.oauth.modal.create': 'Registrar cliente',
|
||||
'settings.oauth.modal.createdTitle': 'Cliente registrado',
|
||||
'settings.oauth.modal.createdWarning':
|
||||
'El secreto del cliente solo se muestra una vez. Cópielo ahora — no se puede recuperar.',
|
||||
'settings.oauth.toast.createError': 'Error al registrar el cliente OAuth',
|
||||
'settings.oauth.toast.deleted': 'Cliente OAuth eliminado',
|
||||
'settings.oauth.toast.deleteError': 'Error al eliminar el cliente OAuth',
|
||||
'settings.oauth.toast.revoked': 'Sesión revocada',
|
||||
'settings.oauth.toast.revokeError': 'Error al revocar la sesión',
|
||||
'settings.oauth.toast.rotateError': 'Error al renovar el secreto del cliente',
|
||||
'settings.oauth.modal.machineClient':
|
||||
'Cliente de máquina (sin inicio de sesión en el navegador)',
|
||||
'settings.oauth.modal.machineClientHint':
|
||||
'Usa el grant client_credentials — sin URIs de redirección. El token se emite directamente vía client_id + client_secret y actúa como tú dentro de los alcances seleccionados.',
|
||||
'settings.oauth.modal.machineClientUsage':
|
||||
'Obtener token: POST /oauth/token con grant_type=client_credentials, client_id y client_secret. Sin navegador, sin token de actualización.',
|
||||
'settings.oauth.badge.machine': 'máquina',
|
||||
'settings.account': 'Cuenta',
|
||||
'settings.about': 'Acerca de',
|
||||
'settings.about.reportBug': 'Reportar un error',
|
||||
'settings.about.reportBugHint': 'Encontraste un problema? Avísanos',
|
||||
'settings.about.featureRequest': 'Solicitar función',
|
||||
'settings.about.featureRequestHint': 'Sugiere una nueva función',
|
||||
'settings.about.wikiHint': 'Documentación y guías',
|
||||
'settings.about.supporters.badge': 'Patrocinadores Mensuales',
|
||||
'settings.about.supporters.title': 'Compañía de viaje para TREK',
|
||||
'settings.about.supporters.subtitle':
|
||||
'Mientras planeas tu próxima ruta, estas personas ayudan a planear el futuro de TREK. Su aporte mensual va directo al desarrollo y a las horas reales invertidas — para que TREK siga siendo Open Source.',
|
||||
'settings.about.supporters.since': 'patrocinador desde {date}',
|
||||
'settings.about.supporters.tierEmpty': 'Sé el primero',
|
||||
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||
'settings.about.supporter.tier.businessClassDreamer':
|
||||
'Business Class Dreamer',
|
||||
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||
'settings.about.description':
|
||||
'TREK es un planificador de viajes autoalojado que te ayuda a organizar tus viajes desde la primera idea hasta el último recuerdo. Planificación diaria, presupuesto, listas de equipaje, fotos y mucho más — todo en un solo lugar, en tu propio servidor.',
|
||||
'settings.about.madeWith': 'Hecho con',
|
||||
'settings.about.madeBy':
|
||||
'por Maurice y una creciente comunidad de código abierto.',
|
||||
'settings.username': 'Usuario',
|
||||
'settings.email': 'Correo',
|
||||
'settings.role': 'Rol',
|
||||
'settings.roleAdmin': 'Administrador',
|
||||
'settings.oidcLinked': 'Vinculado con',
|
||||
'settings.changePassword': 'Cambiar contraseña',
|
||||
'settings.mustChangePassword':
|
||||
'Debe cambiar su contraseña antes de continuar. Establezca una nueva contraseña a continuación.',
|
||||
'settings.currentPassword': 'Contraseña actual',
|
||||
'settings.newPassword': 'Nueva contraseña',
|
||||
'settings.confirmPassword': 'Confirmar nueva contraseña',
|
||||
'settings.updatePassword': 'Actualizar contraseña',
|
||||
'settings.passwordRequired': 'Introduce la contraseña actual y la nueva',
|
||||
'settings.passwordTooShort': 'La contraseña debe tener al menos 8 caracteres',
|
||||
'settings.passwordMismatch': 'Las contraseñas no coinciden',
|
||||
'settings.passwordChanged': 'Contraseña cambiada correctamente',
|
||||
'settings.deleteAccount': 'Eliminar cuenta',
|
||||
'settings.deleteAccountTitle': '¿Eliminar tu cuenta?',
|
||||
'settings.deleteAccountWarning':
|
||||
'Tu cuenta y todos tus viajes, lugares y archivos se eliminarán permanentemente. Esta acción no se puede deshacer.',
|
||||
'settings.deleteAccountConfirm': 'Eliminar permanentemente',
|
||||
'settings.deleteBlockedTitle': 'No es posible eliminarla',
|
||||
'settings.deleteBlockedMessage':
|
||||
'Eres el único administrador. Asciende a otro usuario a administrador antes de eliminar tu cuenta.',
|
||||
'settings.roleUser': 'Usuario',
|
||||
'settings.saveProfile': 'Guardar perfil',
|
||||
'settings.mfa.title': 'Autenticación de dos factores (2FA)',
|
||||
'settings.mfa.description':
|
||||
'Añade un segundo paso al iniciar sesión. Usa una app de autenticación (Google Authenticator, Authy, etc.).',
|
||||
'settings.mfa.requiredByPolicy':
|
||||
'Tu administrador exige autenticación en dos factores. Configura una app de autenticación abajo antes de continuar.',
|
||||
'settings.mfa.backupTitle': 'Códigos de respaldo',
|
||||
'settings.mfa.backupDescription':
|
||||
'Usa estos códigos de un solo uso si pierdes acceso a tu app autenticadora.',
|
||||
'settings.mfa.backupWarning':
|
||||
'Guárdalos ahora. Cada código solo se puede usar una vez.',
|
||||
'settings.mfa.backupCopy': 'Copiar códigos',
|
||||
'settings.mfa.backupDownload': 'Descargar TXT',
|
||||
'settings.mfa.backupPrint': 'Imprimir / PDF',
|
||||
'settings.mfa.backupCopied': 'Códigos de respaldo copiados',
|
||||
'settings.mfa.enabled': '2FA está activado en tu cuenta.',
|
||||
'settings.mfa.disabled': '2FA no está activado.',
|
||||
'settings.mfa.setup': 'Configurar autenticador',
|
||||
'settings.mfa.scanQr':
|
||||
'Escanea este código QR con tu app o introduce la clave manualmente.',
|
||||
'settings.mfa.secretLabel': 'Clave secreta (entrada manual)',
|
||||
'settings.mfa.codePlaceholder': 'Código de 6 dígitos',
|
||||
'settings.mfa.enable': 'Activar 2FA',
|
||||
'settings.mfa.cancelSetup': 'Cancelar',
|
||||
'settings.mfa.disableTitle': 'Desactivar 2FA',
|
||||
'settings.mfa.disableHint':
|
||||
'Introduce tu contraseña y un código actual de tu autenticador.',
|
||||
'settings.mfa.disable': 'Desactivar 2FA',
|
||||
'settings.mfa.toastEnabled': 'Autenticación de dos factores activada',
|
||||
'settings.mfa.toastDisabled': 'Autenticación de dos factores desactivada',
|
||||
'settings.mfa.demoBlocked': 'No disponible en modo demo',
|
||||
'settings.toast.mapSaved': 'Ajustes del mapa guardados',
|
||||
'settings.toast.keysSaved': 'Claves API guardadas',
|
||||
'settings.toast.displaySaved': 'Ajustes de visualización guardados',
|
||||
'settings.toast.profileSaved': 'Perfil guardado',
|
||||
'settings.uploadAvatar': 'Subir foto de perfil',
|
||||
'settings.removeAvatar': 'Eliminar foto de perfil',
|
||||
'settings.avatarUploaded': 'Foto de perfil actualizada',
|
||||
'settings.avatarRemoved': 'Foto de perfil eliminada',
|
||||
'settings.avatarError': 'Falló la subida',
|
||||
'settings.bookingLabels': 'Etiquetas de rutas de reservas',
|
||||
'settings.bookingLabelsHint':
|
||||
'Muestra nombres de estaciones / aeropuertos en el mapa. Desactivado, solo se muestra el icono.',
|
||||
'settings.currentPasswordRequired': 'La contraseña actual es obligatoria',
|
||||
'settings.passwordWeak':
|
||||
'La contraseña debe contener mayúsculas, minúsculas, números y un carácter especial',
|
||||
'settings.notifyVersionAvailable': 'Nueva versión disponible',
|
||||
'settings.notificationPreferences.noChannels':
|
||||
'No hay canales de notificación configurados. Pide a un administrador que configure notificaciones por correo o webhook.',
|
||||
'settings.webhookUrl.label': 'URL del webhook',
|
||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
||||
'settings.webhookUrl.hint':
|
||||
'Introduce tu URL de webhook de Discord, Slack o personalizada para recibir notificaciones.',
|
||||
'settings.webhookUrl.saved': 'URL del webhook guardada',
|
||||
'settings.webhookUrl.test': 'Probar',
|
||||
'settings.webhookUrl.testSuccess': 'Webhook de prueba enviado correctamente',
|
||||
'settings.webhookUrl.testFailed': 'Error al enviar el webhook de prueba',
|
||||
'settings.ntfyUrl.topicLabel': 'Tema de Ntfy',
|
||||
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
|
||||
'settings.ntfyUrl.serverLabel': 'URL del servidor Ntfy (opcional)',
|
||||
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
|
||||
'settings.ntfyUrl.hint':
|
||||
'Introduce tu tema de Ntfy para recibir notificaciones push. Deja el servidor en blanco para usar el predeterminado configurado por tu administrador.',
|
||||
'settings.ntfyUrl.tokenLabel': 'Token de acceso (opcional)',
|
||||
'settings.ntfyUrl.tokenHint':
|
||||
'Requerido para temas protegidos con contraseña.',
|
||||
'settings.ntfyUrl.saved': 'Configuración de Ntfy guardada',
|
||||
'settings.ntfyUrl.test': 'Probar',
|
||||
'settings.ntfyUrl.testSuccess':
|
||||
'Notificación de prueba de Ntfy enviada correctamente',
|
||||
'settings.ntfyUrl.testFailed': 'Error en la notificación de prueba de Ntfy',
|
||||
'settings.ntfyUrl.tokenCleared': 'Token de acceso eliminado',
|
||||
'settings.notificationPreferences.inapp': 'In-App',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
'settings.notificationPreferences.email': 'Email',
|
||||
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||
"settings.currency": "Currency",
|
||||
"settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.",
|
||||
'settings.passkey.title': 'Passkeys',
|
||||
'settings.passkey.description':
|
||||
'Inicia sesión más rápido y con protección frente al phishing usando una passkey: tu huella, tu cara, tu PIN o una llave de seguridad física. Tu contraseña sigue disponible como respaldo.',
|
||||
'settings.passkey.notConfigured':
|
||||
'Las passkeys están habilitadas, pero aún no están del todo configuradas en este servidor. Pide a tu administrador que defina el dominio de WebAuthn.',
|
||||
'settings.passkey.add': 'Añadir una passkey',
|
||||
'settings.passkey.addTitle': 'Añadir una passkey',
|
||||
'settings.passkey.passwordPrompt':
|
||||
'Confirma tu contraseña actual y luego sigue las indicaciones de tu dispositivo.',
|
||||
'settings.passkey.passwordRequired': 'Se requiere tu contraseña actual.',
|
||||
'settings.passkey.namePlaceholder': 'Nombre (opcional, p. ej. "iPhone")',
|
||||
'settings.passkey.addedToast': 'Passkey añadida',
|
||||
'settings.passkey.added': 'Añadida',
|
||||
'settings.passkey.addError': 'No se pudo añadir la passkey',
|
||||
'settings.passkey.cancelled': 'Configuración de la passkey cancelada',
|
||||
'settings.passkey.deleted': 'Passkey eliminada',
|
||||
'settings.passkey.deleteConfirm':
|
||||
'¿Eliminar esta passkey? Confírmalo con tu contraseña.',
|
||||
'settings.passkey.rename': 'Renombrar',
|
||||
'settings.passkey.defaultName': 'Passkey',
|
||||
'settings.passkey.synced': 'Sincronizada',
|
||||
'settings.passkey.deviceBound': 'Este dispositivo',
|
||||
'settings.passkey.lastUsed': 'Último uso',
|
||||
'settings.passkey.neverUsed': 'Nunca usada',
|
||||
'settings.mapPoiPill': 'Explorar lugares en el mapa',
|
||||
'settings.mapPoiPillHint': 'Muestra una píldora de categorías en el mapa del viaje para encontrar restaurantes, alojamientos y más cerca, desde OpenStreetMap.',
|
||||
'settings.airtrail.title': 'AirTrail',
|
||||
'settings.airtrail.hint': 'Conecta tu AirTrail autoalojado para importar y sincronizar vuelos. Crea una clave de API en AirTrail en Ajustes → Seguridad.',
|
||||
'settings.airtrail.url': 'URL de la instancia',
|
||||
'settings.airtrail.apiKey': 'Clave de API',
|
||||
'settings.airtrail.apiKeyPlaceholder': 'Clave de API Bearer',
|
||||
'settings.airtrail.apiKeyHint': 'Generada en AirTrail en Ajustes → Seguridad. Se almacena cifrada.',
|
||||
'settings.airtrail.allowInsecureTls': 'Permitir certificados autofirmados',
|
||||
'settings.airtrail.allowInsecureTlsHint': 'Actívalo solo para una instancia de confianza en tu propia red.',
|
||||
'settings.airtrail.connected': 'Conectado',
|
||||
'settings.airtrail.notConnected': 'No conectado',
|
||||
'settings.airtrail.toast.saved': 'Conexión con AirTrail guardada',
|
||||
'settings.airtrail.toast.saveError': 'No se pudo guardar la conexión',
|
||||
'settings.airtrail.test.button': 'Probar conexión',
|
||||
'settings.airtrail.test.success': 'Conectado: {count} vuelo(s) encontrado(s)',
|
||||
'settings.airtrail.test.failed': 'Error de conexión',
|
||||
};
|
||||
|
||||
export default settings;
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const share: TranslationStrings = {
|
||||
'share.linkTitle': 'Enlace público',
|
||||
'share.linkHint':
|
||||
'Crea un enlace que cualquiera puede usar para ver este viaje sin iniciar sesión. Solo lectura — no se puede editar.',
|
||||
'share.createLink': 'Crear enlace',
|
||||
'share.deleteLink': 'Eliminar enlace',
|
||||
'share.createError': 'No se pudo crear el enlace',
|
||||
'share.permMap': 'Mapa y plan',
|
||||
'share.permBookings': 'Reservas',
|
||||
'share.permPacking': 'Equipaje',
|
||||
'share.permBudget': 'Presupuesto',
|
||||
'share.permCollab': 'Chat',
|
||||
};
|
||||
export default share;
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const shared: TranslationStrings = {
|
||||
'shared.expired': 'Enlace expirado o inválido',
|
||||
'shared.expiredHint': 'Este enlace de viaje compartido ya no está activo.',
|
||||
'shared.readOnly': 'Vista de solo lectura',
|
||||
'shared.tabPlan': 'Plan',
|
||||
'shared.tabBookings': 'Reservas',
|
||||
'shared.tabPacking': 'Equipaje',
|
||||
'shared.tabBudget': 'Presupuesto',
|
||||
'shared.tabChat': 'Chat',
|
||||
'shared.days': 'días',
|
||||
'shared.places': 'lugares',
|
||||
'shared.other': 'Otro',
|
||||
'shared.totalBudget': 'Presupuesto total',
|
||||
'shared.messages': 'mensajes',
|
||||
'shared.sharedVia': 'Compartido vía',
|
||||
'shared.confirmed': 'Confirmado',
|
||||
'shared.pending': 'Pendiente',
|
||||
};
|
||||
export default shared;
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const stats: TranslationStrings = {
|
||||
'stats.countries': 'Países',
|
||||
'stats.cities': 'Ciudades',
|
||||
'stats.trips': 'Viajes',
|
||||
'stats.places': 'Lugares',
|
||||
'stats.worldProgress': 'Progreso mundial',
|
||||
'stats.visited': 'visitados',
|
||||
'stats.remaining': 'restantes',
|
||||
'stats.visitedCountries': 'Países visitados',
|
||||
};
|
||||
export default stats;
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const system_notice: TranslationStrings = {
|
||||
'system_notice.welcome_v1.title': 'Bienvenido a TREK',
|
||||
'system_notice.welcome_v1.body':
|
||||
'Tu planificador de viajes todo en uno. Crea itinerarios, comparte viajes con amigos y mantente organizado, online o sin conexión.',
|
||||
'system_notice.welcome_v1.cta_label': 'Planificar un viaje',
|
||||
'system_notice.welcome_v1.hero_alt':
|
||||
'Destino de viaje pintoresco con la interfaz de TREK',
|
||||
'system_notice.welcome_v1.highlight_plan':
|
||||
'Itinerarios día a día para cualquier viaje',
|
||||
'system_notice.welcome_v1.highlight_share':
|
||||
'Colabora con tus compañeros de viaje',
|
||||
'system_notice.welcome_v1.highlight_offline':
|
||||
'Funciona sin conexión en móvil',
|
||||
'system_notice.dev_test_modal.title': '[Dev] Test notice',
|
||||
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.',
|
||||
'system_notice.pager.prev': 'Aviso anterior',
|
||||
'system_notice.pager.next': 'Siguiente aviso',
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': 'Ir al aviso {n}',
|
||||
'system_notice.pager.position': 'Aviso {current} de {total}',
|
||||
'system_notice.v3_photos.title': 'Las fotos se han movido en 3.0',
|
||||
'system_notice.v3_photos.body':
|
||||
'**Fotos** en el Planificador de Viajes han sido eliminadas. Tus fotos están a salvo — TREK nunca modificó tu biblioteca de Immich o Synology.\n\nLas fotos ahora viven en el addon **Journey**. Journey es opcional — si aún no está disponible, pide a tu admin que lo active en Admin → Complementos.',
|
||||
'system_notice.v3_journey.title': 'Conoce Journey — diario de viaje',
|
||||
'system_notice.v3_journey.body':
|
||||
'Documenta tus viajes como historias enriquecidas con cronologías, galerías de fotos y mapas interactivos.',
|
||||
'system_notice.v3_journey.cta_label': 'Abrir Journey',
|
||||
'system_notice.v3_journey.highlight_timeline': 'Cronología y galería por día',
|
||||
'system_notice.v3_journey.highlight_photos':
|
||||
'Importar desde Immich o Synology',
|
||||
'system_notice.v3_journey.highlight_share':
|
||||
'Compartir públicamente — sin inicio de sesión',
|
||||
'system_notice.v3_journey.highlight_export':
|
||||
'Exportar como libro de fotos PDF',
|
||||
'system_notice.v3_features.title': 'Más novedades en 3.0',
|
||||
'system_notice.v3_features.body':
|
||||
'Otras cosas que vale la pena conocer de esta versión.',
|
||||
'system_notice.v3_features.highlight_dashboard':
|
||||
'Rediseño del panel mobile-first',
|
||||
'system_notice.v3_features.highlight_offline':
|
||||
'Modo sin conexión completo como PWA',
|
||||
'system_notice.v3_features.highlight_search':
|
||||
'Autocompletado de lugares en tiempo real',
|
||||
'system_notice.v3_features.highlight_import':
|
||||
'Importar lugares desde archivos KMZ/KML',
|
||||
'system_notice.v3_mcp.title': 'MCP: actualización OAuth 2.1',
|
||||
'system_notice.v3_mcp.body':
|
||||
'La integración MCP ha sido completamente renovada. OAuth 2.1 es ahora el método de autenticación recomendado. Los tokens estáticos (trek_…) están obsoletos y se eliminarán en una versión futura.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 recomendado (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 ámbitos de permisos granulares',
|
||||
'system_notice.v3_mcp.highlight_deprecated':
|
||||
'Tokens estáticos trek_ obsoletos',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Herramientas y prompts ampliados',
|
||||
'system_notice.v3_thankyou.title': 'Una nota personal de mi parte',
|
||||
'system_notice.v3_thankyou.body':
|
||||
'Antes de seguir — quiero tomarme un momento.\n\nTREK empezó como un proyecto personal que construí para mis propios viajes. Nunca imaginé que crecería hasta convertirse en algo en lo que 4.000 de vosotros confían para planificar sus aventuras. Cada estrella, cada issue, cada solicitud de funcionalidad — los leo todos, y son lo que me mantiene en pie durante las noches largas entre un trabajo a jornada completa y la universidad.\n\nQuiero que sepáis: TREK siempre será open source, siempre self-hosted, siempre vuestro. Sin rastreo, sin suscripciones, sin letra pequeña. Solo una herramienta hecha por alguien que ama viajar tanto como vosotros.\n\nUn agradecimiento especial a [jubnl](https://github.com/jubnl) — te has convertido en un colaborador increíble. Mucho de lo que hace grande la versión 3.0 lleva tu huella. Gracias por creer en este proyecto cuando todavía era un borrador.\n\nY a cada uno de vosotros que reportó un bug, tradujo un texto, compartió TREK con un amigo o simplemente lo usó para planificar un viaje — **gracias**. Vosotros sois la razón de que esto exista.\n\nPor muchas más aventuras juntos.\n\n— Maurice\n\n---\n\n[Únete a la comunidad en Discord](https://discord.gg/7Q6M6jDwzf)\n\nSi TREK mejora tus viajes, un [pequeño café](https://ko-fi.com/mauriceboe) siempre mantiene las luces encendidas.',
|
||||
'system_notice.v3014_whitespace_collision.title':
|
||||
'Acción requerida: conflicto de cuenta de usuario',
|
||||
'system_notice.v3014_whitespace_collision.body':
|
||||
'La actualización 3.0.14 detectó uno o más conflictos de nombre de usuario o correo electrónico causados por espacios en blanco al inicio o al final de los valores almacenados. Las cuentas afectadas se renombraron automáticamente. Revisa los registros del servidor en busca de líneas que empiecen por **[migration] WHITESPACE COLLISION** para identificar qué cuentas necesitan revisión.',
|
||||
};
|
||||
export default system_notice;
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const todo: TranslationStrings = {
|
||||
'todo.subtab.packing': 'Lista de equipaje',
|
||||
'todo.subtab.todo': 'Por hacer',
|
||||
'todo.completed': 'completado(s)',
|
||||
'todo.filter.all': 'Todo',
|
||||
'todo.filter.open': 'Abierto',
|
||||
'todo.filter.done': 'Hecho',
|
||||
'todo.uncategorized': 'Sin categoría',
|
||||
'todo.namePlaceholder': 'Nombre de la tarea',
|
||||
'todo.descriptionPlaceholder': 'Descripción (opcional)',
|
||||
'todo.unassigned': 'Sin asignar',
|
||||
'todo.noCategory': 'Sin categoría',
|
||||
'todo.hasDescription': 'Con descripción',
|
||||
'todo.addItem': 'Nueva tarea',
|
||||
'todo.sidebar.sortBy': 'Ordenar por',
|
||||
'todo.priority': 'Prioridad',
|
||||
'todo.newCategoryLabel': 'nueva',
|
||||
'todo.newCategory': 'Nombre de la categoría',
|
||||
'todo.addCategory': 'Añadir categoría',
|
||||
'todo.newItem': 'Nueva tarea',
|
||||
'todo.empty': 'Aún no hay tareas. ¡Añade una tarea para empezar!',
|
||||
'todo.filter.my': 'Mis tareas',
|
||||
'todo.filter.overdue': 'Vencida',
|
||||
'todo.sidebar.tasks': 'Tareas',
|
||||
'todo.sidebar.categories': 'Categorías',
|
||||
'todo.detail.title': 'Tarea',
|
||||
'todo.detail.description': 'Descripción',
|
||||
'todo.detail.category': 'Categoría',
|
||||
'todo.detail.dueDate': 'Fecha límite',
|
||||
'todo.detail.assignedTo': 'Asignado a',
|
||||
'todo.detail.delete': 'Eliminar',
|
||||
'todo.detail.save': 'Guardar cambios',
|
||||
'todo.detail.create': 'Crear tarea',
|
||||
'todo.detail.priority': 'Prioridad',
|
||||
'todo.detail.noPriority': 'Ninguna',
|
||||
'todo.sortByPrio': 'Prioridad',
|
||||
};
|
||||
export default todo;
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const transport: TranslationStrings = {
|
||||
'transport.addTransport': 'Añadir transporte',
|
||||
'transport.modalTitle.create': 'Añadir transporte',
|
||||
'transport.modalTitle.edit': 'Editar transporte',
|
||||
'transport.title': 'Transportes',
|
||||
'transport.addManual': 'Transporte manual',
|
||||
};
|
||||
export default transport;
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const trip: TranslationStrings = {
|
||||
'trip.tabs.plan': 'Plan',
|
||||
'trip.tabs.transports': 'Transportes',
|
||||
'trip.tabs.reservations': 'Reservas',
|
||||
'trip.tabs.reservationsShort': 'Reservas',
|
||||
'trip.tabs.packing': 'Lista de equipaje',
|
||||
'trip.tabs.packingShort': 'Equipaje',
|
||||
'trip.tabs.lists': 'Listas',
|
||||
'trip.tabs.listsShort': 'Listas',
|
||||
'trip.tabs.budget': "Costs",
|
||||
'trip.tabs.files': 'Archivos',
|
||||
'trip.loading': 'Cargando viaje...',
|
||||
'trip.loadingPhotos': 'Cargando fotos de los lugares...',
|
||||
'trip.mobilePlan': 'Plan',
|
||||
'trip.mobilePlaces': 'Lugares',
|
||||
'trip.toast.placeUpdated': 'Lugar actualizado',
|
||||
'trip.toast.placeAdded': 'Lugar añadido',
|
||||
'trip.toast.placeDeleted': 'Lugar eliminado',
|
||||
'trip.toast.selectDay': 'Selecciona primero un día',
|
||||
'trip.toast.assignedToDay': 'Lugar asignado al día',
|
||||
'trip.toast.reorderError': 'No se pudo reordenar',
|
||||
'trip.toast.reservationUpdated': 'Reserva actualizada',
|
||||
'trip.toast.reservationAdded': 'Reserva añadida',
|
||||
'trip.toast.deleted': 'Eliminado',
|
||||
'trip.confirm.deletePlace': '¿Seguro que quieres eliminar este lugar?',
|
||||
'trip.confirm.deletePlaces': '¿Eliminar {count} lugares?',
|
||||
'trip.toast.placesDeleted': '{count} lugares eliminados',
|
||||
};
|
||||
export default trip;
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const trips: TranslationStrings = {
|
||||
'trips.reminder': 'Recordatorio',
|
||||
'trips.reminderNone': 'Ninguno',
|
||||
'trips.reminderDay': 'día',
|
||||
'trips.reminderDays': 'días',
|
||||
'trips.reminderCustom': 'Personalizado',
|
||||
'trips.memberRemoved': '{username} eliminado',
|
||||
'trips.memberRemoveError': 'Error al eliminar',
|
||||
'trips.memberAdded': '{username} añadido',
|
||||
'trips.memberAddError': 'Error al añadir',
|
||||
'trips.reminderDaysBefore': 'días antes de la salida',
|
||||
'trips.reminderDisabledHint':
|
||||
'Los recordatorios de viaje están desactivados. Actívalos en Admin > Configuración > Notificaciones.',
|
||||
};
|
||||
export default trips;
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const undo: TranslationStrings = {
|
||||
'undo.button': 'Deshacer',
|
||||
'undo.tooltip': 'Deshacer: {action}',
|
||||
'undo.assignPlace': 'Lugar asignado al día',
|
||||
'undo.removeAssignment': 'Lugar eliminado del día',
|
||||
'undo.reorder': 'Lugares reordenados',
|
||||
'undo.optimize': 'Ruta optimizada',
|
||||
'undo.deletePlace': 'Lugar eliminado',
|
||||
'undo.deletePlaces': 'Lugares eliminados',
|
||||
'undo.moveDay': 'Lugar movido a otro día',
|
||||
'undo.lock': 'Bloqueo de lugar activado/desactivado',
|
||||
'undo.importGpx': 'Importación GPX',
|
||||
'undo.importKeyholeMarkup': 'Importación KMZ/KML',
|
||||
'undo.importGoogleList': 'Importación de Google Maps',
|
||||
'undo.importNaverList': 'Importación de Naver Maps',
|
||||
'undo.addPlace': 'Lugar agregado',
|
||||
'undo.done': 'Deshecho: {action}',
|
||||
'undo.importBooking': 'Importar confirmación de reserva',
|
||||
};
|
||||
export default undo;
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const vacay: TranslationStrings = {
|
||||
'vacay.subtitle': 'Planifica y gestiona días de vacaciones',
|
||||
'vacay.settings': 'Ajustes',
|
||||
'vacay.year': 'Año',
|
||||
'vacay.addYear': 'Añadir año siguiente',
|
||||
'vacay.addPrevYear': 'Añadir año anterior',
|
||||
'vacay.removeYear': 'Eliminar año',
|
||||
'vacay.removeYearConfirm': '¿Eliminar {year}?',
|
||||
'vacay.removeYearHint':
|
||||
'Todas las vacaciones y festivos de empresa de este año se borrarán permanentemente.',
|
||||
'vacay.remove': 'Eliminar',
|
||||
'vacay.persons': 'Personas',
|
||||
'vacay.noPersons': 'No se han añadido personas',
|
||||
'vacay.addPerson': 'Añadir persona',
|
||||
'vacay.editPerson': 'Editar persona',
|
||||
'vacay.removePerson': 'Eliminar persona',
|
||||
'vacay.removePersonConfirm': '¿Eliminar a {name}?',
|
||||
'vacay.removePersonHint':
|
||||
'Todas las vacaciones de esta persona se borrarán permanentemente.',
|
||||
'vacay.personName': 'Nombre',
|
||||
'vacay.personNamePlaceholder': 'Introduce un nombre',
|
||||
'vacay.color': 'Color',
|
||||
'vacay.add': 'Añadir',
|
||||
'vacay.legend': 'Leyenda',
|
||||
'vacay.publicHoliday': 'Festivo',
|
||||
'vacay.companyHoliday': 'Festivo de empresa',
|
||||
'vacay.weekend': 'Fin de semana',
|
||||
'vacay.modeVacation': 'Vacaciones',
|
||||
'vacay.modeCompany': 'Festivo de empresa',
|
||||
'vacay.entitlement': 'Derecho',
|
||||
'vacay.entitlementDays': 'Días',
|
||||
'vacay.used': 'Usados',
|
||||
'vacay.remaining': 'Restantes',
|
||||
'vacay.carriedOver': 'de {year}',
|
||||
'vacay.blockWeekends': 'Bloquear fines de semana',
|
||||
'vacay.blockWeekendsHint': 'Impide marcar vacaciones en sábados y domingos',
|
||||
'vacay.weekendDays': 'Días de fin de semana',
|
||||
'vacay.mon': 'Lun',
|
||||
'vacay.tue': 'Mar',
|
||||
'vacay.wed': 'Mié',
|
||||
'vacay.thu': 'Jue',
|
||||
'vacay.fri': 'Vie',
|
||||
'vacay.sat': 'Sáb',
|
||||
'vacay.sun': 'Dom',
|
||||
'vacay.publicHolidays': 'Festivos',
|
||||
'vacay.publicHolidaysHint': 'Marcar festivos en el calendario',
|
||||
'vacay.selectCountry': 'Seleccionar país',
|
||||
'vacay.selectRegion': 'Seleccionar región (opcional)',
|
||||
'vacay.companyHolidays': 'Festivos de empresa',
|
||||
'vacay.companyHolidaysHint':
|
||||
'Permitir marcar días festivos comunes de la empresa',
|
||||
'vacay.companyHolidaysNoDeduct':
|
||||
'Los festivos de empresa no descuentan días de vacaciones.',
|
||||
'vacay.weekStart': 'La semana comienza el',
|
||||
'vacay.weekStartHint': 'Elige si la semana comienza el lunes o el domingo',
|
||||
'vacay.carryOver': 'Arrastrar saldo',
|
||||
'vacay.carryOverHint':
|
||||
'Trasladar automáticamente los días restantes al año siguiente',
|
||||
'vacay.sharing': 'Compartir',
|
||||
'vacay.sharingHint':
|
||||
'Comparte tu calendario de vacaciones con otros usuarios de TREK',
|
||||
'vacay.owner': 'Propietario',
|
||||
'vacay.shareEmailPlaceholder': 'Correo electrónico del usuario de TREK',
|
||||
'vacay.shareSuccess': 'Plan compartido correctamente',
|
||||
'vacay.shareError': 'No se pudo compartir el plan',
|
||||
'vacay.dissolve': 'Deshacer fusión',
|
||||
'vacay.dissolveHint':
|
||||
'Separar de nuevo los calendarios. Tus entradas se conservarán.',
|
||||
'vacay.dissolveAction': 'Disolver',
|
||||
'vacay.dissolved': 'Calendario separado',
|
||||
'vacay.fusedWith': 'Fusionado con',
|
||||
'vacay.you': 'tú',
|
||||
'vacay.noData': 'Sin datos',
|
||||
'vacay.changeColor': 'Cambiar color',
|
||||
'vacay.inviteUser': 'Invitar usuario',
|
||||
'vacay.inviteHint':
|
||||
'Invita a otro usuario de TREK a compartir un calendario combinado de vacaciones.',
|
||||
'vacay.selectUser': 'Seleccionar usuario',
|
||||
'vacay.sendInvite': 'Enviar invitación',
|
||||
'vacay.inviteSent': 'Invitación enviada',
|
||||
'vacay.inviteError': 'No se pudo enviar la invitación',
|
||||
'vacay.pending': 'pendiente',
|
||||
'vacay.noUsersAvailable': 'No hay usuarios disponibles',
|
||||
'vacay.accept': 'Aceptar',
|
||||
'vacay.decline': 'Rechazar',
|
||||
'vacay.acceptFusion': 'Aceptar y fusionar',
|
||||
'vacay.inviteTitle': 'Solicitud de fusión',
|
||||
'vacay.inviteWantsToFuse':
|
||||
'quiere compartir un calendario de vacaciones contigo.',
|
||||
'vacay.fuseInfo1':
|
||||
'Ambos veréis todas las entradas de vacaciones en un único calendario compartido.',
|
||||
'vacay.fuseInfo2': 'Ambas partes pueden crear y editar entradas mutuamente.',
|
||||
'vacay.fuseInfo3':
|
||||
'Ambas partes pueden borrar entradas y cambiar el número de días de vacaciones disponibles.',
|
||||
'vacay.fuseInfo4':
|
||||
'Ajustes como festivos y festivos de empresa se comparten.',
|
||||
'vacay.fuseInfo5':
|
||||
'La fusión puede disolverse en cualquier momento por cualquiera de las partes. Tus entradas se conservarán.',
|
||||
'vacay.addCalendar': 'Añadir calendario',
|
||||
'vacay.calendarColor': 'Color del calendario',
|
||||
'vacay.calendarLabel': 'Etiqueta',
|
||||
'vacay.noCalendars': 'Sin calendarios',
|
||||
};
|
||||
export default vacay;
|
||||
Reference in New Issue
Block a user