mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-27 09:08:30 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd1e7d8fa8 | |||
| 059e72df69 | |||
| dc519b3ea7 | |||
| ad4074f107 | |||
| 0534d7b3a7 | |||
| a0554c2346 | |||
| bc5af23896 | |||
| 92be8a7ca5 | |||
| 44ea1ea0ba | |||
| 3bee600096 | |||
| 5b4030f86b | |||
| e2c00ba18b | |||
| 1050029842 | |||
| 64edf899ac | |||
| 0a9075af73 | |||
| c3a5383f5e | |||
| e96813edcf | |||
| 5e36e38b72 | |||
| 890473ebd5 | |||
| 2b86d1fda7 |
@@ -1300,7 +1300,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.locator('.st-back'),
|
||||
target: p => p.locator('.st-head-close'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 385,
|
||||
"@typescript-eslint/no-non-null-assertion": 384,
|
||||
"@typescript-eslint/no-misused-promises": 849,
|
||||
"no-console": 88,
|
||||
"complexity": 222,
|
||||
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"tests": {
|
||||
"@typescript-eslint/no-explicit-any": 490,
|
||||
"@typescript-eslint/no-non-null-assertion": 1931,
|
||||
"@typescript-eslint/no-non-null-assertion": 1929,
|
||||
"@typescript-eslint/no-misused-promises": 22,
|
||||
"@typescript-eslint/no-unused-vars": 51,
|
||||
"@typescript-eslint/no-floating-promises": 33,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"src/components/PDF/TripPDF.tsx": 1082,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2953,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2936,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1157,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1290,
|
||||
@@ -26,5 +26,5 @@
|
||||
"src/pages/atlas/useAtlas.ts": 1211,
|
||||
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
||||
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
|
||||
"src/styles/studio.css": 2009
|
||||
"src/styles/studio.css": 1936
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-TZHOOK-001 to -007: the world clocks both dashboards share.
|
||||
// FE-COMP-TZHOOK-001 to -009: the world clocks both dashboards share.
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
@@ -97,4 +97,27 @@ describe('useDashboardTimezones', () => {
|
||||
expect(localStorage.getItem('trek_dashboard_tz')).toBeNull();
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// #2694: Chrome on Windows names no zone for a system zone it cannot map.
|
||||
it('FE-COMP-TZHOOK-008: a browser that names no zone still gets the default clocks', () => {
|
||||
const original = Intl.DateTimeFormat.prototype.resolvedOptions;
|
||||
const spy = vi.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockImplementation(function (
|
||||
this: Intl.DateTimeFormat
|
||||
) {
|
||||
return { ...original.call(this), timeZone: undefined as unknown as string };
|
||||
});
|
||||
try {
|
||||
const { result } = renderHook(() => useDashboardTimezones('en'));
|
||||
expect(result.current.zones).toEqual(['Europe/London', 'Asia/Tokyo']);
|
||||
expect(() => result.current.zones.map((tz) => result.current.timeIn(tz))).not.toThrow();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-COMP-TZHOOK-009: a stored zone this browser does not know is left out instead of crashing the clocks', () => {
|
||||
seedSettings(['Europe/Berlin', 'Mars/Olympus_Mons', '']);
|
||||
const { result } = renderHook(() => useDashboardTimezones('en'));
|
||||
expect(result.current.zones).toEqual(['Europe/Berlin']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { browserTimeZone, isValidTimeZone } from '../../utils/localDate';
|
||||
|
||||
const DEFAULT_ZONES = ['Europe/London', 'Asia/Tokyo'];
|
||||
|
||||
@@ -38,13 +39,15 @@ export function shortZone(tz: string): string {
|
||||
* clocks tick every 30 seconds, `adding` toggles the zone picker.
|
||||
*/
|
||||
export function useDashboardTimezones(locale: string) {
|
||||
const home = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const home = browserTimeZone();
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
const isLoaded = useSettingsStore((s) => s.isLoaded);
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
const stored = useSettingsStore((s) => s.settings.dashboard_timezones);
|
||||
// Unset (never chosen) falls back to home + defaults; an explicit list is honoured.
|
||||
const zones = stored ?? [home, ...DEFAULT_ZONES];
|
||||
// A browser that names no zone (#2694) or a zone this browser does not know
|
||||
// would throw while rendering a clock, so such entries are left out.
|
||||
const zones = (stored ?? [home, ...DEFAULT_ZONES]).filter(isValidTimeZone);
|
||||
const setZones = (next: string[]) => {
|
||||
updateSetting('dashboard_timezones', next).catch(() => {});
|
||||
};
|
||||
|
||||
@@ -397,6 +397,19 @@ describe('downloadTripPDF', () => {
|
||||
expect(iframe!.srcdoc).toContain('€')
|
||||
})
|
||||
|
||||
// #2692: a place's description and notes print as the planner shows them, not as raw markdown.
|
||||
it('FE-COMP-TRIPPDF-016c: renders markdown in a place description, sanitized', async () => {
|
||||
const description = ['**Colosseum** tickets', '', '| Gate | Time |', '| --- | --- |', '| A | 9:00 |', '', '<script>alert(1)</script>'].join('\n')
|
||||
const place = { ...placeWithDetails, description }
|
||||
const md = { ...richArgs, places: [place], assignments: { '10': [{ ...assignmentForDay, place }] } as typeof richArgs.assignments }
|
||||
await downloadTripPDF(md)
|
||||
const html = getIframe()?.srcdoc ?? ''
|
||||
expect(html).toContain('<strong>Colosseum</strong>')
|
||||
expect(html).toContain('<td>9:00</td>')
|
||||
expect(html).not.toContain('**Colosseum**')
|
||||
expect(html).not.toContain('<script>alert(1)</script>')
|
||||
})
|
||||
|
||||
it('FE-COMP-TRIPPDF-016b: formats price chip and totals in the trip currency, not EUR', async () => {
|
||||
const usdArgs = {
|
||||
...richArgs,
|
||||
@@ -796,7 +809,7 @@ describe('downloadTripPDF remaining branches', () => {
|
||||
}]))
|
||||
const html = srcdoc()
|
||||
|
||||
expect(html).toContain('ICE 599 · Gl. 7 · Seat 21A · FRA → BER')
|
||||
expect(html).toContain('ICE 599 · reservations.meta.platform 7 · reservations.meta.seat 21A · FRA → BER')
|
||||
})
|
||||
|
||||
it('FE-W5PDF-006: a multi-leg train renders one line per leg', async () => {
|
||||
@@ -811,7 +824,7 @@ describe('downloadTripPDF remaining branches', () => {
|
||||
}]))
|
||||
const html = srcdoc()
|
||||
|
||||
expect(html).toContain('IC 1 · Gl. 3 · BER → MUC')
|
||||
expect(html).toContain('IC 1 · reservations.meta.platform 3 · BER → MUC')
|
||||
expect(html).toContain('EC 2 · MUC → ZRH')
|
||||
})
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { fetchExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { getFlightLegs, getTrainLegs, usesStationRoute } from '../../utils/flightLegs'
|
||||
import { isServiceStopType } from '../Roadtrip/roadtripModel'
|
||||
import { onlyMyPlan } from './pdfScope'
|
||||
import { createPdfMarkdown, PDF_MARKDOWN_CSS } from './pdfMarkdown'
|
||||
|
||||
/**
|
||||
* Every day starts a new page by default. On a trip of short days that prints
|
||||
@@ -231,6 +232,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
let hasTransportNotes = false
|
||||
const loc = _locale || undefined
|
||||
const tr = _t || (k => k)
|
||||
const markdown = createPdfMarkdown()
|
||||
// The store read is the fallback, not the source: a caller that forgets the
|
||||
// prop still prints the reader's own format instead of silently reverting.
|
||||
const is12h = (_timeFormat || useSettingsStore.getState().settings.time_format || '24h') === '12h'
|
||||
@@ -439,8 +441,8 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
const source = (usesStationRoute(r.type) ? getTrainLegs(r) : getFlightLegs(r))[l.index]
|
||||
subtitleLines = [[
|
||||
l.airline, l.flight_number, l.train_number,
|
||||
l.platform ? `Gl. ${l.platform}` : '',
|
||||
l.seat ? `Seat ${l.seat}` : '',
|
||||
l.platform ? `${tr('reservations.meta.platform')} ${l.platform}` : '',
|
||||
l.seat ? `${tr('reservations.meta.seat')} ${l.seat}` : '',
|
||||
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '',
|
||||
source?.confirmation_number,
|
||||
].filter(Boolean).join(' · ')].filter(Boolean)
|
||||
@@ -471,7 +473,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
// Multi-leg: one line per leg so every train number + segment route shows,
|
||||
// with the segment's own booking code where it has one (#1943).
|
||||
subtitleLines = legs.map(l =>
|
||||
[l.train_number, l.platform ? `Gl. ${l.platform}` : '',
|
||||
[l.train_number, l.platform ? `${tr('reservations.meta.platform')} ${l.platform}` : '',
|
||||
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '',
|
||||
l.confirmation_number]
|
||||
.filter(Boolean).join(' · '))
|
||||
@@ -479,7 +481,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
} else {
|
||||
const stops = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)).map(e => e.code || e.name)
|
||||
const route = stops.length >= 2 ? stops.join(' → ') : ''
|
||||
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : '', route].filter(Boolean).join(' · ')
|
||||
subtitle = [meta.train_number, meta.platform ? `${tr('reservations.meta.platform')} ${meta.platform}` : '', meta.seat ? `${tr('reservations.meta.seat')} ${meta.seat}` : '', route].filter(Boolean).join(' · ')
|
||||
}
|
||||
}
|
||||
else if (r.type === 'car') {
|
||||
@@ -587,10 +589,10 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
</div>
|
||||
${place.address ? `<div class="info-row">${svgPin}<span class="info-text">${escHtml(place.address)}</span></div>` : ''}
|
||||
${(place.lat != null && place.lng != null) ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted">${Number(place.lat).toFixed(5)}, ${Number(place.lng).toFixed(5)}</span></div>` : ''}
|
||||
${place.description ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.description)}</span></div>` : ''}
|
||||
${place.description ? markdown.row(place.description) : ''}
|
||||
${chips ? `<div class="chips">${chips}</div>` : ''}
|
||||
${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''}
|
||||
${item.data.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(item.data.notes)}</span></div>` : ''}
|
||||
${place.notes ? markdown.row(place.notes) : ''}
|
||||
${item.data.notes ? markdown.row(item.data.notes) : ''}
|
||||
</div>
|
||||
</div>`
|
||||
}).join('')
|
||||
@@ -836,7 +838,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
.info-spacer { width: 13px; flex-shrink: 0; }
|
||||
.info-text { font-size: 9px; color: #64748b; line-height: 1.5; }
|
||||
.info-text.muted { color: #94a3b8; }
|
||||
.info-text.italic { font-style: italic; }
|
||||
${PDF_MARKDOWN_CSS}
|
||||
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 4px; padding-left: 21px; margin-top: 4px; }
|
||||
.chip { display: inline-flex; align-items: center; gap: 3px; font-size: 8px; font-weight: 600; background: #f1f5f9; color: #374151; border-radius: 99px; padding: 2px 7px; white-space: nowrap; }
|
||||
@@ -930,6 +932,7 @@ ${tripMapHtml}
|
||||
${daysHtml}
|
||||
${pluginSectionsHtml}
|
||||
</body></html>`
|
||||
markdown.dispose()
|
||||
|
||||
// Open in modal with srcdoc iframe (no URL loading = no X-Frame-Options issue)
|
||||
const overlay = document.createElement('div')
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createMarkdownHtml } from '../shared/markdownHtml';
|
||||
|
||||
/**
|
||||
* A place's description and notes are markdown in the app, so the export
|
||||
* renders them the same way instead of printing the raw syntax (#2692): one
|
||||
* info row per text, the HTML from the app's own sanitized pipeline.
|
||||
*/
|
||||
export function createPdfMarkdown(): { row: (text: string) => string; dispose: () => void } {
|
||||
const markdown = createMarkdownHtml();
|
||||
return {
|
||||
row: (text) =>
|
||||
`<div class="info-row"><span class="info-spacer"></span><div class="info-text muted md">${markdown.html(text)}</div></div>`,
|
||||
dispose: markdown.dispose,
|
||||
};
|
||||
}
|
||||
|
||||
/** The markdown's own spacing, lists, tables and links at the export's small size. */
|
||||
export const PDF_MARKDOWN_CSS = `
|
||||
.info-text.md { min-width: 0; overflow-wrap: anywhere; }
|
||||
.md > :first-child { margin-top: 0; }
|
||||
.md > :last-child { margin-bottom: 0; }
|
||||
.md p, .md ul, .md ol, .md table, .md blockquote, .md pre { margin: 0 0 4px; }
|
||||
.md ul, .md ol { padding-inline-start: 14px; }
|
||||
.md h1, .md h2, .md h3, .md h4, .md h5, .md h6 { font-size: 1.1em; font-weight: 700; margin: 4px 0 2px; }
|
||||
.md table { border-collapse: collapse; }
|
||||
.md th, .md td { border: 1px solid #e2e8f0; padding: 2px 5px; text-align: start; }
|
||||
.md code { font-family: ui-monospace, monospace; font-size: 8.5px; }
|
||||
.md a { color: inherit; text-decoration: underline; }
|
||||
.md blockquote { border-inline-start: 2px solid #e2e8f0; padding-inline-start: 6px; }
|
||||
`;
|
||||
@@ -43,6 +43,7 @@ import './dayPlanRows.css'
|
||||
|
||||
import { TypeTile } from './bookings/bookingParts'
|
||||
import { typeInfo } from './bookings/bookingsModel'
|
||||
import { transportSubtitle } from './transportDetailModel'
|
||||
import { dayHeadStays } from './dayHeadStays'
|
||||
import { getAccommodationAnchors, getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder'
|
||||
import {
|
||||
@@ -2166,24 +2167,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
let meta: any = {}
|
||||
try { meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) } catch { meta = {} }
|
||||
|
||||
// Subtitle aus Metadaten zusammensetzen
|
||||
let subtitle = ''
|
||||
if (res.__leg) {
|
||||
// One leg of a multi-leg flight/train — show this segment's own detail.
|
||||
const parts = res.type === 'train'
|
||||
? [res.__leg.train_number, res.__leg.platform ? `${t('reservations.meta.platform')} ${res.__leg.platform}` : '', res.__leg.seat ? `${t('reservations.meta.seat')} ${res.__leg.seat}` : ''].filter(Boolean)
|
||||
: [res.__leg.airline, res.__leg.flight_number].filter(Boolean)
|
||||
if (res.__leg.from || res.__leg.to)
|
||||
parts.push([res.__leg.from, res.__leg.to].filter(Boolean).join(' → '))
|
||||
subtitle = parts.join(', ')
|
||||
} else if (res.type === 'flight') {
|
||||
const parts = [meta.airline, meta.flight_number].filter(Boolean)
|
||||
if (meta.departure_airport || meta.arrival_airport)
|
||||
parts.push([meta.departure_airport, meta.arrival_airport].filter(Boolean).join(' → '))
|
||||
subtitle = parts.join(', ')
|
||||
} else if (res.type === 'train') {
|
||||
subtitle = [meta.train_number, meta.platform ? `${t('reservations.meta.platform')} ${meta.platform}` : '', meta.seat ? `${t('reservations.meta.seat')} ${meta.seat}` : ''].filter(Boolean).join(', ')
|
||||
}
|
||||
// A leg of a multi-leg flight or train shows its own detail (#2701).
|
||||
const subtitle = transportSubtitle(res, t) ?? ''
|
||||
|
||||
// A transit journey (#1065) renders its itinerary inline —
|
||||
// line badges in their colors instead of a plain subtitle,
|
||||
|
||||
@@ -23,3 +23,65 @@ export function bookingDayLabel(date: string, locale: string): string {
|
||||
export function segmentCodeLabel(leg: { from?: string | null; to?: string | null }, t: Translate): string {
|
||||
return [leg.from, leg.to].filter(Boolean).join(' → ') || t('reservations.confirmationCode');
|
||||
}
|
||||
|
||||
/** The per-leg detail the day plan's leg rows carry (utils/dayMerge's `__leg`). */
|
||||
export interface TransportLegDetail {
|
||||
index: number;
|
||||
from?: string | null;
|
||||
to?: string | null;
|
||||
airline?: string | null;
|
||||
flight_number?: string | null;
|
||||
train_number?: string | null;
|
||||
platform?: string | null;
|
||||
seat?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The subtitle of a transport row in the day plan, desktop and phone alike. A
|
||||
* leg of a multi-leg train or flight names its own train or flight, platform
|
||||
* and seat (#2701); a whole booking reads them off its metadata. Null for the
|
||||
* other transport types, which each view labels its own way.
|
||||
*/
|
||||
export function transportSubtitle(
|
||||
res: { type: string; metadata?: unknown; __leg?: TransportLegDetail | null },
|
||||
t: Translate
|
||||
): string | null {
|
||||
const meta = parseMeta(res.metadata);
|
||||
const leg = res.__leg;
|
||||
const trainParts = (d: { train_number?: unknown; platform?: unknown; seat?: unknown }) => [
|
||||
str(d.train_number),
|
||||
str(d.platform) ? `${t('reservations.meta.platform')} ${str(d.platform)}` : '',
|
||||
str(d.seat) ? `${t('reservations.meta.seat')} ${str(d.seat)}` : '',
|
||||
];
|
||||
if (leg) {
|
||||
const parts = res.type === 'train' ? trainParts(leg) : [str(leg.airline), str(leg.flight_number)];
|
||||
if (leg.from || leg.to) parts.push([leg.from, leg.to].filter(Boolean).join(' → '));
|
||||
return parts.filter(Boolean).join(', ');
|
||||
}
|
||||
if (res.type === 'flight') {
|
||||
const parts = [str(meta.airline), str(meta.flight_number)];
|
||||
if (meta.departure_airport || meta.arrival_airport) {
|
||||
parts.push([str(meta.departure_airport), str(meta.arrival_airport)].filter(Boolean).join(' → '));
|
||||
}
|
||||
return parts.filter(Boolean).join(', ');
|
||||
}
|
||||
if (res.type === 'train') return trainParts(meta).filter(Boolean).join(', ');
|
||||
return null;
|
||||
}
|
||||
|
||||
function str(value: unknown): string {
|
||||
return typeof value === 'string' || typeof value === 'number' ? String(value) : '';
|
||||
}
|
||||
|
||||
function parseMeta(metadata: unknown): Record<string, unknown> {
|
||||
let meta = metadata;
|
||||
// An earlier bug could double-encode the metadata; reading it twice heals that.
|
||||
for (let i = 0; i < 2 && typeof meta === 'string'; i++) {
|
||||
try {
|
||||
meta = JSON.parse(meta || '{}');
|
||||
} catch {
|
||||
meta = {};
|
||||
}
|
||||
}
|
||||
return meta && typeof meta === 'object' ? (meta as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { pluginsApi } from '../../api/client'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import { browserTimeZone } from '../../utils/localDate'
|
||||
|
||||
// The design-token contract handed to plugins (#4 richer context): non-secret CSS
|
||||
// values, resolved for the CURRENT theme, so a plugin can match TREK exactly (and
|
||||
@@ -379,7 +380,7 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d
|
||||
timeFormat: settings.time_format,
|
||||
distanceUnit: settings.distance_unit,
|
||||
temperatureUnit: settings.temperature_unit,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
timezone: browserTimeZone() ?? 'UTC',
|
||||
blurBookingCodes: Boolean(settings.blur_booking_codes),
|
||||
},
|
||||
tokens: readThemeTokens(),
|
||||
|
||||
@@ -195,7 +195,11 @@ function useElementAdders(page: BookPageSetup) {
|
||||
const els = gridElements(grid, defaultGridBox(page, single))
|
||||
// The page's own cap, as a duplicate keeps it: frames that would make the book unsaveable are not added.
|
||||
const on = useStudioStore.getState().doc?.spreads[active]
|
||||
if (!on || on.elements.length + els.length > MAX_SPREAD_ELEMENTS) return
|
||||
if (!on) return
|
||||
if (on.elements.length + els.length > MAX_SPREAD_ELEMENTS) {
|
||||
useStudioStore.getState().refuseFull()
|
||||
return
|
||||
}
|
||||
commit(d => ({
|
||||
...d,
|
||||
spreads: d.spreads.map((sp, i) => (i !== active ? sp : { ...sp, elements: [...sp.elements, ...els] })),
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import { StudioHeader } from './StudioHeader';
|
||||
import type { Studio } from './StudioShell';
|
||||
import type { StudioAssistant } from './useStudioAssistant';
|
||||
|
||||
/**
|
||||
* FE-STUDIO-HEAD-001 to FE-STUDIO-HEAD-007: the Studio's head band, with the
|
||||
* hook's answer stubbed to what the band reads.
|
||||
*/
|
||||
|
||||
function studio(over: Partial<Studio> = {}): Studio {
|
||||
return {
|
||||
t: (key: string) => key,
|
||||
journey: { title: 'Japan 2026' },
|
||||
coverUrl: '/uploads/cover.jpg',
|
||||
saveState: { status: 'idle' },
|
||||
peers: [],
|
||||
canUndo: true,
|
||||
canRedo: false,
|
||||
canEdit: true,
|
||||
activeSpread: 0,
|
||||
doc: { spreads: [] },
|
||||
preset: 'square-210',
|
||||
page: { pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 },
|
||||
canRelayoutSpread: true,
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
close: vi.fn(),
|
||||
setPreset: vi.fn(),
|
||||
setPageSize: vi.fn(),
|
||||
setPageEdge: vi.fn(),
|
||||
relayoutCurrentSpread: vi.fn(),
|
||||
relayoutBook: vi.fn(),
|
||||
loadDoc: vi.fn(),
|
||||
acceptTheirs: vi.fn(),
|
||||
keepMine: vi.fn(),
|
||||
saveNow: vi.fn(),
|
||||
...over,
|
||||
} as unknown as Studio;
|
||||
}
|
||||
|
||||
const noAssistant = { available: false } as unknown as StudioAssistant;
|
||||
|
||||
function head(s = studio(), onExport = vi.fn()) {
|
||||
render(<StudioHeader s={s} onExport={onExport} assistant={noAssistant} />);
|
||||
return { s, onExport };
|
||||
}
|
||||
|
||||
describe('StudioHeader', () => {
|
||||
it("FE-STUDIO-HEAD-001: names the journey under the Studio's mark, with its cover as the tile", () => {
|
||||
head();
|
||||
expect(screen.getByRole('heading', { name: 'Japan 2026' })).toBeInTheDocument();
|
||||
expect(screen.getByText('journey.studio.beta')).toBeInTheDocument();
|
||||
expect(document.querySelector('.st-head-tile img')?.getAttribute('src')).toBe('/uploads/cover.jpg');
|
||||
});
|
||||
|
||||
it('FE-STUDIO-HEAD-002: the round button closes the Studio, export hands over', () => {
|
||||
const { s, onExport } = head();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.backToJourney' }));
|
||||
expect(s.close).toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: /journey.studio.export/ }));
|
||||
expect(onExport).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-STUDIO-HEAD-003: undo runs, redo waits for something to redo, paste for something copied', () => {
|
||||
const { s } = head();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.undo' }));
|
||||
expect(s.undo).toHaveBeenCalled();
|
||||
expect(screen.getByRole('button', { name: 'journey.studio.redo' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'journey.studio.paste' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-STUDIO-HEAD-004: the format chip names the format and opens the list, a pick sets it', () => {
|
||||
const { s } = head();
|
||||
const chip = screen.getByRole('button', { name: 'journey.studio.format' });
|
||||
expect(chip).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.click(chip);
|
||||
const options = screen.getAllByRole('option');
|
||||
expect(options.length).toBeGreaterThan(1);
|
||||
fireEvent.click(options[options.length - 1]);
|
||||
expect(s.setPreset).toHaveBeenCalled();
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-STUDIO-HEAD-005: a custom format reads its size, and Escape or a press outside closes the list', () => {
|
||||
head(
|
||||
studio({ preset: 'custom', page: { pageWidth: 180.4, pageHeight: 240, bleed: 3, safe: 5 } } as Partial<Studio>)
|
||||
);
|
||||
const chip = screen.getByRole('button', { name: 'journey.studio.format' });
|
||||
expect(chip).toHaveTextContent('180 × 240 mm');
|
||||
fireEvent.click(chip);
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
fireEvent.click(chip);
|
||||
fireEvent.pointerDown(document.body);
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-STUDIO-HEAD-006: auto layout offers this spread and the whole book', () => {
|
||||
const { s } = head();
|
||||
fireEvent.click(screen.getByRole('button', { name: /journey.studio.autoLayout/ }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /^journey\.studio\.relayoutSpread/ }));
|
||||
expect(s.relayoutCurrentSpread).toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: /journey.studio.autoLayout/ }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /^journey\.studio\.relayoutBook/ }));
|
||||
expect(s.relayoutBook).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-STUDIO-HEAD-007: with something copied, paste puts it on the page on screen', () => {
|
||||
const paste = vi.fn();
|
||||
useStudioStore.setState({ clipboard: { elements: [] } as never, paste } as never);
|
||||
head(studio({ activeSpread: 2 } as Partial<Studio>));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.paste' }));
|
||||
expect(paste).toHaveBeenCalledWith(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,379 @@
|
||||
import { Check, ChevronDown, ClipboardPaste, Download, Redo2, Sparkles, Undo2, X } from 'lucide-react';
|
||||
import { useEffect, useRef, useState, type RefObject } from 'react';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import '../../styles/studio-head.css';
|
||||
import HelpButton from '../Help/HelpButton';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { PAGE_PRESET_ORDER, PAGE_PRESETS } from './pagePresets';
|
||||
import { PeerBadges } from './PeerBadges';
|
||||
import { SaveIndicator } from './SaveIndicator';
|
||||
import { StudioAssistantToggle } from './StudioAssistantPanel';
|
||||
import type { Studio } from './StudioShell';
|
||||
import { StudioWordmark } from './StudioWordmark';
|
||||
import { TrimField } from './TrimField';
|
||||
import type { StudioAssistant } from './useStudioAssistant';
|
||||
|
||||
/**
|
||||
* The Studio's head band, in the shape every TREK dialog has: a raised tile on
|
||||
* the left, a small line above the title, the title, a line under it, and the
|
||||
* round close button at the far end.
|
||||
*
|
||||
* The tile is the journey's cover and the title its name, so it is always clear
|
||||
* whose book this is. What the book is printed as and whether it is saved sit
|
||||
* under the title, because they describe it. The tools on the right are grouped
|
||||
* by what they do: who else is here, editing (undo, redo, paste), making
|
||||
* (auto layout, the assistant), and getting the book out (help, export). How
|
||||
* the page is looked at, the book view, lives with the zoom under the page.
|
||||
*/
|
||||
export function StudioHeader({
|
||||
s,
|
||||
onExport,
|
||||
assistant,
|
||||
}: {
|
||||
s: Studio;
|
||||
onExport: () => void;
|
||||
assistant: StudioAssistant;
|
||||
}) {
|
||||
return (
|
||||
<header className="st-bar st-head">
|
||||
<span className="st-head-tile">{s.coverUrl ? <img src={s.coverUrl} alt="" /> : null}</span>
|
||||
|
||||
<div className="st-head-text">
|
||||
<div className="st-head-eyebrow">
|
||||
<StudioWordmark style={{ height: 17 }} />
|
||||
<span className="st-beta">{s.t('journey.studio.beta')}</span>
|
||||
</div>
|
||||
<h1 className="st-head-title">{s.journey?.title || s.t('journey.title')}</h1>
|
||||
<div className="st-head-sub">
|
||||
<FormatPicker s={s} />
|
||||
<SaveIndicator
|
||||
state={s.saveState}
|
||||
t={s.t}
|
||||
onAcceptTheirs={(current) => s.loadDoc(s.acceptTheirs(current))}
|
||||
onKeepMine={s.keepMine}
|
||||
onRetry={() => {
|
||||
void s.saveNow();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="st-head-tools">
|
||||
<PeerBadges peers={s.peers} t={s.t} />
|
||||
|
||||
<div className="st-head-group">
|
||||
<IconTool label={s.t('journey.studio.undo')} disabled={!s.canUndo} onClick={s.undo}>
|
||||
<Undo2 size={15} />
|
||||
</IconTool>
|
||||
<IconTool label={s.t('journey.studio.redo')} disabled={!s.canRedo} onClick={s.redo}>
|
||||
<Redo2 size={15} />
|
||||
</IconTool>
|
||||
<PasteButton s={s} />
|
||||
</div>
|
||||
|
||||
<div className="st-head-group">
|
||||
<AutoLayoutButton s={s} />
|
||||
{/* The book's assistant; only where it can run for this user. */}
|
||||
<StudioAssistantToggle assistant={assistant} />
|
||||
</div>
|
||||
|
||||
{/* Studio has no navbar, so the help center's button rides on its own bar. */}
|
||||
<HelpButton className="st-tool is-icon" />
|
||||
<button
|
||||
type="button"
|
||||
className="st-tool is-primary"
|
||||
onClick={onExport}
|
||||
disabled={!s.doc}
|
||||
title={s.t('journey.studio.export')}
|
||||
>
|
||||
<Download size={14} />
|
||||
<span className="st-tool-label">{s.t('journey.studio.export')}</span>
|
||||
</button>
|
||||
|
||||
<Tooltip label={s.t('journey.studio.backToJourney')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={s.close}
|
||||
className="st-head-close"
|
||||
aria-label={s.t('journey.studio.backToJourney')}
|
||||
>
|
||||
<X size={16} strokeWidth={2.2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function IconTool({
|
||||
label,
|
||||
disabled,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<button type="button" className="st-tool is-icon" disabled={disabled} onClick={onClick} aria-label={label}>
|
||||
{children}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes a popover on a pointer press outside it and on Escape. Escape is
|
||||
* swallowed, or the shell's own handler would close Studio itself.
|
||||
*/
|
||||
function useDismiss(box: RefObject<HTMLElement | null>, open: boolean, setOpen: (open: boolean) => void) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (!box.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', onDown);
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onDown);
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
};
|
||||
}, [box, open, setOpen]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paste what was copied onto the page on screen (#2316). Beside undo and redo
|
||||
* because it is the same kind of thing, and there so the keyboard shortcut is
|
||||
* not the only way to find it.
|
||||
*/
|
||||
function PasteButton({ s }: { s: Studio }) {
|
||||
const clipboard = useStudioStore((st) => st.clipboard);
|
||||
const paste = useStudioStore((st) => st.paste);
|
||||
const label = s.t('journey.studio.paste');
|
||||
return (
|
||||
<Tooltip label={clipboard ? label : s.t('journey.studio.pasteEmpty')}>
|
||||
<button
|
||||
type="button"
|
||||
className="st-tool is-icon"
|
||||
disabled={!clipboard || !s.canEdit}
|
||||
onClick={() => paste(s.activeSpread)}
|
||||
aria-label={label}
|
||||
>
|
||||
<ClipboardPaste size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay out again: this spread, or the whole book.
|
||||
*
|
||||
* A split button rather than two: they are the same action at two scopes, and
|
||||
* the spread is the one you want nine times out of ten. Rebuilding the whole
|
||||
* book is behind the chevron, where it cannot be hit by accident, and both are
|
||||
* ordinary undo steps.
|
||||
*/
|
||||
function AutoLayoutButton({ s }: { s: Studio }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
useDismiss(box, open, setOpen);
|
||||
|
||||
return (
|
||||
<div className="st-picker" ref={box}>
|
||||
<button
|
||||
type="button"
|
||||
className="st-tool"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
title={s.t('journey.studio.autoLayout')}
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
<span className="st-tool-label">{s.t('journey.studio.autoLayout')}</span>
|
||||
<ChevronDown size={13} style={{ opacity: 0.5 }} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="st-menu" role="menu">
|
||||
<button
|
||||
type="button"
|
||||
className="st-menu-item"
|
||||
role="menuitem"
|
||||
disabled={!s.canRelayoutSpread}
|
||||
onClick={() => {
|
||||
s.relayoutCurrentSpread();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{s.t('journey.studio.relayoutSpread')}</span>
|
||||
<span className="st-menu-dim">
|
||||
{s.t(s.canRelayoutSpread ? 'journey.studio.relayoutSpreadHint' : 'journey.studio.relayoutSpreadNone')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="st-menu-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
s.relayoutBook();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{s.t('journey.studio.relayoutBook')}</span>
|
||||
<span className="st-menu-dim">{s.t('journey.studio.relayoutBookHint')}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The format, as a chip under the book's name: it says what the book is
|
||||
* printed as, and opening it changes that.
|
||||
*
|
||||
* A native <select> was the first draft and it was wrong: the browser draws the
|
||||
* list in its own chrome, so it ignores the theme entirely and drops a list that
|
||||
* runs off the bottom of the screen. This is a small popover instead, themed,
|
||||
* anchored under the chip, and it shows each format's proportions rather than
|
||||
* only naming them.
|
||||
*/
|
||||
function FormatPicker({ s }: { s: Studio }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
useDismiss(box, open, setOpen);
|
||||
|
||||
const active = PAGE_PRESETS[s.preset] ?? PAGE_PRESETS['square-210'];
|
||||
const width = s.preset === 'custom' ? s.page.pageWidth : active.pageWidthMm;
|
||||
const height = s.preset === 'custom' ? s.page.pageHeight : active.pageHeightMm;
|
||||
|
||||
return (
|
||||
<div className="st-picker" ref={box}>
|
||||
<button
|
||||
type="button"
|
||||
className="st-head-chip"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={s.t('journey.studio.format')}
|
||||
>
|
||||
<span
|
||||
className="st-head-chip-shape"
|
||||
style={
|
||||
width >= height
|
||||
? { width: 12, height: Math.max(6, Math.round((12 * height) / width)) }
|
||||
: { height: 12, width: Math.max(6, Math.round((12 * width) / height)) }
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
{s.preset === 'custom'
|
||||
? `${Math.round(s.page.pageWidth)} × ${Math.round(s.page.pageHeight)} mm`
|
||||
: s.t(active.labelKey)}
|
||||
</span>
|
||||
<ChevronDown size={12} style={{ opacity: 0.55 }} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="st-menu st-head-menu" role="listbox">
|
||||
{PAGE_PRESET_ORDER.map((id) => {
|
||||
const p = PAGE_PRESETS[id];
|
||||
const on = id === s.preset;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={id}
|
||||
role="option"
|
||||
aria-selected={on}
|
||||
className={`st-menu-item ${on ? 'is-active' : ''}`}
|
||||
onClick={() => {
|
||||
s.setPreset(id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="st-menu-tile">
|
||||
<span
|
||||
className="st-menu-shape"
|
||||
style={
|
||||
p.pageWidthMm >= p.pageHeightMm
|
||||
? { width: 24, height: Math.round((24 * p.pageHeightMm) / p.pageWidthMm) }
|
||||
: { height: 24, width: Math.round((24 * p.pageWidthMm) / p.pageHeightMm) }
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{s.t(p.labelKey)}</span>
|
||||
<span className="st-menu-dim">
|
||||
{p.pageWidthMm} × {p.pageHeightMm} mm
|
||||
</span>
|
||||
</span>
|
||||
{on && <Check size={14} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/*
|
||||
The two fields live under the list rather than behind a "custom"
|
||||
entry that opens a second popover: picking the custom row and then
|
||||
typing into the fields it reveals is two steps for one decision, and
|
||||
typing into them *is* picking custom; the hook switches the preset
|
||||
for you.
|
||||
*/}
|
||||
<div className="st-menu-sep" />
|
||||
<div className="st-menu-custom">
|
||||
<TrimField
|
||||
label={s.t('journey.studio.width')}
|
||||
value={s.page.pageWidth}
|
||||
onCommit={(v) => s.setPageSize('w', v)}
|
||||
/>
|
||||
<span className="st-menu-times">×</span>
|
||||
<TrimField
|
||||
label={s.t('journey.studio.height')}
|
||||
value={s.page.pageHeight}
|
||||
onCommit={(v) => s.setPageSize('h', v)}
|
||||
/>
|
||||
<span className="st-menu-unit">mm</span>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
What the press takes off, and what it must not print into. Under the
|
||||
trim size because they answer the same question, how big is this book
|
||||
really, and because a vendor states all three in one breath.
|
||||
*/}
|
||||
<div className="st-menu-custom">
|
||||
<TrimField
|
||||
label={s.t('journey.studio.bleed')}
|
||||
value={s.page.bleed}
|
||||
min={0}
|
||||
max={20}
|
||||
step={0.5}
|
||||
onCommit={(v) => s.setPageEdge('bleed', v)}
|
||||
/>
|
||||
<span className="st-menu-times" />
|
||||
<TrimField
|
||||
label={s.t('journey.studio.safeArea')}
|
||||
value={s.page.safe}
|
||||
min={0}
|
||||
max={40}
|
||||
step={0.5}
|
||||
onCommit={(v) => s.setPageEdge('safe', v)}
|
||||
/>
|
||||
<span className="st-menu-unit">mm</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import { useToast } from '../shared/Toast';
|
||||
|
||||
/**
|
||||
* What the Studio says about a click it could not carry out. Today one thing:
|
||||
* the page already holds as many elements as a page can, so a sticker, a frame,
|
||||
* a paste or a duplicate was not added. Every path that refuses counts it in the
|
||||
* store, and this says it once per count rather than each path in its own words.
|
||||
*/
|
||||
export function StudioNotices() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const refused = useStudioStore((s) => s.refusedFull);
|
||||
const seen = useRef(refused);
|
||||
useEffect(() => {
|
||||
if (refused === seen.current) return;
|
||||
seen.current = refused;
|
||||
toast.warning(t('journey.studio.pageFull'));
|
||||
}, [refused, toast, t]);
|
||||
return null;
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useNavigate } from 'react-router'
|
||||
import {
|
||||
ArrowLeft, BookOpen, Check, ChevronDown, ClipboardPaste, Download, Maximize2, Minus, Plus,
|
||||
Redo2, Sparkles, Undo2,
|
||||
} from 'lucide-react'
|
||||
import { BookOpen, Download, Maximize2, Minus, Plus } from 'lucide-react'
|
||||
import { useJourneyStudio } from '../../pages/journeyStudio/useJourneyStudio'
|
||||
import { PAGE_PRESET_ORDER, PAGE_PRESETS } from './pagePresets'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { foliosOf } from './bookSheets'
|
||||
import { StudioSidebar } from './StudioSidebar'
|
||||
import type { RailSection } from './sidebar/railSections'
|
||||
@@ -15,21 +12,16 @@ import { useAuthStore } from '../../store/authStore'
|
||||
import { StudioCanvas } from './StudioCanvas'
|
||||
import { StudioInspector } from './StudioInspector'
|
||||
import { StudioPaletteScope } from './paint/StudioPalette'
|
||||
import { StudioWordmark } from './StudioWordmark'
|
||||
import { SaveIndicator } from './SaveIndicator'
|
||||
import { downloadSpread } from './spreadFile'
|
||||
import { StudioExport } from './StudioExport'
|
||||
import { PeerBadges } from './PeerBadges'
|
||||
import { TrimField } from './TrimField'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { useStudioStore } from '../../store/studioStore'
|
||||
import { StudioAssistantColumn, StudioAssistantToggle, StudioPageStage } from './StudioAssistantPanel'
|
||||
import { useStudioAssistant, type StudioAssistant } from './useStudioAssistant'
|
||||
import { StudioAssistantColumn, StudioPageStage } from './StudioAssistantPanel'
|
||||
import { useStudioAssistant } from './useStudioAssistant'
|
||||
import { StudioHeader } from './StudioHeader'
|
||||
import { StudioNotices } from './StudioNotices'
|
||||
import '../../styles/dashboard.css'
|
||||
import '../../styles/studio.css'
|
||||
import './bookFontFaces'
|
||||
import { useWarmBookFaces } from './bookFontWarm'
|
||||
import HelpButton from '../Help/HelpButton'
|
||||
|
||||
/**
|
||||
* The Studio shell: top bar, page rail, workbench, inspector.
|
||||
@@ -93,13 +85,8 @@ export default function StudioShell() {
|
||||
aria-modal="true"
|
||||
aria-label={s.t('journey.studio.title')}
|
||||
>
|
||||
<StudioBar
|
||||
s={s}
|
||||
bookView={bookView}
|
||||
setBookView={setBookView}
|
||||
onExport={() => setExporting(true)}
|
||||
assistant={assistant}
|
||||
/>
|
||||
<StudioHeader s={s} onExport={() => setExporting(true)} assistant={assistant} />
|
||||
<StudioNotices />
|
||||
{/* The journey gallery's picker, inside Studio's own layer (#2271). */}
|
||||
{s.providerPick && s.journey && (
|
||||
<ProviderPicker
|
||||
@@ -132,7 +119,7 @@ export default function StudioShell() {
|
||||
section={railSection}
|
||||
onSection={setRailSection}
|
||||
/>
|
||||
<Workbench s={s} bookView={bookView} />
|
||||
<Workbench s={s} bookView={bookView} setBookView={setBookView} />
|
||||
<StudioPaletteScope spreadIndex={s.activeSpread} t={s.t}>
|
||||
<StudioInspector
|
||||
spreadIndex={s.activeSpread}
|
||||
@@ -164,339 +151,10 @@ export default function StudioShell() {
|
||||
)
|
||||
}
|
||||
|
||||
type Studio = ReturnType<typeof useJourneyStudio>
|
||||
export type Studio = ReturnType<typeof useJourneyStudio>
|
||||
|
||||
function StudioBar({
|
||||
s, bookView, setBookView, onExport, assistant,
|
||||
}: {
|
||||
s: Studio; bookView: boolean; setBookView: (v: boolean) => void; onExport: () => void
|
||||
assistant: StudioAssistant
|
||||
}) {
|
||||
return (
|
||||
<div className="st-bar">
|
||||
<button type="button" onClick={s.close} className="st-back" title={s.t('journey.studio.backToJourney')}>
|
||||
<ArrowLeft size={16} />
|
||||
{s.coverUrl
|
||||
? <img src={s.coverUrl} alt="" className="st-cover" />
|
||||
: <span className="st-cover" />}
|
||||
<span className="st-back-label">{s.journey?.title || s.t('journey.title')}</span>
|
||||
</button>
|
||||
|
||||
<div className="st-sep" />
|
||||
|
||||
{/*
|
||||
The lockup, not a name field. The book is the journey's, and it was
|
||||
already titled after it — a second place to type the same name is a
|
||||
field whose only job is to be left alone. What the bar is short of is
|
||||
somewhere to say what this thing *is*.
|
||||
*/}
|
||||
<div className="st-brand">
|
||||
<StudioWordmark style={{ height: 26 }} />
|
||||
<span className="st-beta">{s.t('journey.studio.beta')}</span>
|
||||
</div>
|
||||
|
||||
<PeerBadges peers={s.peers} t={s.t} />
|
||||
|
||||
<SaveIndicator
|
||||
state={s.saveState}
|
||||
t={s.t}
|
||||
onAcceptTheirs={current => s.loadDoc(s.acceptTheirs(current))}
|
||||
onKeepMine={s.keepMine}
|
||||
onRetry={() => { void s.saveNow() }}
|
||||
/>
|
||||
|
||||
<div className="st-bar-group">
|
||||
<button type="button"
|
||||
className={`st-tool ${bookView ? 'is-on' : ''}`}
|
||||
onClick={() => setBookView(!bookView)}
|
||||
title={s.t('journey.studio.bookView')}
|
||||
>
|
||||
<BookOpen size={14} />
|
||||
<span className="st-tool-label">{s.t('journey.studio.bookView')}</span>
|
||||
</button>
|
||||
|
||||
<div className="st-sep" />
|
||||
|
||||
<FormatPicker s={s} />
|
||||
|
||||
<div className="st-sep" />
|
||||
|
||||
<button type="button"
|
||||
className="st-tool is-icon"
|
||||
disabled={!s.canUndo}
|
||||
onClick={s.undo}
|
||||
title={s.t('journey.studio.undo')}
|
||||
aria-label={s.t('journey.studio.undo')}
|
||||
>
|
||||
<Undo2 size={15} />
|
||||
</button>
|
||||
<button type="button"
|
||||
className="st-tool is-icon"
|
||||
disabled={!s.canRedo}
|
||||
onClick={s.redo}
|
||||
title={s.t('journey.studio.redo')}
|
||||
aria-label={s.t('journey.studio.redo')}
|
||||
>
|
||||
<Redo2 size={15} />
|
||||
</button>
|
||||
<PasteButton s={s} />
|
||||
|
||||
<div className="st-sep" />
|
||||
|
||||
<AutoLayoutButton s={s} />
|
||||
{/* The book's assistant, as a column beside the page; only where it can run for this user. */}
|
||||
<StudioAssistantToggle assistant={assistant} />
|
||||
<button type="button"
|
||||
className="st-tool is-primary"
|
||||
onClick={onExport}
|
||||
disabled={!s.doc}
|
||||
title={s.t('journey.studio.export')}
|
||||
>
|
||||
<Download size={14} />
|
||||
<span className="st-tool-label">{s.t('journey.studio.export')}</span>
|
||||
</button>
|
||||
{/* Studio has no navbar, so the help center's button rides on its own bar. */}
|
||||
<HelpButton className="st-tool is-icon" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay out again — this spread, or the whole book.
|
||||
*
|
||||
* A split button rather than two: they are the same action at two scopes, and
|
||||
* the spread is the one you want nine times out of ten. Rebuilding the whole
|
||||
* book is behind the chevron, where it cannot be hit by accident, and both are
|
||||
* ordinary undo steps.
|
||||
*/
|
||||
/**
|
||||
* Paste what was copied onto the page on screen (#2316). Beside undo and redo
|
||||
* because it is the same kind of thing, and there so the keyboard shortcut is
|
||||
* not the only way to find it.
|
||||
*/
|
||||
function PasteButton({ s }: { s: Studio }) {
|
||||
const clipboard = useStudioStore(st => st.clipboard)
|
||||
const paste = useStudioStore(st => st.paste)
|
||||
const label = s.t('journey.studio.paste')
|
||||
return (
|
||||
<Tooltip label={clipboard ? label : s.t('journey.studio.pasteEmpty')}>
|
||||
<button type="button"
|
||||
className="st-tool is-icon"
|
||||
disabled={!clipboard || !s.canEdit}
|
||||
onClick={() => paste(s.activeSpread)}
|
||||
aria-label={label}
|
||||
>
|
||||
<ClipboardPaste size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function AutoLayoutButton({ s }: { s: Studio }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (!box.current?.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { e.stopPropagation(); setOpen(false) }
|
||||
}
|
||||
document.addEventListener('pointerdown', onDown)
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onDown)
|
||||
document.removeEventListener('keydown', onKey, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div className="st-picker" ref={box}>
|
||||
<button type="button"
|
||||
className="st-tool"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
title={s.t('journey.studio.autoLayout')}
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
<span className="st-tool-label">{s.t('journey.studio.autoLayout')}</span>
|
||||
<ChevronDown size={13} style={{ opacity: .5 }} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="st-menu" role="menu">
|
||||
<button type="button"
|
||||
className="st-menu-item"
|
||||
role="menuitem"
|
||||
disabled={!s.canRelayoutSpread}
|
||||
onClick={() => { s.relayoutCurrentSpread(); setOpen(false) }}
|
||||
>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{s.t('journey.studio.relayoutSpread')}</span>
|
||||
<span className="st-menu-dim">
|
||||
{s.t(s.canRelayoutSpread ? 'journey.studio.relayoutSpreadHint' : 'journey.studio.relayoutSpreadNone')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
className="st-menu-item"
|
||||
role="menuitem"
|
||||
onClick={() => { s.relayoutBook(); setOpen(false) }}
|
||||
>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{s.t('journey.studio.relayoutBook')}</span>
|
||||
<span className="st-menu-dim">{s.t('journey.studio.relayoutBookHint')}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The format picker.
|
||||
*
|
||||
* A native <select> was the first draft and it was wrong: the browser draws the
|
||||
* list in its own chrome, so it ignores the theme entirely and drops a list that
|
||||
* runs off the bottom of the screen. This is a small popover instead — themed,
|
||||
* anchored under the bar, and it shows each format's proportions rather than
|
||||
* only naming them.
|
||||
*/
|
||||
function FormatPicker({ s }: { s: Studio }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (!box.current?.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
// Swallow it, or the shell's Escape handler would close Studio itself.
|
||||
if (e.key === 'Escape') { e.stopPropagation(); setOpen(false) }
|
||||
}
|
||||
document.addEventListener('pointerdown', onDown)
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onDown)
|
||||
document.removeEventListener('keydown', onKey, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const active = PAGE_PRESETS[s.preset] ?? PAGE_PRESETS['square-210']
|
||||
|
||||
return (
|
||||
<div className="st-picker" ref={box}>
|
||||
<button type="button"
|
||||
className="st-tool"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={s.t('journey.studio.format')}
|
||||
>
|
||||
<span className="st-tool-label">
|
||||
{s.preset === 'custom'
|
||||
? `${Math.round(s.page.pageWidth)} × ${Math.round(s.page.pageHeight)} mm`
|
||||
: s.t(active.labelKey)}
|
||||
</span>
|
||||
<ChevronDown size={13} style={{ opacity: .5 }} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="st-menu" role="listbox">
|
||||
{PAGE_PRESET_ORDER.map(id => {
|
||||
const p = PAGE_PRESETS[id]
|
||||
const on = id === s.preset
|
||||
return (
|
||||
<button type="button"
|
||||
key={id}
|
||||
role="option"
|
||||
aria-selected={on}
|
||||
className={`st-menu-item ${on ? 'is-active' : ''}`}
|
||||
onClick={() => { s.setPreset(id); setOpen(false) }}
|
||||
>
|
||||
<span className="st-menu-tile">
|
||||
<span
|
||||
className="st-menu-shape"
|
||||
style={
|
||||
p.pageWidthMm >= p.pageHeightMm
|
||||
? { width: 24, height: Math.round(24 * p.pageHeightMm / p.pageWidthMm) }
|
||||
: { height: 24, width: Math.round(24 * p.pageWidthMm / p.pageHeightMm) }
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{s.t(p.labelKey)}</span>
|
||||
<span className="st-menu-dim">{p.pageWidthMm} × {p.pageHeightMm} mm</span>
|
||||
</span>
|
||||
{on && <Check size={14} />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/*
|
||||
The two fields live under the list rather than behind a "custom"
|
||||
entry that opens a second popover: picking the custom row and then
|
||||
typing into the fields it reveals is two steps for one decision, and
|
||||
typing into them *is* picking custom — the hook switches the preset
|
||||
for you.
|
||||
*/}
|
||||
<div className="st-menu-sep" />
|
||||
<div className="st-menu-custom">
|
||||
<TrimField
|
||||
label={s.t('journey.studio.width')}
|
||||
value={s.page.pageWidth}
|
||||
onCommit={v => s.setPageSize('w', v)}
|
||||
/>
|
||||
<span className="st-menu-times">×</span>
|
||||
<TrimField
|
||||
label={s.t('journey.studio.height')}
|
||||
value={s.page.pageHeight}
|
||||
onCommit={v => s.setPageSize('h', v)}
|
||||
/>
|
||||
<span className="st-menu-unit">mm</span>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
What the press takes off, and what it must not print into.
|
||||
|
||||
Under the trim size because they answer the same question — how big
|
||||
is this book, really — and because a vendor states all three in one
|
||||
breath. They were fixed at 3 and 5, which is what most photo-book
|
||||
printers ask for and is useless to the one that asks for 5 and 10.
|
||||
*/}
|
||||
<div className="st-menu-custom">
|
||||
<TrimField
|
||||
label={s.t('journey.studio.bleed')}
|
||||
value={s.page.bleed}
|
||||
min={0}
|
||||
max={20}
|
||||
step={0.5}
|
||||
onCommit={v => s.setPageEdge('bleed', v)}
|
||||
/>
|
||||
<span className="st-menu-times" />
|
||||
<TrimField
|
||||
label={s.t('journey.studio.safeArea')}
|
||||
value={s.page.safe}
|
||||
min={0}
|
||||
max={40}
|
||||
step={0.5}
|
||||
onCommit={v => s.setPageEdge('safe', v)}
|
||||
/>
|
||||
<span className="st-menu-unit">mm</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Workbench({ s, bookView }: { s: Studio; bookView: boolean }) {
|
||||
function Workbench({ s, bookView, setBookView }: { s: Studio; bookView: boolean; setBookView: (on: boolean) => void }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="st-work-wrap">
|
||||
<div className="st-work" ref={s.workRef}>
|
||||
@@ -538,6 +196,16 @@ function Workbench({ s, bookView }: { s: Studio; bookView: boolean }) {
|
||||
<button type="button" onClick={s.zoomToFit} aria-label={s.t('journey.studio.zoomFit')} title={s.t('journey.studio.zoomFit')}>
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
{/* How the page is looked at, so with the zoom rather than in the head band. */}
|
||||
<button type="button"
|
||||
onClick={() => setBookView(!bookView)}
|
||||
aria-pressed={bookView}
|
||||
aria-label={t('journey.studio.bookView')}
|
||||
title={t('journey.studio.bookView')}
|
||||
className={bookView ? 'is-on' : undefined}
|
||||
>
|
||||
<BookOpen size={14} />
|
||||
</button>
|
||||
{/*
|
||||
The page, as a file.
|
||||
|
||||
|
||||
@@ -99,7 +99,11 @@ export function StudioTextStyles({
|
||||
const place = (lockup: BookLockup) => {
|
||||
const els = placeLockup(lockup, { page, spread, words, gallery: source.photos, newId: bookElementId });
|
||||
// The page's own cap, as a duplicate keeps it: a set that would make the book unsaveable is not placed.
|
||||
if (!els.length || !spread || spread.elements.length + els.length > MAX_SPREAD_ELEMENTS) return;
|
||||
if (!els.length || !spread) return;
|
||||
if (spread.elements.length + els.length > MAX_SPREAD_ELEMENTS) {
|
||||
useStudioStore.getState().refuseFull();
|
||||
return;
|
||||
}
|
||||
commit((d) => ({
|
||||
...d,
|
||||
spreads: d.spreads.map((sp, i) => (i === active ? addBookLockup(sp, els, bookElementId('g')) : sp)),
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createMarkdownHtml } from './markdownHtml';
|
||||
import { SafeMarkdown } from './SafeMarkdown';
|
||||
|
||||
describe('SafeMarkdown (#2692)', () => {
|
||||
it('FE-COMP-SAFEMD-001: renders GitHub markdown with kept line breaks and safe links', () => {
|
||||
render(<SafeMarkdown>{'# Title\nfirst\nsecond\n\n[site](https://example.com)'}</SafeMarkdown>);
|
||||
expect(screen.getByRole('heading', { name: 'Title' })).toBeInTheDocument();
|
||||
expect(document.querySelector('br')).not.toBeNull();
|
||||
const link = screen.getByRole('link', { name: 'site' });
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow');
|
||||
});
|
||||
|
||||
it('FE-COMP-SAFEMD-002: shows raw HTML as text and never as an element', () => {
|
||||
render(<SafeMarkdown>{'Room <TBD> <img src=x onerror=alert(1)>'}</SafeMarkdown>);
|
||||
expect(document.querySelector('img')).toBeNull();
|
||||
expect(screen.getByText(/Room <TBD>/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMarkdownHtml (#2692)', () => {
|
||||
it('FE-COMP-SAFEMD-003: returns the sanitized HTML, the same for the same text', () => {
|
||||
const markdown = createMarkdownHtml();
|
||||
try {
|
||||
const html = markdown.html('**bold** and <script>alert(1)</script>');
|
||||
expect(html).toContain('<strong>bold</strong>');
|
||||
expect(html).not.toContain('<script>');
|
||||
expect(markdown.html('**bold** and <script>alert(1)</script>')).toBe(html);
|
||||
expect(markdown.html('- a\n- b')).toContain('<li>a</li>');
|
||||
} finally {
|
||||
markdown.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { sanitizedMarkdownComponents, sanitizedMarkdownPlugins } from './markdownSanitize';
|
||||
|
||||
/**
|
||||
* Text a user wrote in markdown (a place's description, its notes), rendered
|
||||
* the way the planner shows it: GitHub flavour, line breaks kept, raw HTML
|
||||
* shown as text, the rest sanitized, links in a new tab without an opener.
|
||||
* Safe for pages strangers see, like a shared trip (#2692).
|
||||
*/
|
||||
export function SafeMarkdown({ children }: { children: string }) {
|
||||
return (
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
rehypePlugins={sanitizedMarkdownPlugins}
|
||||
components={sanitizedMarkdownComponents}
|
||||
>
|
||||
{children}
|
||||
</Markdown>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { flushSync } from 'react-dom';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { SafeMarkdown } from './SafeMarkdown';
|
||||
|
||||
/**
|
||||
* Turns markdown into the HTML {@link SafeMarkdown} renders, for output built as
|
||||
* a string such as the PDF export (#2692). It renders through the same
|
||||
* pipeline into a detached node, so the sanitizer is the one the app uses and
|
||||
* no second markdown library is bundled. Call it outside a render and dispose
|
||||
* of it when done; the same text is rendered once.
|
||||
*/
|
||||
export function createMarkdownHtml(): { html: (text: string) => string; dispose: () => void } {
|
||||
const host = document.createElement('div');
|
||||
const root = createRoot(host);
|
||||
const done = new Map<string, string>();
|
||||
return {
|
||||
html: (text) => {
|
||||
const known = done.get(text);
|
||||
if (known !== undefined) return known;
|
||||
flushSync(() => root.render(<SafeMarkdown>{text}</SafeMarkdown>));
|
||||
done.set(text, host.innerHTML);
|
||||
return host.innerHTML;
|
||||
},
|
||||
dispose: () => root.unmount(),
|
||||
};
|
||||
}
|
||||
@@ -224,7 +224,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
|
||||
drag={dragFor(row)}
|
||||
onOpen={() => {
|
||||
if (editing) tl.editTransport(row.res)
|
||||
else shell.openSheet('transport', { reservationId: row.res.id })
|
||||
else shell.openSheet('transport', { reservationId: row.res.id, legIndex: row.res.__leg?.index })
|
||||
}}
|
||||
/>
|
||||
{dayScheduleFor('reservation', row.res.id)}
|
||||
|
||||
@@ -265,7 +265,7 @@ export function TransportRow({ res, dayId, chrome, reorder, drag, onOpen }: {
|
||||
const time = start
|
||||
? `${fmtTime(start, chrome)}${phase === 'single' && end ? ` – ${fmtTime(end, chrome)}` : ''}`
|
||||
: ''
|
||||
const sub = transportSubtitle(res)
|
||||
const sub = transportSubtitle(res, chrome.t)
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { getDayBookendHotels, isDayInAccommodationRange } from '../../../../utils/dayOrder'
|
||||
import type { MergedItem } from '../../../../utils/dayMerge'
|
||||
import type { TransitLegDisplay } from '../../../../components/Planner/transitDisplay'
|
||||
import { transportSubtitle as dayPlanTransportSubtitle, type TransportLegDetail } from '../../../../components/Planner/transportDetailModel'
|
||||
import { projectDayItinerary } from '../../../../components/Map/dayTourProjection'
|
||||
import type { Accommodation, Assignment, Day, DayNote, Place, Reservation, RouteSegment, TranslationFn } from '../../../../types'
|
||||
|
||||
@@ -16,7 +17,7 @@ import type { Accommodation, Assignment, Day, DayNote, Place, Reservation, Route
|
||||
|
||||
/** A day-timeline transport row can be a synthetic per-leg expansion of a multi-leg booking. */
|
||||
export type TransportEntry = Reservation & {
|
||||
__leg?: { index: number; total: number; from: string | null; to: string | null }
|
||||
__leg?: TransportLegDetail & { total: number }
|
||||
}
|
||||
|
||||
export interface TransitMeta {
|
||||
@@ -48,26 +49,8 @@ export function getTransitMeta(res: Reservation): TransitMeta | null {
|
||||
}
|
||||
|
||||
/** Same subtitle language as the desktop day plan: airline/number, train/platform/seat, leg detail. */
|
||||
export function transportSubtitle(res: TransportEntry): string {
|
||||
const meta = parseReservationMeta(res) as Record<string, string | undefined>
|
||||
if (res.__leg) {
|
||||
const parts = [res.__leg.from, res.__leg.to].filter(Boolean).length
|
||||
? [[res.__leg.from, res.__leg.to].filter(Boolean).join(' → ')]
|
||||
: []
|
||||
return parts.join(' · ')
|
||||
}
|
||||
if (res.type === 'flight') {
|
||||
const parts = [meta.airline, meta.flight_number].filter(Boolean) as string[]
|
||||
if (meta.departure_airport || meta.arrival_airport) {
|
||||
parts.push([meta.departure_airport, meta.arrival_airport].filter(Boolean).join(' → '))
|
||||
}
|
||||
return parts.join(' · ')
|
||||
}
|
||||
if (res.type === 'train') {
|
||||
return [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Sitz ${meta.seat}` : '']
|
||||
.filter(Boolean).join(' · ')
|
||||
}
|
||||
return res.location || ''
|
||||
export function transportSubtitle(res: TransportEntry, t: TranslationFn): string {
|
||||
return dayPlanTransportSubtitle(res, t) ?? (res.location || '')
|
||||
}
|
||||
|
||||
const sameCoord = (a: [number, number], b: [number, number]): boolean =>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTranslation } from '../../../../i18n'
|
||||
import { useSettingsStore } from '../../../../store/settingsStore'
|
||||
import { RES_ICONS } from '../../../../components/Planner/DayPlanSidebar.constants'
|
||||
import { splitReservationDateTime } from '../../../../utils/formatters'
|
||||
import { getFlightLegs, getTrainLegs, usesStationRoute } from '../../../../utils/flightLegs'
|
||||
import { getFlightLegs, getTrainLegs, usesStationRoute, type FlightLeg, type TrainLeg } from '../../../../utils/flightLegs'
|
||||
import { openFile } from '../../../../utils/fileDownload'
|
||||
import { runsOnDay } from '../../../../utils/reservationRoutes'
|
||||
import { filesFor } from '../../../../utils/reservationFiles'
|
||||
@@ -19,6 +19,8 @@ import { Eyebrow, INNER_CLS, StatBox, TileHeader, displayTime } from './MTripShe
|
||||
|
||||
interface TransportSheetPayload {
|
||||
reservationId?: number
|
||||
/** The leg of a multi-leg train or flight the day plan row stood for (#2701). */
|
||||
legIndex?: number
|
||||
}
|
||||
|
||||
interface TransitLeg {
|
||||
@@ -118,24 +120,38 @@ export default function MTransportSheet({ planner, shell }: MTripSheetsProps) {
|
||||
const to = (res.endpoints || []).find(e => e.role === 'to')
|
||||
const { date, time: startTime } = splitReservationDateTime(res.reservation_time)
|
||||
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
|
||||
const depTime = from?.local_time || startTime
|
||||
const arrTime = to?.local_time || endTime
|
||||
|
||||
const subParts: string[] = []
|
||||
if (meta.airline) subParts.push(meta.airline)
|
||||
if (meta.flight_number) subParts.push(meta.flight_number)
|
||||
if (meta.train_number) subParts.push(meta.train_number)
|
||||
if (from?.name && to?.name) subParts.push(`${from.name} → ${to.name}`)
|
||||
else if (date) subParts.push(bookingDayLabel(date, locale))
|
||||
|
||||
const seat = meta.seat
|
||||
const platform = meta.platform
|
||||
const transitLegs: TransitLeg[] = Array.isArray(meta.transit?.legs) ? meta.transit.legs : []
|
||||
const bookingDepTime = from?.local_time || startTime
|
||||
const bookingArrTime = to?.local_time || endTime
|
||||
|
||||
// Per-segment booking codes (#1943), only on a real stopover booking: the
|
||||
// single-leg fallback would just echo the booking's own code shown below.
|
||||
const routeLegs = res.type === 'flight' ? getFlightLegs(res) : usesStationRoute(res.type) ? getTrainLegs(res) : []
|
||||
const isFlight = res.type === 'flight'
|
||||
const routeLegs: (FlightLeg | TrainLeg)[] = isFlight ? getFlightLegs(res) : usesStationRoute(res.type) ? getTrainLegs(res) : []
|
||||
const legCodes = routeLegs.length > 1 ? routeLegs.filter(l => l.confirmation_number) : []
|
||||
// Opened from one leg's row, the sheet shows that leg's own number, seat,
|
||||
// platform and times rather than the first leg's (#2701).
|
||||
const leg = routeLegs.length > 1 && payload.legIndex != null ? routeLegs[payload.legIndex] ?? null : null
|
||||
const legDetail = (leg ?? {}) as Partial<FlightLeg & TrainLeg>
|
||||
const depTime = leg ? leg.dep_time || '' : bookingDepTime
|
||||
const arrTime = leg ? leg.arr_time || '' : bookingArrTime
|
||||
const depLabel = (leg ? leg.from : from?.name) || t('reservations.time')
|
||||
const arrLabel = (leg ? leg.to : to?.name) || t('reservations.time')
|
||||
|
||||
const subParts: string[] = []
|
||||
const airline = leg ? legDetail.airline : meta.airline
|
||||
const flightNumber = leg ? legDetail.flight_number : meta.flight_number
|
||||
const trainNumber = leg ? legDetail.train_number : meta.train_number
|
||||
if (airline) subParts.push(airline)
|
||||
if (flightNumber) subParts.push(flightNumber)
|
||||
if (trainNumber) subParts.push(trainNumber)
|
||||
if (leg && (leg.from || leg.to)) subParts.push([leg.from, leg.to].filter(Boolean).join(' → '))
|
||||
else if (from?.name && to?.name) subParts.push(`${from.name} → ${to.name}`)
|
||||
else if (date) subParts.push(bookingDayLabel(date, locale))
|
||||
|
||||
// A flight's legs carry no seat or platform of their own, so those stay the booking's.
|
||||
const seat = leg && !isFlight ? legDetail.seat : meta.seat
|
||||
const platform = leg && !isFlight ? legDetail.platform : meta.platform
|
||||
const transitLegs: TransitLeg[] = Array.isArray(meta.transit?.legs) ? meta.transit.legs : []
|
||||
|
||||
const resFiles = filesFor(res, planner.files || [])
|
||||
|
||||
@@ -186,10 +202,10 @@ export default function MTransportSheet({ planner, shell }: MTripSheetsProps) {
|
||||
{(depTime || arrTime || seat || platform) && (
|
||||
<div className="mt-[14px] flex gap-[6px]">
|
||||
{depTime && (
|
||||
<StatBox value={displayTime(depTime, locale, timeFormat)} label={from?.name || t('reservations.time')} />
|
||||
<StatBox value={displayTime(depTime, locale, timeFormat)} label={depLabel} />
|
||||
)}
|
||||
{arrTime && (
|
||||
<StatBox value={displayTime(arrTime, locale, timeFormat)} label={to?.name || t('reservations.time')} />
|
||||
<StatBox value={displayTime(arrTime, locale, timeFormat)} label={arrLabel} />
|
||||
)}
|
||||
{platform && <StatBox value={platform} label={t('reservations.meta.platform')} />}
|
||||
{seat && <StatBox value={seat} label={t('reservations.meta.seat')} />}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import type { Journey } from '../store/journeyStore'
|
||||
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
|
||||
import { useJourney } from './journey/useJourney'
|
||||
import { journeyYearLabel } from './journey/journeyModel'
|
||||
import HelpAnchor from '../components/Help/HelpAnchor'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../components/shared/DialogShell'
|
||||
import { EditorField, INPUT } from '../components/shared/dialogParts'
|
||||
@@ -438,7 +439,7 @@ function JourneyCard({ journey, onClick }: { journey: Journey & { entry_count?:
|
||||
<span className="absolute top-3.5 start-3.5 z-[2] inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-white text-[11px] font-semibold"
|
||||
style={{ background: 'rgba(255,255,255,0.18)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid rgba(255,255,255,0.26)' }}>
|
||||
<Calendar size={12} strokeWidth={2.2} />
|
||||
{new Date(j.created_at).getFullYear()}
|
||||
{journeyYearLabel(j)}
|
||||
</span>
|
||||
{lifecycle !== 'live' && (
|
||||
<span className="absolute top-3.5 end-3.5 z-[2] px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wide text-white"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { journeyYearLabel } from './journeyModel';
|
||||
|
||||
describe('journeyYearLabel (#2685)', () => {
|
||||
it('FE-PAGE-JOURNEY-YEAR-001: names the year of the trips, not the year the journey was written', () => {
|
||||
expect(
|
||||
journeyYearLabel({ trip_date_min: '2024-09-12', trip_date_max: '2024-09-20', created_at: '2026-03-01 10:00:00' })
|
||||
).toBe('2024');
|
||||
});
|
||||
|
||||
it('FE-PAGE-JOURNEY-YEAR-002: a trip over New Year names both years', () => {
|
||||
expect(
|
||||
journeyYearLabel({ trip_date_min: '2024-12-28', trip_date_max: '2025-01-04', created_at: '2026-03-01 10:00:00' })
|
||||
).toBe('2024/2025');
|
||||
});
|
||||
|
||||
it('FE-PAGE-JOURNEY-YEAR-003: one known date is enough, and without any it falls back to when it was written', () => {
|
||||
expect(
|
||||
journeyYearLabel({ trip_date_min: null, trip_date_max: '2023-05-02', created_at: '2026-03-01 10:00:00' })
|
||||
).toBe('2023');
|
||||
expect(journeyYearLabel({ trip_date_min: null, trip_date_max: null, created_at: '2026-03-01 10:00:00' })).toBe(
|
||||
'2026'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
/** The year a journey card names: the years its trips cover, not the year it was written (#2685). */
|
||||
export function journeyYearLabel(journey: {
|
||||
trip_date_min?: string | null;
|
||||
trip_date_max?: string | null;
|
||||
created_at?: string | number | null;
|
||||
}): string {
|
||||
// The dates are calendar dates, so the year is read from the text, not through a Date in some zone.
|
||||
const from = journey.trip_date_min?.slice(0, 4);
|
||||
const to = journey.trip_date_max?.slice(0, 4);
|
||||
if (from && to && to !== from) return `${from}/${to}`;
|
||||
if (from || to) return (from || to) as string;
|
||||
return journey.created_at ? String(new Date(journey.created_at).getFullYear()) : '';
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import { SharedPlaceDetails } from './SharedPlaceDetails';
|
||||
|
||||
/** FE-PAGE-SHARED-PLACE-001: a stop's description and notes on a shared trip, read as the planner shows them (#2692). */
|
||||
|
||||
describe('SharedPlaceDetails', () => {
|
||||
it('FE-PAGE-SHARED-PLACE-001: renders markdown in the description and notes, and keeps raw HTML as text', () => {
|
||||
render(
|
||||
<SharedPlaceDetails
|
||||
place={{
|
||||
name: 'Market',
|
||||
address: 'Market Square 1',
|
||||
description: [
|
||||
'## Opening',
|
||||
'',
|
||||
'| Day | Hours |',
|
||||
'| --- | --- |',
|
||||
'| Sat | 8 to 14 |',
|
||||
'',
|
||||
'<img src=x onerror=alert(1)>',
|
||||
].join('\n'),
|
||||
notes: ['- fruit', '- bread'].join('\n'),
|
||||
}}
|
||||
assignmentNotes="**Bring** cash"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole('heading', { name: 'Opening' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('cell', { name: '8 to 14' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Bring').tagName).toBe('STRONG');
|
||||
expect(screen.getByText('fruit').tagName).toBe('LI');
|
||||
expect(document.querySelector('img[src="x"]')).toBeNull();
|
||||
expect(screen.queryByText('## Opening')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from '../../i18n'
|
||||
import { fs } from '../../components/shared/DialogShell'
|
||||
import { Tooltip } from '../../components/shared/Tooltip'
|
||||
import { WhiteBadge } from '../../components/Planner/planParts'
|
||||
import { SafeMarkdown } from '../../components/shared/SafeMarkdown'
|
||||
import { getGoogleMapsUrlForPlace } from '../../components/Planner/placeGoogleMaps'
|
||||
import { formatDurationMinutes, isHttpUrl, plannedStayMinutes } from './sharedTripModel'
|
||||
|
||||
@@ -61,7 +62,7 @@ export function SharedPlaceDetails({ place, assignmentNotes }: { place: SharedPl
|
||||
</div>
|
||||
)}
|
||||
{place.description && (
|
||||
<div className="whitespace-pre-wrap text-content-secondary [overflow-wrap:anywhere]" style={fs(12, 'body')}>{place.description}</div>
|
||||
<div className="collab-note-md-full text-content-secondary [overflow-wrap:anywhere]" style={fs(12, 'body')}><SafeMarkdown>{place.description}</SafeMarkdown></div>
|
||||
)}
|
||||
{dayNote && <NoteLine label={t('places.assignmentNotes')} text={dayNote} strong />}
|
||||
{placeNote && <NoteLine label={t('places.formNotes')} text={placeNote} />}
|
||||
@@ -98,7 +99,7 @@ function NoteLine({ label, text, strong = false }: { label: string; text: string
|
||||
<Tooltip label={label} placement="top">
|
||||
<div className={`flex items-start gap-1.5 rounded-lg bg-surface-secondary px-2 py-1.5 ${strong ? 'text-content-secondary' : 'text-content-muted'}`} style={fs(11.5, 'body')}>
|
||||
<FileText size={11} strokeWidth={2} className="mt-0.5 flex-none text-content-faint" aria-label={label} />
|
||||
<span className="min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere]">{text}</span>
|
||||
<div className="collab-note-md-full min-w-0 [overflow-wrap:anywhere]"><SafeMarkdown>{text}</SafeMarkdown></div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
@@ -21,6 +21,7 @@ import { aiConnectionsRepo } from '../repo/aiConnectionsRepo';
|
||||
import { assistantRepo } from '../repo/assistantRepo';
|
||||
import { OnlineOnlyError } from '../repo/onlineOnly';
|
||||
import type { Assignment } from '../types';
|
||||
import { browserTimeZone } from '../utils/localDate';
|
||||
import { randomId } from '../utils/randomId';
|
||||
import type { RouteProfileFocus } from '../utils/routeGeometry';
|
||||
import { assistantScopeOf, assistantTargetOf, type AssistantChatKey } from './assistantChatKey';
|
||||
@@ -331,7 +332,7 @@ function errorCodeOf(err: unknown): AssistantErrorCode {
|
||||
function clientContext() {
|
||||
return {
|
||||
locale: useSettingsStore.getState().settings.language || 'en',
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||
timeZone: browserTimeZone() ?? 'UTC',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { forgetServerLanguage } from './settingsStore'
|
||||
import { forgetResumeRoute } from '../utils/resumeRoute'
|
||||
import { markSignedOut, clearSignedOut } from '../utils/signedOut'
|
||||
import { forgetPushDeviceOnLogout, resyncPushSubscription } from '../push/webPush'
|
||||
import { browserTimeZone } from '../utils/localDate'
|
||||
|
||||
/** How long a logout waits for the queued changes to go out. */
|
||||
const LOGOUT_FLUSH_MS = 5000
|
||||
@@ -154,7 +155,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
hasMapsKey: false,
|
||||
hasAmapKey: false,
|
||||
placesProvider: 'auto',
|
||||
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
serverTimezone: browserTimeZone() ?? '',
|
||||
appRequireMfa: false,
|
||||
tripRemindersEnabled: false,
|
||||
placesPhotosEnabled: true,
|
||||
|
||||
@@ -54,6 +54,7 @@ interface Host {
|
||||
styleClip: BookCopiedStyle | null;
|
||||
commit: (fn: (doc: BookDocument) => BookDocument) => void;
|
||||
apply: (fn: (doc: BookDocument) => BookDocument) => void;
|
||||
refuseFull: () => void;
|
||||
}
|
||||
|
||||
type Set = (partial: Partial<Host> | ((s: Host) => Partial<Host>)) => void;
|
||||
@@ -370,7 +371,11 @@ function panelActions(
|
||||
if (!doc) return;
|
||||
const chosen = spread.elements.filter((e) => ids.includes(e.id));
|
||||
// The page's own cap: copies that would make the book unsaveable are not made.
|
||||
if (!chosen.length || spread.elements.length + chosen.length > MAX_SPREAD_ELEMENTS) return;
|
||||
if (!chosen.length) return;
|
||||
if (spread.elements.length + chosen.length > MAX_SPREAD_ELEMENTS) {
|
||||
get().refuseFull();
|
||||
return;
|
||||
}
|
||||
const copies = chosen.map(
|
||||
(e) =>
|
||||
({
|
||||
|
||||
@@ -35,6 +35,14 @@ interface StudioState extends StudioLayerActions {
|
||||
gestureBase: BookDocument | null
|
||||
/** Elements copied for pasting onto any page of the book (#2316). Kept across page switches. */
|
||||
clipboard: StudioClipboard | null
|
||||
/**
|
||||
* Counts the times something was not added because the page already holds
|
||||
* MAX_SPREAD_ELEMENTS. The Studio says so once per count (StudioNotices), so
|
||||
* a full page no longer swallows a click without a word.
|
||||
*/
|
||||
refusedFull: number
|
||||
/** Notes that an addition was refused because the page is full. */
|
||||
refuseFull: () => void
|
||||
/**
|
||||
* The page a chat card points at, outlined in the page rail (journey-studio.md
|
||||
* 4.2). Not part of the book or its history; opening a book clears it.
|
||||
@@ -147,6 +155,8 @@ export const useStudioStore = create<StudioState>((set, get) => ({
|
||||
future: [],
|
||||
gestureBase: null,
|
||||
clipboard: null,
|
||||
refusedFull: 0,
|
||||
refuseFull: () => set(st => ({ refusedFull: st.refusedFull + 1 })),
|
||||
hintSpread: null,
|
||||
readOnly: false,
|
||||
|
||||
@@ -228,7 +238,11 @@ export const useStudioStore = create<StudioState>((set, get) => ({
|
||||
addElement: (spreadIndex, el) => {
|
||||
// The page's own cap, as paste and duplicate keep it: what would make the book unsaveable is not added.
|
||||
const target = get().doc?.spreads[spreadIndex]
|
||||
if (!target || target.elements.length >= MAX_SPREAD_ELEMENTS) return false
|
||||
if (!target) return false
|
||||
if (target.elements.length >= MAX_SPREAD_ELEMENTS) {
|
||||
get().refuseFull()
|
||||
return false
|
||||
}
|
||||
get().commit(doc =>
|
||||
replaceSpread(doc, spreadIndex, sp => ({ ...sp, elements: [...sp.elements, el] })))
|
||||
/*
|
||||
@@ -268,6 +282,10 @@ export const useStudioStore = create<StudioState>((set, get) => ({
|
||||
const room = Math.max(0, MAX_SPREAD_ELEMENTS - target.elements.length)
|
||||
const source = doc.spreads.find(sp => sp.id === clipboard.fromSpreadId)
|
||||
// A copied group arrives as a group of its own, never as part of one already on the page.
|
||||
if (room === 0) {
|
||||
get().refuseFull()
|
||||
return
|
||||
}
|
||||
const copies = regroupCopies(pastedElements(clipboard, target, doc.page).slice(0, room), source, bookElementId)
|
||||
if (!copies.elements.length) return
|
||||
get().commit(d => replaceSpread(d, spreadIndex, sp => withCopies(sp, copies)))
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
inspector keep all their room. */
|
||||
|
||||
/* The toggle is the bar's newest tool, so it gives up its label first: from 1360
|
||||
to 1519 it is an icon while the others keep theirs, and the bar fits where it
|
||||
did before. The journey's name gives way too rather than push the bar past
|
||||
the window. */
|
||||
to 1519 it is an icon while the others keep theirs, and the head band fits
|
||||
where it did before. */
|
||||
@media (max-width: 1519px) {
|
||||
.trek-dash .st-assistant-toggle .st-tool-label {
|
||||
display: none;
|
||||
@@ -15,14 +14,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.trek-dash .st-bar .st-back {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.trek-dash .st-bar .st-back > svg {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.trek-dash .st-assistant-notice {
|
||||
margin: 8px 12px 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/* ----------------- head band (StudioHeader.tsx) ----------------- *
|
||||
* The shape every TREK dialog head has: a tinted band, a raised tile, a small
|
||||
* line above the title and a round close button at the end. The band keeps the
|
||||
* glass of the old bar under its tint, so the journey still shows through.
|
||||
*/
|
||||
.trek-dash .st-head {
|
||||
/* Its own stacking context, above the rails: the panels below carry z-index 1,
|
||||
and without this the format menu would open behind them. */
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding-inline: 14px 16px;
|
||||
background:
|
||||
linear-gradient(
|
||||
color-mix(in srgb, var(--accent) 7%, transparent),
|
||||
color-mix(in srgb, var(--accent) 7%, transparent)
|
||||
),
|
||||
var(--glass-bg);
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
}
|
||||
|
||||
/* The journey's cover, so it is always clear whose book this is. */
|
||||
.trek-dash .st-head-tile {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
border-radius: 13px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-sm);
|
||||
}
|
||||
.trek-dash .st-head-tile img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.trek-dash .st-head-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* The wordmark and the beta mark: what this screen is, quietly, above its name. */
|
||||
.trek-dash .st-head-eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 17px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.trek-dash .st-head-eyebrow svg {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.trek-dash .st-head-title {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--ink);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.trek-dash .st-head-sub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* The format, as a chip that opens the format menu. */
|
||||
.trek-dash .st-head-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 22px;
|
||||
padding-inline: 7px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-xs);
|
||||
color: var(--ink-2);
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
color 0.16s,
|
||||
box-shadow 0.16s;
|
||||
}
|
||||
.trek-dash .st-head-chip:hover {
|
||||
color: var(--ink);
|
||||
box-shadow: var(--sh-sm);
|
||||
}
|
||||
.trek-dash .st-head-chip:focus {
|
||||
outline: none;
|
||||
}
|
||||
.trek-dash .st-head-chip:focus-visible {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--accent-soft),
|
||||
0 0 0 3px var(--accent);
|
||||
}
|
||||
.trek-dash .st-head-chip-shape {
|
||||
flex: none;
|
||||
border: 1.5px solid currentColor;
|
||||
border-radius: 2px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
/* The chip sits on the left, so its menu opens towards the page, not off the window. */
|
||||
.trek-dash .st-head-menu {
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: auto;
|
||||
}
|
||||
|
||||
.trek-dash .st-head-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
/* Tools that belong together share one raised group, like a segmented control. */
|
||||
.trek-dash .st-head-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-xs);
|
||||
}
|
||||
|
||||
.trek-dash .st-head-close {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex: none;
|
||||
margin-inline-start: 2px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-sm);
|
||||
color: var(--ink-3);
|
||||
transition: color 0.16s;
|
||||
}
|
||||
.trek-dash .st-head-close:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
.trek-dash .st-head-close:focus {
|
||||
outline: none;
|
||||
}
|
||||
.trek-dash .st-head-close:focus-visible {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--accent-soft),
|
||||
0 0 0 3px var(--accent);
|
||||
}
|
||||
|
||||
/* The book view moved to the zoom, where the page's view lives; on reads as pressed. */
|
||||
.trek-dash .st-zoom button.is-on {
|
||||
background: color-mix(in oklch, var(--accent) 14%, transparent);
|
||||
color: var(--ink);
|
||||
}
|
||||
@@ -25,7 +25,7 @@
|
||||
z-index: 9999;
|
||||
inset: 16px;
|
||||
display: grid;
|
||||
grid-template-rows: 56px minmax(0, 1fr);
|
||||
grid-template-rows: 72px minmax(0, 1fr);
|
||||
border-radius: var(--r-xl);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--glass-border);
|
||||
@@ -88,66 +88,7 @@
|
||||
.st-exit { animation-name: st-fade-out; }
|
||||
}
|
||||
|
||||
/* ----------------- top bar ----------------- */
|
||||
.trek-dash .st-bar {
|
||||
/* Its own stacking context, above the rails: the panels below carry z-index 1,
|
||||
and without this the format menu would open behind them. */
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding-block: 0;
|
||||
padding-inline: 10px 18px;
|
||||
min-width: 0;
|
||||
background: var(--glass-bg);
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
}
|
||||
|
||||
.trek-dash .st-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 36px;
|
||||
padding: 0 12px 0 8px;
|
||||
border-radius: 999px;
|
||||
color: var(--ink-2);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
max-width: 240px;
|
||||
transition: background .18s, color .18s;
|
||||
}
|
||||
.trek-dash .st-back:hover { background: var(--accent-soft); color: var(--ink); }
|
||||
.trek-dash .st-back .st-back-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The journey's own cover, so you never lose track of whose book this is. */
|
||||
.trek-dash .st-cover {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 7px;
|
||||
object-fit: cover;
|
||||
flex: none;
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.trek-dash .st-bar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
flex: none;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
/* The undo/redo pair belongs together; everything else in the group breathes. */
|
||||
.trek-dash .st-bar-group .st-tool.is-icon + .st-tool.is-icon { margin-inline-start: -5px; }
|
||||
|
||||
/* ----------------- tools (head band and elsewhere) ----------------- */
|
||||
.trek-dash .st-tool {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1506,20 +1447,6 @@
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* ----------------- brand ----------------- *
|
||||
* The lockup takes the bar's ink through `currentColor`, so it is dark on a
|
||||
* light theme and light on a dark one from one copy of the artwork.
|
||||
*/
|
||||
.trek-dash .st-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
flex: 0 0 auto;
|
||||
color: var(--ink);
|
||||
min-width: 0;
|
||||
}
|
||||
.trek-dash .st-brand svg { flex: none; }
|
||||
|
||||
/* Small, quiet and set apart — a beta mark states a fact about the feature; it
|
||||
is not a call to action and should not read as one. */
|
||||
.trek-dash .st-beta {
|
||||
|
||||
@@ -44,10 +44,26 @@ const VECTOR_CACHE = 'gl-map-offline'
|
||||
*/
|
||||
const GLYPH_RANGES = ['0-255', '256-511', '512-767', '768-1023', '1024-1279', '1280-1535', '1536-1791', '8192-8447', '8448-8703']
|
||||
|
||||
interface StyleSource {
|
||||
type?: string
|
||||
url?: string
|
||||
tiles?: string[]
|
||||
minzoom?: number
|
||||
maxzoom?: number
|
||||
}
|
||||
|
||||
/** One tiled source of the style, with the zooms it actually serves. */
|
||||
interface TileSource {
|
||||
template: string
|
||||
minZoom: number
|
||||
maxZoom: number
|
||||
vector: boolean
|
||||
}
|
||||
|
||||
interface StyleDoc {
|
||||
sprite?: string
|
||||
glyphs?: string
|
||||
sources?: Record<string, { url?: string; tiles?: string[] }>
|
||||
sources?: Record<string, StyleSource>
|
||||
layers?: { layout?: { 'text-font'?: string[] } }[]
|
||||
}
|
||||
|
||||
@@ -100,28 +116,73 @@ async function openVectorCache(): Promise<Cache | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Tile coordinates covering the box, low zoom first so a truncated run is still useful. */
|
||||
function enumerateVectorTiles(bbox: TileBbox, minZoom: number, maxZoom: number): [number, number, number][] {
|
||||
const out: [number, number, number][] = []
|
||||
for (let z = minZoom; z <= maxZoom; z++) {
|
||||
// Same screen-widened rectangle as the raster side: the opening fitBounds
|
||||
// view shows more than the places extent on the non-limiting axis (#2180).
|
||||
const { minX, maxX, minY, maxY } = tileRange(bbox, z)
|
||||
for (let x = minX; x <= maxX; x++) {
|
||||
for (let y = minY; y <= maxY; y++) {
|
||||
out.push([z, x, y])
|
||||
if (out.length >= MAX_VECTOR_TILES) return out
|
||||
}
|
||||
/**
|
||||
* Tile URLs covering the box, vector sources first and low zoom first within
|
||||
* each, so a truncated run is still useful. A source is only asked for the
|
||||
* zooms it serves: OpenFreeMap's shaded relief stops at z6 and answers 404
|
||||
* above it.
|
||||
*/
|
||||
function enumerateTileUrls(bbox: TileBbox, sources: TileSource[]): string[] {
|
||||
const out: string[] = []
|
||||
for (const source of sources) {
|
||||
for (let z = Math.max(source.minZoom, VECTOR_MIN_ZOOM); z <= Math.min(source.maxZoom, VECTOR_MAX_ZOOM); z++) {
|
||||
if (!pushZoomTiles(out, source.template, bbox, z)) return out
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** One zoom's tiles of a source onto `out`; false once the run is full. */
|
||||
function pushZoomTiles(out: string[], template: string, bbox: TileBbox, z: number): boolean {
|
||||
// Same screen-widened rectangle as the raster side: the opening fitBounds
|
||||
// view shows more than the places extent on the non-limiting axis (#2180).
|
||||
const { minX, maxX, minY, maxY } = tileRange(bbox, z)
|
||||
for (let x = minX; x <= maxX; x++) {
|
||||
for (let y = minY; y <= maxY; y++) {
|
||||
out.push(template.replace('{z}', String(z)).replace('{x}', String(x)).replace('{y}', String(y)))
|
||||
if (out.length >= MAX_VECTOR_TILES) return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The style's tiled sources, vector first. A source given as a TileJSON
|
||||
* reference gets its template and zooms from there: the tile path carries a
|
||||
* planet version, and asking without that segment answers 200 with an empty
|
||||
* body, so it cannot be guessed. A source without tiles (GeoJSON) is skipped.
|
||||
*/
|
||||
async function readTileSources(style: StyleDoc, cache: Cache | null): Promise<TileSource[]> {
|
||||
const out: TileSource[] = []
|
||||
for (const source of Object.values(style.sources ?? {})) {
|
||||
let { tiles, minzoom, maxzoom } = source
|
||||
if (!tiles?.[0] && source.url) {
|
||||
await warm(cache, source.url)
|
||||
const tileJson = await fetchJson<{ tiles?: string[]; minzoom?: number; maxzoom?: number }>(source.url)
|
||||
tiles = tileJson?.tiles
|
||||
minzoom ??= tileJson?.minzoom
|
||||
maxzoom ??= tileJson?.maxzoom
|
||||
}
|
||||
if (!tiles?.[0]) continue
|
||||
out.push({
|
||||
template: tiles[0],
|
||||
minZoom: minzoom ?? VECTOR_MIN_ZOOM,
|
||||
maxZoom: maxzoom ?? VECTOR_MAX_ZOOM,
|
||||
vector: source.type !== 'raster' && source.type !== 'raster-dem',
|
||||
})
|
||||
}
|
||||
return out.sort((a, b) => Number(b.vector) - Number(a.vector))
|
||||
}
|
||||
|
||||
/**
|
||||
* The device-wide half: the style, its TileJSON, the sprite and the glyphs.
|
||||
* Returns the tile URL template, or null when the style could not be read.
|
||||
* Returns the vector tile URL template, or null when the style could not be read.
|
||||
*/
|
||||
export async function prefetchStyleAssets(styleUrl: string): Promise<string | null> {
|
||||
return (await prefetchStyle(styleUrl))?.[0]?.template ?? null
|
||||
}
|
||||
|
||||
async function prefetchStyle(styleUrl: string): Promise<TileSource[] | null> {
|
||||
const cache = await openVectorCache()
|
||||
const style = await fetchJson<StyleDoc>(styleUrl)
|
||||
if (!style) return null
|
||||
@@ -148,19 +209,7 @@ export async function prefetchStyleAssets(styleUrl: string): Promise<string | nu
|
||||
)
|
||||
}
|
||||
|
||||
// The vector source is a TileJSON reference, and the tile path it hands back
|
||||
// carries a planet version. Asking without that segment answers 200 with an
|
||||
// empty body, so the template has to come from the TileJSON rather than be
|
||||
// guessed.
|
||||
for (const source of Object.values(style.sources ?? {})) {
|
||||
if (source.tiles?.[0]) return source.tiles[0]
|
||||
if (source.url) {
|
||||
await warm(cache, source.url)
|
||||
const tileJson = await fetchJson<{ tiles?: string[] }>(source.url)
|
||||
if (tileJson?.tiles?.[0]) return tileJson.tiles[0]
|
||||
}
|
||||
}
|
||||
return null
|
||||
return readTileSources(style, cache)
|
||||
}
|
||||
|
||||
export interface VectorPrefetchResult {
|
||||
@@ -184,25 +233,21 @@ export async function prefetchVectorForPlaces(
|
||||
const bbox = computeBbox(places)
|
||||
if (!bbox) return { tiles: 0, template: null }
|
||||
|
||||
const template = await prefetchStyleAssets(styleUrl)
|
||||
if (!template || isCancelled()) return { tiles: 0, template }
|
||||
const sources = await prefetchStyle(styleUrl)
|
||||
const template = sources?.[0]?.template ?? null
|
||||
if (!sources?.length || isCancelled()) return { tiles: 0, template }
|
||||
|
||||
const cache = await openVectorCache()
|
||||
const coords = enumerateVectorTiles(bbox, VECTOR_MIN_ZOOM, VECTOR_MAX_ZOOM)
|
||||
const urls = enumerateTileUrls(bbox, sources)
|
||||
|
||||
let cursor = 0
|
||||
let fetched = 0
|
||||
|
||||
async function worker(): Promise<void> {
|
||||
while (cursor < coords.length) {
|
||||
while (cursor < urls.length) {
|
||||
if (isCancelled() || !navigator.onLine) return
|
||||
const next = coords[cursor++]
|
||||
if (!next) return
|
||||
const [z, x, y] = next
|
||||
const url = template!
|
||||
.replace('{z}', String(z))
|
||||
.replace('{x}', String(x))
|
||||
.replace('{y}', String(y))
|
||||
const url = urls[cursor++]
|
||||
if (!url) return
|
||||
if (await warm(cache, url)) fetched++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +149,11 @@ async function syncTrip(tripId: number): Promise<void> {
|
||||
// first sync is in. Ahead of the writes and rethrowing anything but a 404, it
|
||||
// threw the whole downloaded bundle away for that trip while the run still
|
||||
// reported it stored, so Settings said "N trips ready" over an empty database.
|
||||
await roadtripPreferencesRepo.read(tripId).catch(() => { /* optional addon, optional cache */ })
|
||||
// Asked only while the addon is on, like the tours below: with it off, every
|
||||
// trip of a dashboard load spent a request on a 404.
|
||||
if (useAddonStore.getState().isEnabled('roadtrip')) {
|
||||
await roadtripPreferencesRepo.read(tripId).catch(() => { /* optional cache */ })
|
||||
}
|
||||
|
||||
// Tours, the same way: optional, and never fatal. Without them every Tour
|
||||
// reads as a plain place offline. Asked only while the addon is on, so a
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { isLocalToday, localIsoDate, localMinutes } from './localDate'
|
||||
import { browserTimeZone, isLocalToday, isValidTimeZone, localIsoDate, localMinutes } from './localDate'
|
||||
|
||||
/**
|
||||
* "Today" must be the user's local calendar date. `toISOString()` is the UTC
|
||||
@@ -66,3 +66,26 @@ describe('isLocalToday', () => {
|
||||
expect(isLocalToday(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('browserTimeZone and isValidTimeZone (#2694)', () => {
|
||||
it('names the browser zone, and nothing when the browser names none', () => {
|
||||
expect(browserTimeZone()).toBe(Intl.DateTimeFormat().resolvedOptions().timeZone)
|
||||
const original = Intl.DateTimeFormat.prototype.resolvedOptions
|
||||
const spy = vi.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockImplementation(function (this: Intl.DateTimeFormat) {
|
||||
return { ...original.call(this), timeZone: undefined as unknown as string }
|
||||
})
|
||||
try {
|
||||
expect(browserTimeZone()).toBeUndefined()
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts the zones Intl knows and refuses the rest', () => {
|
||||
expect(isValidTimeZone('Europe/Berlin')).toBe(true)
|
||||
expect(isValidTimeZone('UTC')).toBe(true)
|
||||
expect(isValidTimeZone('Mars/Olympus_Mons')).toBe(false)
|
||||
expect(isValidTimeZone('')).toBe(false)
|
||||
expect(isValidTimeZone(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,3 +27,24 @@ export function localMinutes(d: Date = new Date()): number {
|
||||
export function isLocalToday(date: string | null | undefined): boolean {
|
||||
return !!date && date.slice(0, 10) === localIsoDate()
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser's IANA time zone, or undefined when it cannot name one. Chrome
|
||||
* on Windows answers undefined for a system zone it cannot map, such as
|
||||
* Alberta's permanent UTC-6 (#2694), so every reader picks its own fallback.
|
||||
*/
|
||||
export function browserTimeZone(): string | undefined {
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone as string | undefined
|
||||
return typeof tz === 'string' && tz ? tz : undefined
|
||||
}
|
||||
|
||||
/** Whether `Intl` accepts the zone; formatting with one it rejects throws a RangeError. */
|
||||
export function isValidTimeZone(tz: unknown): tz is string {
|
||||
if (typeof tz !== 'string' || !tz) return false
|
||||
try {
|
||||
new Intl.DateTimeFormat(undefined, { timeZone: tz })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ describe('TransportRow', () => {
|
||||
|
||||
expect(screen.getByText('LH 714 to Tokyo')).toBeInTheDocument()
|
||||
expect(container.textContent).toContain('08:15 – 18:40')
|
||||
expect(screen.getByText('Lufthansa · LH714 · MUC → HND')).toBeInTheDocument()
|
||||
expect(screen.getByText('Lufthansa, LH714, MUC → HND')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-014: a multi-day span shows only the day-relevant time', () => {
|
||||
@@ -234,7 +234,7 @@ describe('TransportRow', () => {
|
||||
} as unknown as TransportEntry
|
||||
render(<TransportRow {...base} res={train} />)
|
||||
|
||||
expect(screen.getByText('ICE 599 · Gl. 7 · Sitz 21A')).toBeInTheDocument()
|
||||
expect(screen.getByText('ICE 599, reservations.meta.platform 7, reservations.meta.seat 21A')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-018: an untimed booking of an unknown type still renders its title', () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { fireEvent, render, screen } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-TRSH-001 to FE-MOB-TRSH-033
|
||||
// FE-MOB-TRSH-001 to FE-MOB-TRSH-035
|
||||
|
||||
vi.mock('../../../../src/utils/fileDownload', () => ({ openFile: vi.fn() }))
|
||||
|
||||
@@ -442,4 +442,34 @@ describe('MTransportSheet', () => {
|
||||
expect(planner.handleSelectDay).toHaveBeenCalledWith(3, false)
|
||||
})
|
||||
})
|
||||
|
||||
// #2701: a leg row opens the sheet on that leg, not on the booking's first one.
|
||||
describe('opened from one leg of a multi-leg train', () => {
|
||||
const TRAIN = {
|
||||
...FLIGHT, id: 8, type: 'train', title: 'Takayama to Kyoto', confirmation_number: null,
|
||||
metadata: JSON.stringify({
|
||||
train_number: 'Hida 6', seat: '10-4C',
|
||||
legs: [
|
||||
{ from: 'Takayama', to: 'Nagoya', train_number: 'Hida 6', seat: '10-4C', platform: '1', dep_time: '09:40', arr_time: '12:10' },
|
||||
{ from: 'Nagoya', to: 'Kyoto', train_number: 'Nozomi 67', seat: '4-5A', platform: '14', dep_time: '12:35', arr_time: '13:10' },
|
||||
],
|
||||
}),
|
||||
} as unknown as Reservation
|
||||
|
||||
it("FE-MOB-TRSH-034: shows the second leg's own train, seat, platform and times", () => {
|
||||
render(<MTransportSheet planner={makePlanner({ reservations: [TRAIN] })} shell={makeShell({ sheet: { id: 'transport', payload: { reservationId: 8, legIndex: 1 } } })} />)
|
||||
expect(screen.getByText(/Nozomi 67/)).toBeInTheDocument()
|
||||
expect(screen.getByText('4-5A')).toBeInTheDocument()
|
||||
expect(screen.getByText('14')).toBeInTheDocument()
|
||||
expect(screen.getByText('12:35')).toBeInTheDocument()
|
||||
expect(screen.getByText('13:10')).toBeInTheDocument()
|
||||
expect(screen.queryByText('10-4C')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRSH-035: without a leg the sheet still shows the booking as a whole', () => {
|
||||
render(<MTransportSheet planner={makePlanner({ reservations: [TRAIN] })} shell={makeShell({ sheet: { id: 'transport', payload: { reservationId: 8 } } })} />)
|
||||
expect(screen.getByText('10-4C')).toBeInTheDocument()
|
||||
expect(screen.queryByText('4-5A')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -128,14 +128,17 @@ describe('planTimelineModel — metadata parsing', () => {
|
||||
})
|
||||
|
||||
describe('planTimelineModel — transportSubtitle', () => {
|
||||
// The labels stay keys, so the assertions read what the subtitle puts where.
|
||||
const tr: TranslationFn = (key) => key
|
||||
|
||||
it('FE-MOB-PTLM-006: renders a synthetic leg as from → to', () => {
|
||||
const res = { ...buildReservation({ type: 'flight' }), __leg: { index: 0, total: 2, from: 'FRA', to: 'IST' } } as TransportEntry
|
||||
expect(transportSubtitle(res)).toBe('FRA → IST')
|
||||
expect(transportSubtitle(res, tr)).toBe('FRA → IST')
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-007: renders a leg without endpoints as an empty subtitle', () => {
|
||||
const res = { ...buildReservation({ type: 'flight' }), __leg: { index: 1, total: 2, from: null, to: null } } as TransportEntry
|
||||
expect(transportSubtitle(res)).toBe('')
|
||||
expect(transportSubtitle(res, tr)).toBe('')
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-008: joins airline, flight number and airports for a flight', () => {
|
||||
@@ -143,25 +146,42 @@ describe('planTimelineModel — transportSubtitle', () => {
|
||||
type: 'flight',
|
||||
metadata: JSON.stringify({ airline: 'LH', flight_number: 'LH123', departure_airport: 'FRA', arrival_airport: 'HND' }),
|
||||
})
|
||||
expect(transportSubtitle(res)).toBe('LH · LH123 · FRA → HND')
|
||||
expect(transportSubtitle(res, tr)).toBe('LH, LH123, FRA → HND')
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-009: omits the airport pair when neither airport is known', () => {
|
||||
const res = buildReservation({ type: 'flight', metadata: JSON.stringify({ airline: 'LH', flight_number: 'LH123' }) })
|
||||
expect(transportSubtitle(res)).toBe('LH · LH123')
|
||||
expect(transportSubtitle(res, tr)).toBe('LH, LH123')
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-010: labels platform and seat for a train', () => {
|
||||
const full = buildReservation({
|
||||
type: 'train', metadata: JSON.stringify({ train_number: 'ICE 599', platform: '7', seat: '32A' }),
|
||||
})
|
||||
expect(transportSubtitle(full)).toBe('ICE 599 · Gl. 7 · Sitz 32A')
|
||||
expect(transportSubtitle(buildReservation({ type: 'train', metadata: '{"train_number":"ICE 599"}' }))).toBe('ICE 599')
|
||||
expect(transportSubtitle(full, tr)).toBe('ICE 599, reservations.meta.platform 7, reservations.meta.seat 32A')
|
||||
expect(transportSubtitle(buildReservation({ type: 'train', metadata: '{"train_number":"ICE 599"}' }), tr)).toBe('ICE 599')
|
||||
})
|
||||
|
||||
// #2701: a leg names its own train, platform and seat, not the first leg's.
|
||||
it('FE-MOB-PTLM-011b: a train leg carries its own number, platform and seat', () => {
|
||||
const res = {
|
||||
...buildReservation({ type: 'train', metadata: JSON.stringify({ train_number: 'Hida 6', seat: '10-4C' }) }),
|
||||
__leg: { index: 1, total: 2, from: 'Nagoya', to: 'Kyoto', train_number: 'Nozomi 67', platform: '14', seat: '4-5A' },
|
||||
} as TransportEntry
|
||||
expect(transportSubtitle(res, tr)).toBe('Nozomi 67, reservations.meta.platform 14, reservations.meta.seat 4-5A, Nagoya → Kyoto')
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-011c: a flight leg carries its own airline and flight number', () => {
|
||||
const res = {
|
||||
...buildReservation({ type: 'flight' }),
|
||||
__leg: { index: 1, total: 2, from: 'IST', to: 'HND', airline: 'TK', flight_number: 'TK52' },
|
||||
} as TransportEntry
|
||||
expect(transportSubtitle(res, tr)).toBe('TK, TK52, IST → HND')
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-011: falls back to the location for any other type', () => {
|
||||
expect(transportSubtitle(buildReservation({ type: 'bus', location: 'Central Station' }))).toBe('Central Station')
|
||||
expect(transportSubtitle(buildReservation({ type: 'bus', location: null }))).toBe('')
|
||||
expect(transportSubtitle(buildReservation({ type: 'bus', location: 'Central Station' }), tr)).toBe('Central Station')
|
||||
expect(transportSubtitle(buildReservation({ type: 'bus', location: null }), tr)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { MAX_SPREAD_ELEMENTS, type BookDocument, type BookElement, type BookSpread } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const toast = vi.hoisted(() => ({ warning: vi.fn() }));
|
||||
vi.mock('../../../src/components/shared/Toast', () => ({ useToast: () => toast }));
|
||||
vi.mock('../../../src/i18n', () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
|
||||
|
||||
import { StudioNotices } from '../../../src/components/Studio/StudioNotices';
|
||||
import { useStudioStore } from '../../../src/store/studioStore';
|
||||
|
||||
/**
|
||||
* FE-STUDIO-FULL-001 to FE-STUDIO-FULL-004: a page that already holds
|
||||
* MAX_SPREAD_ELEMENTS takes nothing more, and the Studio says so instead of
|
||||
* swallowing the click.
|
||||
*/
|
||||
|
||||
const el = (id: string): BookElement =>
|
||||
({
|
||||
id,
|
||||
kind: 'shape',
|
||||
frame: { x: 0, y: 0, w: 10, h: 10 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
shape: 'rect',
|
||||
fill: '#000000',
|
||||
gradient: 'none',
|
||||
stroke: null,
|
||||
strokeWidth: 0,
|
||||
strokeStyle: 'solid',
|
||||
radius: 0,
|
||||
}) as BookElement;
|
||||
|
||||
const spread = (id: string, elements: BookElement[]): BookSpread => ({
|
||||
id,
|
||||
role: 'inner',
|
||||
background: null,
|
||||
elements,
|
||||
parked: [],
|
||||
entryId: null,
|
||||
});
|
||||
|
||||
const book = (...spreads: BookSpread[]): BookDocument =>
|
||||
({
|
||||
version: 1,
|
||||
title: 'T',
|
||||
page: { preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 },
|
||||
spreads,
|
||||
}) as BookDocument;
|
||||
|
||||
const full = () => Array.from({ length: MAX_SPREAD_ELEMENTS }, (_, i) => el(`f${i}`));
|
||||
const store = () => useStudioStore.getState();
|
||||
|
||||
beforeEach(() => {
|
||||
store().load(book(spread('full', full()), spread('room', [el('a')])));
|
||||
useStudioStore.setState({ refusedFull: 0, clipboard: null });
|
||||
});
|
||||
|
||||
describe('a full page', () => {
|
||||
it('FE-STUDIO-FULL-001: refuses one more element and counts the refusal', () => {
|
||||
expect(store().addElement(0, el('extra'))).toBe(false);
|
||||
expect(store().doc?.spreads[0]?.elements).toHaveLength(MAX_SPREAD_ELEMENTS);
|
||||
expect(store().refusedFull).toBe(1);
|
||||
expect(store().addElement(1, el('fits'))).toBe(true);
|
||||
expect(store().refusedFull).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-FULL-002: a paste onto it is refused and counted', () => {
|
||||
useStudioStore.setState({ clipboard: { fromSpreadId: 'room', elements: [el('a')] } });
|
||||
store().paste(0);
|
||||
expect(store().doc?.spreads[0]?.elements).toHaveLength(MAX_SPREAD_ELEMENTS);
|
||||
expect(store().refusedFull).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-FULL-003: a duplicate on it is refused and counted', () => {
|
||||
store().duplicate(0, ['f0']);
|
||||
expect(store().doc?.spreads[0]?.elements).toHaveLength(MAX_SPREAD_ELEMENTS);
|
||||
expect(store().refusedFull).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-FULL-004: the Studio says it once per refusal', () => {
|
||||
toast.warning.mockClear();
|
||||
render(<StudioNotices />);
|
||||
expect(toast.warning).not.toHaveBeenCalled();
|
||||
act(() => store().refuseFull());
|
||||
expect(toast.warning).toHaveBeenCalledTimes(1);
|
||||
expect(toast.warning).toHaveBeenLastCalledWith('journey.studio.pageFull');
|
||||
act(() => store().refuseFull());
|
||||
expect(toast.warning).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -147,7 +147,7 @@ describe('prefetchVectorForPlaces', () => {
|
||||
const zooms = new Set(
|
||||
[...cache.store.keys()]
|
||||
.filter(u => u.endsWith('.pbf') && u.includes('/planet/'))
|
||||
.map(u => Number(u.split('/planet/')[1]!.split('/')[1])),
|
||||
.map(u => Number((u.split('/planet/')[1] ?? '').split('/')[1])),
|
||||
);
|
||||
// z14 is the last one OpenFreeMap serves; MapLibre scales it beyond that
|
||||
// rather than asking for another, which is why this stops where raster could not.
|
||||
@@ -166,7 +166,7 @@ describe('prefetchVectorForPlaces', () => {
|
||||
|
||||
const xs = [...cache.store.keys()]
|
||||
.filter(u => u.endsWith('.pbf') && u.includes('/planet/'))
|
||||
.map(u => u.split('/planet/')[1]!.split('/'))
|
||||
.map(u => (u.split('/planet/')[1] ?? '').split('/'))
|
||||
.filter(parts => Number(parts[1]) === 7)
|
||||
.map(parts => Number(parts[2]));
|
||||
expect((Math.max(...xs) - Math.min(...xs) + 1) * 256).toBeGreaterThanOrEqual(window.innerWidth);
|
||||
@@ -201,6 +201,46 @@ describe('prefetchVectorForPlaces', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// OpenFreeMap's liberty style lists its shaded relief (raster, up to z6) before the
|
||||
// vector planet. Taking the first source used to prefetch that relief at z0 to z14,
|
||||
// hundreds of 404s, and never the vector tiles the map needs offline.
|
||||
describe('a style with a raster source ahead of the vector one', () => {
|
||||
const places = [buildPlace({ lat: 52.52, lng: 13.405 })];
|
||||
const RELIEF = 'https://tiles.openfreemap.org/natural_earth/ne2sr/{z}/{x}/{y}.png';
|
||||
const LIBERTY = {
|
||||
sources: {
|
||||
ne2_shaded: { type: 'raster', tiles: [RELIEF], maxzoom: 6 },
|
||||
openmaptiles: { type: 'vector', url: 'https://tiles.openfreemap.org/planet' },
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((url: string, init?: { mode?: string }) => {
|
||||
requested.push({ url, mode: init?.mode });
|
||||
if (url === STYLE_URL) return Promise.resolve({ ok: true, json: () => Promise.resolve(LIBERTY), clone: () => ({}) });
|
||||
return Promise.resolve(respond(url));
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-SYNC-GLPF-015: the template is the vector source, not the first one listed', async () => {
|
||||
await expect(prefetchStyleAssets(STYLE_URL)).resolves.toBe(TILE_TEMPLATE);
|
||||
});
|
||||
|
||||
it('FE-SYNC-GLPF-016: downloads the vector tiles and the relief only at the zooms it serves', async () => {
|
||||
const result = await prefetchVectorForPlaces(places, STYLE_URL);
|
||||
expect(result.template).toBe(TILE_TEMPLATE);
|
||||
const urls = requested.map(r => r.url);
|
||||
const vectorZooms = new Set(urls.filter(u => u.includes('/planet/')).map(u => Number((u.split('/planet/')[1] ?? '').split('/')[1])));
|
||||
expect(Math.max(...vectorZooms)).toBe(14);
|
||||
const reliefZooms = urls.filter(u => u.includes('/ne2sr/')).map(u => Number((u.split('/ne2sr/')[1] ?? '').split('/')[0]));
|
||||
expect(reliefZooms.length).toBeGreaterThan(0);
|
||||
expect(Math.max(...reliefZooms)).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearVectorCache', () => {
|
||||
it('FE-SYNC-GLPF-012: drops the offline vector basemap', async () => {
|
||||
await clearVectorCache();
|
||||
|
||||
@@ -476,7 +476,9 @@ async function carryOn(
|
||||
const { text, calls, images } = answer;
|
||||
turn.history.push({ role: 'assistant', text, toolCalls: calls, providerState: step.providerState });
|
||||
for (const part of step.parts) {
|
||||
if (part.type !== 'tool-call') continue;
|
||||
// A web search is the provider's own record, kept in the provider state; an answer to it would
|
||||
// name a call the provider never made, and it refuses the next step over that.
|
||||
if (part.type !== 'tool-call' || part.toolName === ASSISTANT_WEB_SEARCH_STEP) continue;
|
||||
const seen = images.get(part.toolCallId);
|
||||
turn.history.push({
|
||||
role: 'tool',
|
||||
|
||||
@@ -347,8 +347,8 @@ export class PlacesMcp {
|
||||
name: 'search_place',
|
||||
description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id/google_ftid if configured). Use these IDs when calling create_place so the app can display opening hours, ratings, and map links. Pass locationBias whenever the trip has a destination: a bare name like "Central Station" or "Museum of Modern Art" otherwise resolves wherever the provider guesses, which is regularly the wrong continent. Searches the TREK index and OpenStreetMap, or Google Places alone with provider "google" and whenever the admin switch "Search with Google only" is on: if the instance has a plugin providing its own search index, call search_places_via_plugins as well, and note that ratings can only come from there.',
|
||||
inputSchema: {
|
||||
query: z.string().min(1).max(500).describe('Place name or address to search for'),
|
||||
locationBias: mapsSearchRequestSchema.shape.locationBias.describe('Centre the search on a coordinate: { lat, lng, radius? } with radius in metres (default 50000). Only the Google provider honours it; the OpenStreetMap fallback ignores it'),
|
||||
query: z.string().min(1).max(500).describe('One place name or address. Several places in one query match none of them well; search each on its own, the calls can run side by side'),
|
||||
locationBias: mapsSearchRequestSchema.shape.locationBias.describe('Centre the search on a coordinate: { lat, lng, radius? } with radius in metres (default 50000). Use where the place should be, such as a stop already planned in that neighbourhood, not the middle of the city: the TREK index ranks by distance from it, OpenStreetMap orders its results by it and Google searches a circle around it'),
|
||||
lang: z.string().max(35).optional().describe('BCP 47 language for the result names, e.g. "de" or "ja". Defaults to English'),
|
||||
provider: mapsSearchRequestSchema.shape.provider.describe('"google" sends this one search to Google Places alone instead of the TREK index and OpenStreetMap, the same as the "search Google instead" link under the results in the app. Ignored unless Google holds the keyed slot: an instance without a Google key, or one whose admin picked Amap or OpenStreetMap as the places provider, answers from the TREK index and OpenStreetMap as usual'),
|
||||
},
|
||||
|
||||
@@ -858,4 +858,22 @@ describe('runAgentLoop', () => {
|
||||
await runAgentLoop(unable.deps, unable.input, unable.writer, new AbortController().signal);
|
||||
expect(unable.requests[0]?.webSearch).toBe(false);
|
||||
});
|
||||
|
||||
it('ASTAGT-041: a step that searched the web and called a read answers only the read, never the search', async () => {
|
||||
const t = setup(
|
||||
[
|
||||
[
|
||||
{ type: 'web-search', query: 'Mount Takao trails' },
|
||||
{ type: 'tool-call', call: { id: 'call_1', name: 'list_places', args: { tripId: 1 } } },
|
||||
{ type: 'finish', reason: 'tool-calls' },
|
||||
],
|
||||
[{ type: 'text-delta', delta: 'Trail 1 up, trail 6 down.' }],
|
||||
],
|
||||
{ webSearch: true },
|
||||
true,
|
||||
);
|
||||
await runAgentLoop(t.deps, t.input, t.writer, new AbortController().signal);
|
||||
const answers = t.requests[1].messages.filter((m) => m.role === 'tool');
|
||||
expect(answers.map((m) => (m as { toolCallId: string }).toolCallId)).toEqual(['call_1']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,581 +1,138 @@
|
||||
{
|
||||
"ar": {
|
||||
"admin.ts": { "marked": 22, "identical": 9 },
|
||||
"backup.ts": { "marked": 1, "identical": 0 },
|
||||
"collab.ts": { "marked": 1, "identical": 0 },
|
||||
"common.ts": { "marked": 13, "identical": 0 },
|
||||
"dayplan.ts": { "marked": 8, "identical": 0 },
|
||||
"help.ts": { "marked": 0, "identical": 2 },
|
||||
"journey.ts": { "marked": 284, "identical": 2 },
|
||||
"login.ts": { "marked": 1, "identical": 3 },
|
||||
"nav.ts": { "marked": 0, "identical": 1 },
|
||||
"oauth.ts": { "marked": 19, "identical": 0 },
|
||||
"settings.ts": { "marked": 14, "identical": 68 },
|
||||
"storage.ts": { "marked": 0, "identical": 1 },
|
||||
"system_notice.ts": { "marked": 3, "identical": 3 },
|
||||
"undo.ts": { "marked": 0, "identical": 1 },
|
||||
"vacay.ts": { "marked": 0, "identical": 5 }
|
||||
"admin.ts": { "marked": 0, "identical": 2 },
|
||||
"settings.ts": { "marked": 1, "identical": 1 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 1 }
|
||||
},
|
||||
"az": {
|
||||
"admin.ts": { "marked": 0, "identical": 5 },
|
||||
"atlas.ts": { "marked": 0, "identical": 1 },
|
||||
"budget.ts": { "marked": 0, "identical": 1 },
|
||||
"dayplan.ts": { "marked": 0, "identical": 1 },
|
||||
"help.ts": { "marked": 0, "identical": 3 },
|
||||
"journey.ts": { "marked": 0, "identical": 8 },
|
||||
"mobileCollections.ts": { "marked": 0, "identical": 1 },
|
||||
"nav.ts": { "marked": 0, "identical": 3 },
|
||||
"admin.ts": { "marked": 0, "identical": 1 },
|
||||
"journey.ts": { "marked": 0, "identical": 1 },
|
||||
"places.ts": { "marked": 0, "identical": 1 },
|
||||
"reservations.ts": { "marked": 0, "identical": 4 },
|
||||
"settings.ts": { "marked": 0, "identical": 6 },
|
||||
"shared.ts": { "marked": 0, "identical": 1 },
|
||||
"storage.ts": { "marked": 0, "identical": 2 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 1 },
|
||||
"trip.ts": { "marked": 0, "identical": 2 }
|
||||
"settings.ts": { "marked": 0, "identical": 1 }
|
||||
},
|
||||
"br": {
|
||||
"admin.ts": { "marked": 0, "identical": 29 },
|
||||
"atlas.ts": { "marked": 0, "identical": 1 },
|
||||
"budget.ts": { "marked": 0, "identical": 3 },
|
||||
"collab.ts": { "marked": 0, "identical": 2 },
|
||||
"collection.ts": { "marked": 0, "identical": 2 },
|
||||
"common.ts": { "marked": 12, "identical": 0 },
|
||||
"dashboard.ts": { "marked": 0, "identical": 1 },
|
||||
"day.ts": { "marked": 0, "identical": 2 },
|
||||
"dayplan.ts": { "marked": 2, "identical": 1 },
|
||||
"journey.ts": { "marked": 115, "identical": 7 },
|
||||
"login.ts": { "marked": 0, "identical": 3 },
|
||||
"map.ts": { "marked": 0, "identical": 1 },
|
||||
"mobileCollections.ts": { "marked": 0, "identical": 1 },
|
||||
"mobileJourney.ts": { "marked": 0, "identical": 1 },
|
||||
"nav.ts": { "marked": 0, "identical": 3 },
|
||||
"oauth.ts": { "marked": 17, "identical": 2 },
|
||||
"places.ts": { "marked": 0, "identical": 3 },
|
||||
"reservations.ts": { "marked": 0, "identical": 7 },
|
||||
"roadtrip.ts": { "marked": 0, "identical": 4 },
|
||||
"settings.ts": { "marked": 0, "identical": 85 },
|
||||
"share.ts": { "marked": 0, "identical": 1 },
|
||||
"shared.ts": { "marked": 0, "identical": 1 },
|
||||
"storage.ts": { "marked": 0, "identical": 4 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 5 },
|
||||
"trip.ts": { "marked": 0, "identical": 1 },
|
||||
"undo.ts": { "marked": 0, "identical": 1 },
|
||||
"vacay.ts": { "marked": 0, "identical": 6 }
|
||||
"admin.ts": { "marked": 0, "identical": 1 },
|
||||
"journey.ts": { "marked": 0, "identical": 1 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 2 }
|
||||
},
|
||||
"ca": {
|
||||
"admin.ts": { "marked": 0, "identical": 16 },
|
||||
"atlas.ts": { "marked": 0, "identical": 2 },
|
||||
"backup.ts": { "marked": 0, "identical": 1 },
|
||||
"budget.ts": { "marked": 0, "identical": 4 },
|
||||
"categories.ts": { "marked": 0, "identical": 2 },
|
||||
"collab.ts": { "marked": 0, "identical": 3 },
|
||||
"collection.ts": { "marked": 0, "identical": 2 },
|
||||
"common.ts": { "marked": 0, "identical": 2 },
|
||||
"dashboard.ts": { "marked": 0, "identical": 1 },
|
||||
"dawarich.ts": { "marked": 0, "identical": 1 },
|
||||
"dayplan.ts": { "marked": 0, "identical": 3 },
|
||||
"docsync.ts": { "marked": 0, "identical": 2 },
|
||||
"files.ts": { "marked": 0, "identical": 3 },
|
||||
"help.ts": { "marked": 0, "identical": 2 },
|
||||
"inspector.ts": { "marked": 0, "identical": 1 },
|
||||
"journey.ts": { "marked": 110, "identical": 12 },
|
||||
"map.ts": { "marked": 0, "identical": 1 },
|
||||
"mobileTrip.ts": { "marked": 0, "identical": 2 },
|
||||
"nav.ts": { "marked": 0, "identical": 1 },
|
||||
"oauth.ts": { "marked": 0, "identical": 1 },
|
||||
"packing.ts": { "marked": 0, "identical": 1 },
|
||||
"places.ts": { "marked": 0, "identical": 2 },
|
||||
"planner.ts": { "marked": 0, "identical": 1 },
|
||||
"reservations.ts": { "marked": 0, "identical": 6 },
|
||||
"roadtrip.ts": { "marked": 0, "identical": 3 },
|
||||
"settings.ts": { "marked": 0, "identical": 20 },
|
||||
"storage.ts": { "marked": 0, "identical": 4 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 3 },
|
||||
"transport.ts": { "marked": 0, "identical": 1 },
|
||||
"trip.ts": { "marked": 0, "identical": 1 },
|
||||
"vacay.ts": { "marked": 0, "identical": 7 }
|
||||
"admin.ts": { "marked": 0, "identical": 1 },
|
||||
"journey.ts": { "marked": 0, "identical": 4 },
|
||||
"settings.ts": { "marked": 0, "identical": 1 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 2 }
|
||||
},
|
||||
"cs": {
|
||||
"admin.ts": { "marked": 0, "identical": 26 },
|
||||
"atlas.ts": { "marked": 0, "identical": 2 },
|
||||
"backup.ts": { "marked": 0, "identical": 1 },
|
||||
"collab.ts": { "marked": 0, "identical": 1 },
|
||||
"collection.ts": { "marked": 0, "identical": 2 },
|
||||
"common.ts": { "marked": 12, "identical": 0 },
|
||||
"day.ts": { "marked": 0, "identical": 2 },
|
||||
"dayplan.ts": { "marked": 2, "identical": 0 },
|
||||
"docsync.ts": { "marked": 0, "identical": 1 },
|
||||
"help.ts": { "marked": 0, "identical": 3 },
|
||||
"journey.ts": { "marked": 115, "identical": 6 },
|
||||
"login.ts": { "marked": 0, "identical": 3 },
|
||||
"nav.ts": { "marked": 0, "identical": 1 },
|
||||
"oauth.ts": { "marked": 17, "identical": 1 },
|
||||
"places.ts": { "marked": 0, "identical": 2 },
|
||||
"reservations.ts": { "marked": 0, "identical": 3 },
|
||||
"roadtrip.ts": { "marked": 0, "identical": 3 },
|
||||
"settings.ts": { "marked": 0, "identical": 83 },
|
||||
"share.ts": { "marked": 0, "identical": 1 },
|
||||
"shared.ts": { "marked": 0, "identical": 1 },
|
||||
"storage.ts": { "marked": 0, "identical": 3 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 5 },
|
||||
"tours.ts": { "marked": 0, "identical": 1 },
|
||||
"undo.ts": { "marked": 0, "identical": 1 },
|
||||
"vacay.ts": { "marked": 0, "identical": 5 }
|
||||
"admin.ts": { "marked": 0, "identical": 1 },
|
||||
"journey.ts": { "marked": 0, "identical": 1 },
|
||||
"places.ts": { "marked": 0, "identical": 1 }
|
||||
},
|
||||
"de": {
|
||||
"admin.ts": { "marked": 0, "identical": 42 },
|
||||
"atlas.ts": { "marked": 0, "identical": 1 },
|
||||
"budget.ts": { "marked": 0, "identical": 7 },
|
||||
"collab.ts": { "marked": 0, "identical": 5 },
|
||||
"collection.ts": { "marked": 0, "identical": 4 },
|
||||
"common.ts": { "marked": 0, "identical": 1 },
|
||||
"dashboard.ts": { "marked": 0, "identical": 1 },
|
||||
"dawarich.ts": { "marked": 0, "identical": 1 },
|
||||
"day.ts": { "marked": 0, "identical": 3 },
|
||||
"dayplan.ts": { "marked": 0, "identical": 4 },
|
||||
"docsync.ts": { "marked": 0, "identical": 1 },
|
||||
"files.ts": { "marked": 0, "identical": 2 },
|
||||
"help.ts": { "marked": 0, "identical": 10 },
|
||||
"inspector.ts": { "marked": 0, "identical": 1 },
|
||||
"journey.ts": { "marked": 9, "identical": 25 },
|
||||
"map.ts": { "marked": 0, "identical": 2 },
|
||||
"mobileCollections.ts": { "marked": 0, "identical": 1 },
|
||||
"mobileJourney.ts": { "marked": 0, "identical": 1 },
|
||||
"mobileTrip.ts": { "marked": 0, "identical": 3 },
|
||||
"nav.ts": { "marked": 0, "identical": 3 },
|
||||
"notifications.ts": { "marked": 0, "identical": 1 },
|
||||
"oauth.ts": { "marked": 17, "identical": 4 },
|
||||
"packing.ts": { "marked": 0, "identical": 1 },
|
||||
"places.ts": { "marked": 0, "identical": 5 },
|
||||
"planner.ts": { "marked": 0, "identical": 1 },
|
||||
"reservations.ts": { "marked": 0, "identical": 13 },
|
||||
"roadtrip.ts": { "marked": 0, "identical": 5 },
|
||||
"settings.ts": { "marked": 0, "identical": 28 },
|
||||
"share.ts": { "marked": 0, "identical": 1 },
|
||||
"shared.ts": { "marked": 0, "identical": 2 },
|
||||
"storage.ts": { "marked": 0, "identical": 5 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 5 },
|
||||
"tours.ts": { "marked": 0, "identical": 1 },
|
||||
"transport.ts": { "marked": 0, "identical": 1 },
|
||||
"trip.ts": { "marked": 0, "identical": 2 },
|
||||
"vacay.ts": { "marked": 0, "identical": 1 }
|
||||
"admin.ts": { "marked": 0, "identical": 1 },
|
||||
"journey.ts": { "marked": 0, "identical": 2 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 2 }
|
||||
},
|
||||
"es": {
|
||||
"admin.ts": { "marked": 0, "identical": 28 },
|
||||
"atlas.ts": { "marked": 0, "identical": 1 },
|
||||
"budget.ts": { "marked": 0, "identical": 2 },
|
||||
"categories.ts": { "marked": 0, "identical": 1 },
|
||||
"collab.ts": { "marked": 0, "identical": 1 },
|
||||
"collection.ts": { "marked": 0, "identical": 2 },
|
||||
"common.ts": { "marked": 12, "identical": 2 },
|
||||
"dashboard.ts": { "marked": 0, "identical": 1 },
|
||||
"dayplan.ts": { "marked": 2, "identical": 0 },
|
||||
"docsync.ts": { "marked": 0, "identical": 1 },
|
||||
"help.ts": { "marked": 0, "identical": 3 },
|
||||
"journey.ts": { "marked": 115, "identical": 7 },
|
||||
"login.ts": { "marked": 0, "identical": 3 },
|
||||
"map.ts": { "marked": 0, "identical": 1 },
|
||||
"nav.ts": { "marked": 0, "identical": 2 },
|
||||
"oauth.ts": { "marked": 17, "identical": 1 },
|
||||
"packing.ts": { "marked": 0, "identical": 1 },
|
||||
"reservations.ts": { "marked": 0, "identical": 3 },
|
||||
"roadtrip.ts": { "marked": 0, "identical": 1 },
|
||||
"settings.ts": { "marked": 0, "identical": 84 },
|
||||
"share.ts": { "marked": 0, "identical": 1 },
|
||||
"shared.ts": { "marked": 0, "identical": 2 },
|
||||
"storage.ts": { "marked": 0, "identical": 3 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 5 },
|
||||
"trip.ts": { "marked": 0, "identical": 4 },
|
||||
"undo.ts": { "marked": 0, "identical": 1 },
|
||||
"vacay.ts": { "marked": 0, "identical": 6 }
|
||||
},
|
||||
"et": {
|
||||
"admin.ts": { "marked": 0, "identical": 3 },
|
||||
"budget.ts": { "marked": 0, "identical": 1 },
|
||||
"dashboard.ts": { "marked": 0, "identical": 1 },
|
||||
"dayplan.ts": { "marked": 0, "identical": 1 },
|
||||
"files.ts": { "marked": 0, "identical": 2 },
|
||||
"help.ts": { "marked": 0, "identical": 1 },
|
||||
"journey.ts": { "marked": 0, "identical": 2 },
|
||||
"mobileTrip.ts": { "marked": 0, "identical": 1 },
|
||||
"reservations.ts": { "marked": 0, "identical": 1 },
|
||||
"settings.ts": { "marked": 0, "identical": 2 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 1 },
|
||||
"transport.ts": { "marked": 0, "identical": 1 },
|
||||
"vacay.ts": { "marked": 0, "identical": 1 }
|
||||
"admin.ts": { "marked": 0, "identical": 1 },
|
||||
"journey.ts": { "marked": 0, "identical": 1 }
|
||||
},
|
||||
"fr": {
|
||||
"admin.ts": { "marked": 0, "identical": 48 },
|
||||
"atlas.ts": { "marked": 0, "identical": 2 },
|
||||
"budget.ts": { "marked": 0, "identical": 9 },
|
||||
"collab.ts": { "marked": 0, "identical": 8 },
|
||||
"collection.ts": { "marked": 0, "identical": 4 },
|
||||
"common.ts": { "marked": 12, "identical": 1 },
|
||||
"dashboard.ts": { "marked": 0, "identical": 3 },
|
||||
"dawarich.ts": { "marked": 0, "identical": 3 },
|
||||
"day.ts": { "marked": 0, "identical": 1 },
|
||||
"dayplan.ts": { "marked": 2, "identical": 5 },
|
||||
"docsync.ts": { "marked": 0, "identical": 1 },
|
||||
"files.ts": { "marked": 0, "identical": 5 },
|
||||
"admin.ts": { "marked": 0, "identical": 2 },
|
||||
"atlas.ts": { "marked": 0, "identical": 1 },
|
||||
"help.ts": { "marked": 0, "identical": 1 },
|
||||
"inspector.ts": { "marked": 0, "identical": 2 },
|
||||
"journey.ts": { "marked": 115, "identical": 32 },
|
||||
"login.ts": { "marked": 0, "identical": 3 },
|
||||
"map.ts": { "marked": 0, "identical": 3 },
|
||||
"memories.ts": { "marked": 0, "identical": 3 },
|
||||
"mobileJourney.ts": { "marked": 0, "identical": 3 },
|
||||
"mobileTrip.ts": { "marked": 0, "identical": 3 },
|
||||
"nav.ts": { "marked": 0, "identical": 3 },
|
||||
"notif.ts": { "marked": 0, "identical": 2 },
|
||||
"notifications.ts": { "marked": 0, "identical": 3 },
|
||||
"oauth.ts": { "marked": 17, "identical": 4 },
|
||||
"packing.ts": { "marked": 0, "identical": 1 },
|
||||
"pdf.ts": { "marked": 0, "identical": 1 },
|
||||
"places.ts": { "marked": 0, "identical": 8 },
|
||||
"planner.ts": { "marked": 0, "identical": 3 },
|
||||
"reservations.ts": { "marked": 0, "identical": 16 },
|
||||
"roadtrip.ts": { "marked": 0, "identical": 6 },
|
||||
"settings.ts": { "marked": 0, "identical": 89 },
|
||||
"share.ts": { "marked": 0, "identical": 2 },
|
||||
"shared.ts": { "marked": 0, "identical": 3 },
|
||||
"storage.ts": { "marked": 0, "identical": 4 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 4 },
|
||||
"todo.ts": { "marked": 0, "identical": 1 },
|
||||
"tours.ts": { "marked": 0, "identical": 1 },
|
||||
"transport.ts": { "marked": 0, "identical": 2 },
|
||||
"trip.ts": { "marked": 0, "identical": 7 },
|
||||
"undo.ts": { "marked": 0, "identical": 1 },
|
||||
"vacay.ts": { "marked": 0, "identical": 5 }
|
||||
"journey.ts": { "marked": 0, "identical": 10 },
|
||||
"notif.ts": { "marked": 0, "identical": 1 },
|
||||
"places.ts": { "marked": 0, "identical": 1 },
|
||||
"planner.ts": { "marked": 0, "identical": 1 },
|
||||
"roadtrip.ts": { "marked": 0, "identical": 2 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 2 }
|
||||
},
|
||||
"gr": {
|
||||
"admin.ts": { "marked": 0, "identical": 21 },
|
||||
"common.ts": { "marked": 12, "identical": 1 },
|
||||
"dashboard.ts": { "marked": 36, "identical": 0 },
|
||||
"admin.ts": { "marked": 0, "identical": 11 },
|
||||
"common.ts": { "marked": 0, "identical": 1 },
|
||||
"day.ts": { "marked": 0, "identical": 2 },
|
||||
"help.ts": { "marked": 0, "identical": 3 },
|
||||
"journey.ts": { "marked": 110, "identical": 2 },
|
||||
"login.ts": { "marked": 0, "identical": 3 },
|
||||
"memories.ts": { "marked": 0, "identical": 1 },
|
||||
"nav.ts": { "marked": 0, "identical": 1 },
|
||||
"oauth.ts": { "marked": 17, "identical": 0 },
|
||||
"help.ts": { "marked": 0, "identical": 2 },
|
||||
"journey.ts": { "marked": 0, "identical": 1 },
|
||||
"places.ts": { "marked": 0, "identical": 1 },
|
||||
"reservations.ts": { "marked": 0, "identical": 2 },
|
||||
"roadtrip.ts": { "marked": 0, "identical": 3 },
|
||||
"settings.ts": { "marked": 0, "identical": 86 },
|
||||
"storage.ts": { "marked": 0, "identical": 2 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 3 },
|
||||
"undo.ts": { "marked": 0, "identical": 1 },
|
||||
"vacay.ts": { "marked": 0, "identical": 5 }
|
||||
},
|
||||
"hu": {
|
||||
"admin.ts": { "marked": 0, "identical": 26 },
|
||||
"common.ts": { "marked": 12, "identical": 0 },
|
||||
"dayplan.ts": { "marked": 2, "identical": 0 },
|
||||
"help.ts": { "marked": 0, "identical": 3 },
|
||||
"journey.ts": { "marked": 115, "identical": 2 },
|
||||
"login.ts": { "marked": 0, "identical": 3 },
|
||||
"nav.ts": { "marked": 0, "identical": 3 },
|
||||
"oauth.ts": { "marked": 17, "identical": 1 },
|
||||
"places.ts": { "marked": 0, "identical": 1 },
|
||||
"reservations.ts": { "marked": 0, "identical": 1 },
|
||||
"settings.ts": { "marked": 0, "identical": 79 },
|
||||
"storage.ts": { "marked": 0, "identical": 1 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 5 },
|
||||
"undo.ts": { "marked": 0, "identical": 1 },
|
||||
"vacay.ts": { "marked": 0, "identical": 5 }
|
||||
},
|
||||
"id": {
|
||||
"admin.ts": { "marked": 0, "identical": 29 },
|
||||
"atlas.ts": { "marked": 0, "identical": 1 },
|
||||
"backup.ts": { "marked": 0, "identical": 1 },
|
||||
"budget.ts": { "marked": 0, "identical": 3 },
|
||||
"collab.ts": { "marked": 0, "identical": 3 },
|
||||
"collection.ts": { "marked": 0, "identical": 3 },
|
||||
"common.ts": { "marked": 12, "identical": 1 },
|
||||
"dashboard.ts": { "marked": 0, "identical": 1 },
|
||||
"day.ts": { "marked": 0, "identical": 2 },
|
||||
"dayplan.ts": { "marked": 2, "identical": 1 },
|
||||
"docsync.ts": { "marked": 0, "identical": 1 },
|
||||
"help.ts": { "marked": 0, "identical": 5 },
|
||||
"journey.ts": { "marked": 115, "identical": 13 },
|
||||
"login.ts": { "marked": 0, "identical": 3 },
|
||||
"memories.ts": { "marked": 0, "identical": 1 },
|
||||
"mobileCollections.ts": { "marked": 0, "identical": 1 },
|
||||
"nav.ts": { "marked": 0, "identical": 4 },
|
||||
"oauth.ts": { "marked": 17, "identical": 2 },
|
||||
"packing.ts": { "marked": 0, "identical": 2 },
|
||||
"places.ts": { "marked": 0, "identical": 3 },
|
||||
"reservations.ts": { "marked": 0, "identical": 7 },
|
||||
"roadtrip.ts": { "marked": 0, "identical": 5 },
|
||||
"settings.ts": { "marked": 0, "identical": 90 },
|
||||
"share.ts": { "marked": 0, "identical": 1 },
|
||||
"shared.ts": { "marked": 0, "identical": 1 },
|
||||
"storage.ts": { "marked": 0, "identical": 2 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 5 },
|
||||
"trip.ts": { "marked": 0, "identical": 2 },
|
||||
"undo.ts": { "marked": 0, "identical": 1 },
|
||||
"vacay.ts": { "marked": 0, "identical": 5 }
|
||||
},
|
||||
"it": {
|
||||
"admin.ts": { "marked": 0, "identical": 30 },
|
||||
"atlas.ts": { "marked": 0, "identical": 3 },
|
||||
"budget.ts": { "marked": 0, "identical": 3 },
|
||||
"collab.ts": { "marked": 0, "identical": 1 },
|
||||
"collection.ts": { "marked": 0, "identical": 2 },
|
||||
"common.ts": { "marked": 0, "identical": 3 },
|
||||
"dashboard.ts": { "marked": 0, "identical": 1 },
|
||||
"day.ts": { "marked": 0, "identical": 2 },
|
||||
"dayplan.ts": { "marked": 2, "identical": 1 },
|
||||
"docsync.ts": { "marked": 0, "identical": 1 },
|
||||
"files.ts": { "marked": 0, "identical": 1 },
|
||||
"inspector.ts": { "marked": 0, "identical": 1 },
|
||||
"journey.ts": { "marked": 115, "identical": 4 },
|
||||
"login.ts": { "marked": 0, "identical": 3 },
|
||||
"map.ts": { "marked": 0, "identical": 1 },
|
||||
"memories.ts": { "marked": 0, "identical": 1 },
|
||||
"mobileTrip.ts": { "marked": 0, "identical": 1 },
|
||||
"nav.ts": { "marked": 0, "identical": 2 },
|
||||
"oauth.ts": { "marked": 17, "identical": 2 },
|
||||
"places.ts": { "marked": 0, "identical": 2 },
|
||||
"reservations.ts": { "marked": 0, "identical": 6 },
|
||||
"roadtrip.ts": { "marked": 0, "identical": 5 },
|
||||
"settings.ts": { "marked": 0, "identical": 89 },
|
||||
"share.ts": { "marked": 0, "identical": 1 },
|
||||
"shared.ts": { "marked": 0, "identical": 1 },
|
||||
"storage.ts": { "marked": 0, "identical": 3 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 5 },
|
||||
"trip.ts": { "marked": 0, "identical": 1 },
|
||||
"undo.ts": { "marked": 0, "identical": 1 },
|
||||
"vacay.ts": { "marked": 0, "identical": 6 }
|
||||
},
|
||||
"ja": {
|
||||
"admin.ts": { "marked": 0, "identical": 9 },
|
||||
"common.ts": { "marked": 12, "identical": 0 },
|
||||
"help.ts": { "marked": 0, "identical": 1 },
|
||||
"journey.ts": { "marked": 110, "identical": 2 },
|
||||
"login.ts": { "marked": 0, "identical": 3 },
|
||||
"nav.ts": { "marked": 0, "identical": 1 },
|
||||
"oauth.ts": { "marked": 17, "identical": 0 },
|
||||
"settings.ts": { "marked": 0, "identical": 71 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 3 },
|
||||
"undo.ts": { "marked": 0, "identical": 1 },
|
||||
"vacay.ts": { "marked": 0, "identical": 5 }
|
||||
},
|
||||
"ko": {
|
||||
"admin.ts": { "marked": 0, "identical": 10 },
|
||||
"common.ts": { "marked": 12, "identical": 0 },
|
||||
"help.ts": { "marked": 0, "identical": 1 },
|
||||
"journey.ts": { "marked": 110, "identical": 3 },
|
||||
"login.ts": { "marked": 0, "identical": 3 },
|
||||
"nav.ts": { "marked": 0, "identical": 1 },
|
||||
"oauth.ts": { "marked": 17, "identical": 1 },
|
||||
"settings.ts": { "marked": 0, "identical": 69 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 3 },
|
||||
"undo.ts": { "marked": 0, "identical": 1 },
|
||||
"vacay.ts": { "marked": 0, "identical": 5 }
|
||||
},
|
||||
"nl": {
|
||||
"admin.ts": { "marked": 0, "identical": 28 },
|
||||
"atlas.ts": { "marked": 0, "identical": 1 },
|
||||
"backup.ts": { "marked": 0, "identical": 1 },
|
||||
"budget.ts": { "marked": 0, "identical": 4 },
|
||||
"collab.ts": { "marked": 0, "identical": 6 },
|
||||
"collection.ts": { "marked": 0, "identical": 5 },
|
||||
"common.ts": { "marked": 12, "identical": 0 },
|
||||
"dashboard.ts": { "marked": 0, "identical": 1 },
|
||||
"day.ts": { "marked": 0, "identical": 1 },
|
||||
"dayplan.ts": { "marked": 2, "identical": 5 },
|
||||
"docsync.ts": { "marked": 0, "identical": 1 },
|
||||
"files.ts": { "marked": 0, "identical": 2 },
|
||||
"journey.ts": { "marked": 115, "identical": 17 },
|
||||
"login.ts": { "marked": 0, "identical": 3 },
|
||||
"map.ts": { "marked": 0, "identical": 2 },
|
||||
"mobileCollections.ts": { "marked": 0, "identical": 1 },
|
||||
"mobileJourney.ts": { "marked": 0, "identical": 1 },
|
||||
"mobileTrip.ts": { "marked": 0, "identical": 4 },
|
||||
"nav.ts": { "marked": 0, "identical": 2 },
|
||||
"oauth.ts": { "marked": 17, "identical": 2 },
|
||||
"packing.ts": { "marked": 0, "identical": 1 },
|
||||
"places.ts": { "marked": 0, "identical": 7 },
|
||||
"planner.ts": { "marked": 0, "identical": 1 },
|
||||
"reservations.ts": { "marked": 0, "identical": 15 },
|
||||
"roadtrip.ts": { "marked": 0, "identical": 5 },
|
||||
"settings.ts": { "marked": 0, "identical": 91 },
|
||||
"share.ts": { "marked": 0, "identical": 1 },
|
||||
"shared.ts": { "marked": 0, "identical": 2 },
|
||||
"storage.ts": { "marked": 0, "identical": 5 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 6 },
|
||||
"tours.ts": { "marked": 0, "identical": 1 },
|
||||
"trip.ts": { "marked": 0, "identical": 5 },
|
||||
"undo.ts": { "marked": 0, "identical": 1 },
|
||||
"vacay.ts": { "marked": 0, "identical": 6 }
|
||||
},
|
||||
"pl": {
|
||||
"admin.ts": { "marked": 0, "identical": 25 },
|
||||
"atlas.ts": { "marked": 0, "identical": 2 },
|
||||
"budget.ts": { "marked": 0, "identical": 3 },
|
||||
"common.ts": { "marked": 12, "identical": 0 },
|
||||
"dayplan.ts": { "marked": 2, "identical": 1 },
|
||||
"docsync.ts": { "marked": 0, "identical": 1 },
|
||||
"files.ts": { "marked": 0, "identical": 2 },
|
||||
"help.ts": { "marked": 0, "identical": 2 },
|
||||
"journey.ts": { "marked": 115, "identical": 5 },
|
||||
"login.ts": { "marked": 0, "identical": 3 },
|
||||
"memories.ts": { "marked": 0, "identical": 1 },
|
||||
"mobileCollections.ts": { "marked": 0, "identical": 1 },
|
||||
"mobileTrip.ts": { "marked": 0, "identical": 1 },
|
||||
"nav.ts": { "marked": 0, "identical": 3 },
|
||||
"notifications.ts": { "marked": 0, "identical": 1 },
|
||||
"oauth.ts": { "marked": 17, "identical": 1 },
|
||||
"places.ts": { "marked": 0, "identical": 2 },
|
||||
"reservations.ts": { "marked": 0, "identical": 6 },
|
||||
"settings.ts": { "marked": 0, "identical": 81 },
|
||||
"shared.ts": { "marked": 0, "identical": 1 },
|
||||
"storage.ts": { "marked": 0, "identical": 3 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 5 },
|
||||
"transport.ts": { "marked": 0, "identical": 1 },
|
||||
"trip.ts": { "marked": 0, "identical": 2 },
|
||||
"undo.ts": { "marked": 0, "identical": 1 },
|
||||
"vacay.ts": { "marked": 0, "identical": 5 }
|
||||
},
|
||||
"ru": {
|
||||
"admin.ts": { "marked": 0, "identical": 21 },
|
||||
"common.ts": { "marked": 12, "identical": 0 },
|
||||
"dayplan.ts": { "marked": 2, "identical": 0 },
|
||||
"help.ts": { "marked": 0, "identical": 2 },
|
||||
"journey.ts": { "marked": 115, "identical": 1 },
|
||||
"login.ts": { "marked": 0, "identical": 3 },
|
||||
"oauth.ts": { "marked": 17, "identical": 1 },
|
||||
"settings.ts": { "marked": 0, "identical": 15 },
|
||||
"storage.ts": { "marked": 0, "identical": 1 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 5 },
|
||||
"undo.ts": { "marked": 0, "identical": 1 },
|
||||
"vacay.ts": { "marked": 0, "identical": 5 }
|
||||
},
|
||||
"sk": {
|
||||
"admin.ts": { "marked": 0, "identical": 8 },
|
||||
"atlas.ts": { "marked": 0, "identical": 1 },
|
||||
"backup.ts": { "marked": 0, "identical": 1 },
|
||||
"collab.ts": { "marked": 0, "identical": 1 },
|
||||
"collection.ts": { "marked": 0, "identical": 1 },
|
||||
"dashboard.ts": { "marked": 0, "identical": 1 },
|
||||
"day.ts": { "marked": 0, "identical": 2 },
|
||||
"docsync.ts": { "marked": 0, "identical": 1 },
|
||||
"help.ts": { "marked": 0, "identical": 3 },
|
||||
"journey.ts": { "marked": 0, "identical": 9 },
|
||||
"oauth.ts": { "marked": 0, "identical": 1 },
|
||||
"places.ts": { "marked": 0, "identical": 2 },
|
||||
"reservations.ts": { "marked": 0, "identical": 3 },
|
||||
"roadtrip.ts": { "marked": 0, "identical": 3 },
|
||||
"settings.ts": { "marked": 0, "identical": 12 },
|
||||
"share.ts": { "marked": 0, "identical": 1 },
|
||||
"shared.ts": { "marked": 0, "identical": 1 },
|
||||
"settings.ts": { "marked": 0, "identical": 8 },
|
||||
"storage.ts": { "marked": 0, "identical": 2 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 3 }
|
||||
},
|
||||
"hu": {
|
||||
"admin.ts": { "marked": 0, "identical": 1 }
|
||||
},
|
||||
"id": {
|
||||
"admin.ts": { "marked": 0, "identical": 2 },
|
||||
"journey.ts": { "marked": 0, "identical": 1 }
|
||||
},
|
||||
"it": {
|
||||
"admin.ts": { "marked": 0, "identical": 1 },
|
||||
"journey.ts": { "marked": 0, "identical": 1 },
|
||||
"storage.ts": { "marked": 0, "identical": 1 }
|
||||
},
|
||||
"ja": {
|
||||
"admin.ts": { "marked": 0, "identical": 1 },
|
||||
"settings.ts": { "marked": 0, "identical": 1 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 2 }
|
||||
},
|
||||
"ko": {
|
||||
"admin.ts": { "marked": 0, "identical": 1 },
|
||||
"settings.ts": { "marked": 0, "identical": 1 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 3 }
|
||||
},
|
||||
"nl": {
|
||||
"journey.ts": { "marked": 0, "identical": 2 },
|
||||
"places.ts": { "marked": 0, "identical": 1 },
|
||||
"settings.ts": { "marked": 0, "identical": 6 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 3 }
|
||||
},
|
||||
"pl": {
|
||||
"admin.ts": { "marked": 0, "identical": 1 },
|
||||
"places.ts": { "marked": 0, "identical": 1 }
|
||||
},
|
||||
"ru": {
|
||||
"admin.ts": { "marked": 0, "identical": 4 },
|
||||
"settings.ts": { "marked": 0, "identical": 3 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 3 }
|
||||
},
|
||||
"sk": {
|
||||
"admin.ts": { "marked": 0, "identical": 1 }
|
||||
},
|
||||
"sv": {
|
||||
"admin.ts": { "marked": 0, "identical": 24 },
|
||||
"atlas.ts": { "marked": 0, "identical": 2 },
|
||||
"budget.ts": { "marked": 0, "identical": 9 },
|
||||
"collection.ts": { "marked": 0, "identical": 1 },
|
||||
"dashboard.ts": { "marked": 0, "identical": 1 },
|
||||
"dayplan.ts": { "marked": 0, "identical": 2 },
|
||||
"files.ts": { "marked": 0, "identical": 2 },
|
||||
"help.ts": { "marked": 0, "identical": 6 },
|
||||
"inspector.ts": { "marked": 0, "identical": 1 },
|
||||
"journey.ts": { "marked": 110, "identical": 18 },
|
||||
"login.ts": { "marked": 0, "identical": 3 },
|
||||
"members.ts": { "marked": 0, "identical": 1 },
|
||||
"memories.ts": { "marked": 0, "identical": 1 },
|
||||
"mobileCollections.ts": { "marked": 0, "identical": 1 },
|
||||
"mobileTrip.ts": { "marked": 0, "identical": 3 },
|
||||
"nav.ts": { "marked": 0, "identical": 3 },
|
||||
"notifications.ts": { "marked": 0, "identical": 1 },
|
||||
"oauth.ts": { "marked": 0, "identical": 3 },
|
||||
"admin.ts": { "marked": 0, "identical": 1 },
|
||||
"budget.ts": { "marked": 0, "identical": 1 },
|
||||
"journey.ts": { "marked": 0, "identical": 1 },
|
||||
"mobileTrip.ts": { "marked": 0, "identical": 1 },
|
||||
"packing.ts": { "marked": 0, "identical": 1 },
|
||||
"reservations.ts": { "marked": 0, "identical": 7 },
|
||||
"roadtrip.ts": { "marked": 0, "identical": 1 },
|
||||
"settings.ts": { "marked": 0, "identical": 76 },
|
||||
"shared.ts": { "marked": 0, "identical": 1 },
|
||||
"storage.ts": { "marked": 0, "identical": 2 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 3 },
|
||||
"tours.ts": { "marked": 0, "identical": 1 },
|
||||
"transport.ts": { "marked": 0, "identical": 1 },
|
||||
"trip.ts": { "marked": 0, "identical": 2 },
|
||||
"undo.ts": { "marked": 0, "identical": 1 },
|
||||
"vacay.ts": { "marked": 0, "identical": 6 }
|
||||
"settings.ts": { "marked": 0, "identical": 1 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 2 }
|
||||
},
|
||||
"th": {
|
||||
"admin.ts": { "marked": 0, "identical": 1 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 1 }
|
||||
},
|
||||
"tr": {
|
||||
"admin.ts": { "marked": 0, "identical": 11 },
|
||||
"collab.ts": { "marked": 0, "identical": 1 },
|
||||
"common.ts": { "marked": 12, "identical": 0 },
|
||||
"help.ts": { "marked": 0, "identical": 2 },
|
||||
"journey.ts": { "marked": 110, "identical": 5 },
|
||||
"login.ts": { "marked": 0, "identical": 6 },
|
||||
"memories.ts": { "marked": 0, "identical": 1 },
|
||||
"nav.ts": { "marked": 0, "identical": 1 },
|
||||
"notifications.ts": { "marked": 0, "identical": 1 },
|
||||
"oauth.ts": { "marked": 17, "identical": 0 },
|
||||
"places.ts": { "marked": 0, "identical": 1 },
|
||||
"roadtrip.ts": { "marked": 0, "identical": 1 },
|
||||
"settings.ts": { "marked": 0, "identical": 71 },
|
||||
"shared.ts": { "marked": 0, "identical": 1 },
|
||||
"storage.ts": { "marked": 0, "identical": 1 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 4 },
|
||||
"trip.ts": { "marked": 0, "identical": 2 },
|
||||
"undo.ts": { "marked": 0, "identical": 1 },
|
||||
"vacay.ts": { "marked": 0, "identical": 3 }
|
||||
"system_notice.ts": { "marked": 0, "identical": 1 }
|
||||
},
|
||||
"uk": {
|
||||
"admin.ts": { "marked": 0, "identical": 12 },
|
||||
"common.ts": { "marked": 12, "identical": 0 },
|
||||
"help.ts": { "marked": 0, "identical": 3 },
|
||||
"journey.ts": { "marked": 110, "identical": 3 },
|
||||
"login.ts": { "marked": 0, "identical": 3 },
|
||||
"nav.ts": { "marked": 0, "identical": 1 },
|
||||
"oauth.ts": { "marked": 17, "identical": 1 },
|
||||
"settings.ts": { "marked": 0, "identical": 78 },
|
||||
"storage.ts": { "marked": 0, "identical": 1 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 5 },
|
||||
"undo.ts": { "marked": 0, "identical": 1 },
|
||||
"vacay.ts": { "marked": 0, "identical": 5 }
|
||||
"admin.ts": { "marked": 0, "identical": 4 },
|
||||
"settings.ts": { "marked": 0, "identical": 3 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 1 }
|
||||
},
|
||||
"vi": {
|
||||
"admin.ts": { "marked": 0, "identical": 13 },
|
||||
"common.ts": { "marked": 0, "identical": 1 },
|
||||
"help.ts": { "marked": 0, "identical": 2 },
|
||||
"journey.ts": { "marked": 110, "identical": 3 },
|
||||
"reservations.ts": { "marked": 0, "identical": 1 },
|
||||
"settings.ts": { "marked": 0, "identical": 8 },
|
||||
"storage.ts": { "marked": 0, "identical": 1 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 2 },
|
||||
"undo.ts": { "marked": 0, "identical": 1 },
|
||||
"vacay.ts": { "marked": 0, "identical": 5 }
|
||||
"admin.ts": { "marked": 0, "identical": 1 },
|
||||
"settings.ts": { "marked": 0, "identical": 1 }
|
||||
},
|
||||
"zh": {
|
||||
"admin.ts": { "marked": 0, "identical": 3 },
|
||||
"help.ts": { "marked": 0, "identical": 1 },
|
||||
"journey.ts": { "marked": 110, "identical": 1 },
|
||||
"settings.ts": { "marked": 0, "identical": 9 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 3 },
|
||||
"vacay.ts": { "marked": 0, "identical": 5 }
|
||||
"admin.ts": { "marked": 0, "identical": 1 },
|
||||
"settings.ts": { "marked": 0, "identical": 1 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 1 }
|
||||
},
|
||||
"zh-TW": {
|
||||
"admin.ts": { "marked": 0, "identical": 18 },
|
||||
"common.ts": { "marked": 12, "identical": 0 },
|
||||
"dayplan.ts": { "marked": 2, "identical": 0 },
|
||||
"help.ts": { "marked": 0, "identical": 3 },
|
||||
"journey.ts": { "marked": 115, "identical": 1 },
|
||||
"login.ts": { "marked": 0, "identical": 3 },
|
||||
"nav.ts": { "marked": 0, "identical": 1 },
|
||||
"oauth.ts": { "marked": 17, "identical": 1 },
|
||||
"settings.ts": { "marked": 0, "identical": 76 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 5 },
|
||||
"undo.ts": { "marked": 0, "identical": 1 },
|
||||
"vacay.ts": { "marked": 0, "identical": 5 }
|
||||
"admin.ts": { "marked": 0, "identical": 1 },
|
||||
"settings.ts": { "marked": 0, "identical": 1 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 3 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,8 @@ export const BRAND_NAMES = [
|
||||
'OpenRouter',
|
||||
'Organic Maps',
|
||||
'Polaroid',
|
||||
// The photo book editor's name, like Atlas and Vacay for their add-ons.
|
||||
'Studio',
|
||||
'Synology',
|
||||
'Synology Photos',
|
||||
'Vacay',
|
||||
|
||||
@@ -264,6 +264,23 @@ describe('a mark set as text on a page laid out again', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('the page background a saved layout brings', () => {
|
||||
it("BOOKLAY-030: replaces the page's colour and its link to a theme colour, so a theme cannot paint over it", () => {
|
||||
const from = bookSpreadSchema.parse({ id: 'a', role: 'inner', background: '#123456', elements: [] });
|
||||
const layout = bookLayoutFromSpread(from, page, 'Navy');
|
||||
const linked = bookSpreadSchema.parse({
|
||||
id: 'b',
|
||||
role: 'inner',
|
||||
background: '#ffffff',
|
||||
backgroundToken: 'paper',
|
||||
elements: [],
|
||||
});
|
||||
const next = applySavedBookLayout(linked, layout, page);
|
||||
expect(next.background).toBe('#123456');
|
||||
expect(next).not.toHaveProperty('backgroundToken');
|
||||
});
|
||||
});
|
||||
|
||||
describe('letters filled with a photo in a saved layout', () => {
|
||||
const frame = { x: 10, y: 10, w: 80, h: 40 };
|
||||
const filledWith = (photoId: number) => ({ type: 'photo' as const, photoId, focalX: 0.5, focalY: 0.5, zoom: 1 });
|
||||
|
||||
@@ -145,7 +145,9 @@ export function applySavedBookLayout(spread: BookSpread, layout: BookLayout, pag
|
||||
const used = new Set(wordsFor.values());
|
||||
const parked = [...photos.slice(pi), ...texts.filter((t) => !used.has(t))].slice(0, MAX_SPREAD_ELEMENTS);
|
||||
|
||||
const { backgroundPaint: _old, ...rest } = spread;
|
||||
// The layout's background replaces the page's, so the page's link to a theme
|
||||
// colour goes with it: kept, the next theme would paint over the layout's colour.
|
||||
const { backgroundPaint: _old, backgroundToken: _unlinked, ...rest } = spread;
|
||||
const backgroundPaint = layout.backgroundPaint ? { backgroundPaint: layout.backgroundPaint } : {};
|
||||
return { ...rest, background: layout.background, ...backgroundPaint, elements: out, parked };
|
||||
}
|
||||
|
||||
@@ -79,31 +79,40 @@ export const FOOD_STICKERS: readonly BookStickerDef[] = [
|
||||
id: 'croissant',
|
||||
theme: 'food',
|
||||
tags: ['croissant', 'pastry', 'bakery', 'france', 'paris', 'breakfast', 'boulangerie', 'cafe'],
|
||||
descr: 'A golden, flaky croissant curved into a crescent, its rolled layers shaded darker at each fold.',
|
||||
descr: 'A golden croissant curved into a crescent, its rolled layers marked by darker folds and a baked underside.',
|
||||
box: [110, 70],
|
||||
slots: [
|
||||
{ name: 'pastry', color: '#e2a04a' },
|
||||
{ name: 'crust', color: '#b86b24' },
|
||||
],
|
||||
layers: [
|
||||
{ p: 0, d: 'M8 50C4 44 8 36 16 36C18 40 22 44 28 46C26 52 18 56 12 54Z' },
|
||||
{ p: 0, d: 'M102 50C106 44 102 36 94 36C92 40 88 44 82 46C84 52 92 56 98 54Z' },
|
||||
{ p: 0, d: 'M18 34C20 24 28 18 36 18C38 26 40 34 40 46C32 48 24 44 18 34Z' },
|
||||
{ p: 0, d: 'M92 34C90 24 82 18 74 18C72 26 70 34 70 46C78 48 86 44 92 34Z' },
|
||||
{ p: 0, d: 'M36 16C42 8 50 6 55 6C60 6 68 8 74 16C72 28 70 40 68 50C60 54 50 54 42 50C40 40 38 28 36 16Z' },
|
||||
// One closed crescent, so no seam between two pieces can let the page show through.
|
||||
{
|
||||
p: 1,
|
||||
d: 'M28 46C24 44 20 40 16 36M40 46C40 34 38 26 36 18M70 46C70 34 72 26 74 18M82 46C86 44 90 40 94 36',
|
||||
o: 0.8,
|
||||
w: 1.6,
|
||||
p: 0,
|
||||
d: 'M6 54C3 46 10 38 20 34C27 21 39 11 55 10C71 11 83 21 90 34C100 38 107 46 104 54C98 57 92 55 86 50C78 54 66 56 55 56C44 56 32 54 24 50C18 55 12 57 6 54Z',
|
||||
},
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M8 51C14 53 19 51 24 47C32 51 44 53 55 53C66 53 78 51 86 47C91 51 96 53 102 51L104 54C98 57 92 55 86 50C78 54 66 56 55 56C44 56 32 54 24 50C18 55 12 57 6 54Z',
|
||||
o: 0.22,
|
||||
},
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M20 34C26 36 30 40 32 52C28 52 26 51 24 50C21 46 20 40 20 34ZM90 34C84 36 80 40 78 52C82 52 84 51 86 50C89 46 90 40 90 34Z',
|
||||
o: 0.12,
|
||||
},
|
||||
{
|
||||
p: 1,
|
||||
d: 'M18 34C24 44 32 48 40 46L42 50C32 52 22 46 18 34ZM92 34C86 44 78 48 70 46L68 50C78 52 88 46 92 34ZM8 50C10 54 14 55 18 54L12 54C10 53 9 52 8 50Z',
|
||||
o: 0.6,
|
||||
d: 'M23 49C19 44 18.5 38.5 21 34M39 54C35 44 34.5 28 38.5 14.5M71 54C75 44 75.5 28 71.5 14.5M87 49C91 44 91.5 38.5 89 34',
|
||||
o: 0.85,
|
||||
w: 2,
|
||||
},
|
||||
{
|
||||
p: 'light',
|
||||
d: 'M47 15.5C50.5 13 59.5 13 63 15.5M26.5 31C28.5 27 31.5 24 35 22M75 22C78.5 24 81.5 27 83.5 31M9.5 46C10.5 43 13 40.5 16 39M94 39C97 40.5 99.5 43 100.5 46',
|
||||
o: 0.5,
|
||||
w: 1.8,
|
||||
},
|
||||
{ p: 'shade', d: 'M42 50C50 54 60 54 68 50L68 52C60 57 50 57 42 52Z', o: 0.2 },
|
||||
{ p: 'light', d: 'M44 14C48 10 54 9 58 9M24 28C26 24 30 21 34 20M78 22C82 23 86 26 88 30', o: 0.45, w: 1.6 },
|
||||
],
|
||||
license: 'in-house',
|
||||
},
|
||||
@@ -122,7 +131,7 @@ export const FOOD_STICKERS: readonly BookStickerDef[] = [
|
||||
{ p: 0, d: 'M8 28L18 62C20 66 24 68 30 68L70 68C76 68 80 66 82 62L92 28Z' },
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M14 30L22 66M23 30L29.4 66M32 30L36.8 66M41 30L44.2 66M50 30L51.6 66M59 30L59 66M68 30L66.4 66M77 30L73.8 66M86 30L81.2 66',
|
||||
d: 'M14 30L21.2 62M23 30L29.4 66M32 30L36.8 66M41 30L44.2 66M50 30L51.6 66M59 30L59 66M68 30L66.4 66M77 30L73.8 66M86 30L81.8 62',
|
||||
o: 0.18,
|
||||
w: 1,
|
||||
},
|
||||
@@ -395,6 +404,8 @@ export const FOOD_STICKERS: readonly BookStickerDef[] = [
|
||||
],
|
||||
layers: [
|
||||
{ p: '#c9a77c', d: 'M22 8L98 36M30 4L102 30', w: 2.4 },
|
||||
// The bowl's rim behind the broth, and its front edge reaching over the broth below.
|
||||
{ p: 0, d: 'M102 46C102 53.2 81 59 55 59C29 59 8 53.2 8 46C8 38.8 29 33 55 33C81 33 102 38.8 102 46Z' },
|
||||
{ p: 1, d: 'M101 46C101 52.6 80.4 58 55 58C29.6 58 9 52.6 9 46C9 39.4 29.6 34 55 34C80.4 34 101 39.4 101 46Z' },
|
||||
{
|
||||
p: '#f3e3b8',
|
||||
@@ -424,7 +435,7 @@ export const FOOD_STICKERS: readonly BookStickerDef[] = [
|
||||
d: 'M21.6 50C21.6 50.9 20.9 51.6 20 51.6C19.1 51.6 18.4 50.9 18.4 50C18.4 49.1 19.1 48.4 20 48.4C20.9 48.4 21.6 49.1 21.6 50ZM20.7 50C20.7 50.4 20.4 50.7 20 50.7C19.6 50.7 19.3 50.4 19.3 50C19.3 49.6 19.6 49.3 20 49.3C20.4 49.3 20.7 49.6 20.7 50ZM25.6 48C25.6 48.9 24.9 49.6 24 49.6C23.1 49.6 22.4 48.9 22.4 48C22.4 47.1 23.1 46.4 24 46.4C24.9 46.4 25.6 47.1 25.6 48ZM24.7 48C24.7 48.4 24.4 48.7 24 48.7C23.6 48.7 23.3 48.4 23.3 48C23.3 47.6 23.6 47.3 24 47.3C24.4 47.3 24.7 47.6 24.7 48ZM53.6 52C53.6 52.9 52.9 53.6 52 53.6C51.1 53.6 50.4 52.9 50.4 52C50.4 51.1 51.1 50.4 52 50.4C52.9 50.4 53.6 51.1 53.6 52ZM52.7 52C52.7 52.4 52.4 52.7 52 52.7C51.6 52.7 51.3 52.4 51.3 52C51.3 51.6 51.6 51.3 52 51.3C52.4 51.3 52.7 51.6 52.7 52ZM87.6 44C87.6 44.9 86.9 45.6 86 45.6C85.1 45.6 84.4 44.9 84.4 44C84.4 43.1 85.1 42.4 86 42.4C86.9 42.4 87.6 43.1 87.6 44ZM86.7 44C86.7 44.4 86.4 44.7 86 44.7C85.6 44.7 85.3 44.4 85.3 44C85.3 43.6 85.6 43.3 86 43.3C86.4 43.3 86.7 43.6 86.7 44ZM91.6 48C91.6 48.9 90.9 49.6 90 49.6C89.1 49.6 88.4 48.9 88.4 48C88.4 47.1 89.1 46.4 90 46.4C90.9 46.4 91.6 47.1 91.6 48ZM90.7 48C90.7 48.4 90.4 48.7 90 48.7C89.6 48.7 89.3 48.4 89.3 48C89.3 47.6 89.6 47.3 90 47.3C90.4 47.3 90.7 47.6 90.7 48ZM57.6 40C57.6 40.9 56.9 41.6 56 41.6C55.1 41.6 54.4 40.9 54.4 40C54.4 39.1 55.1 38.4 56 38.4C56.9 38.4 57.6 39.1 57.6 40ZM56.7 40C56.7 40.4 56.4 40.7 56 40.7C55.6 40.7 55.3 40.4 55.3 40C55.3 39.6 55.6 39.3 56 39.3C56.4 39.3 56.7 39.6 56.7 40Z',
|
||||
eo: 1,
|
||||
},
|
||||
{ p: 0, d: 'M8 46C8 72 30 86 55 86C80 86 102 72 102 46C96 54 80 60 55 60C30 60 14 54 8 46Z' },
|
||||
{ p: 0, d: 'M8 46C8 72 30 86 55 86C80 86 102 72 102 46C96 53 80 57 55 57C30 57 14 53 8 46Z' },
|
||||
{ p: 'shade', d: 'M72 59C84 56 98 52 102 46C102 72 80 86 55 86C74 80 78 70 72 59Z', o: 0.18 },
|
||||
{
|
||||
p: '#fbf8f0',
|
||||
|
||||
+30
-31
@@ -338,17 +338,17 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.uninstallBody':
|
||||
'سيؤدي هذا إلى إيقاف الإضافة وإزالة تعليماتها البرمجية وحذف جميع بياناتها. لا يمكن التراجع عن هذا الإجراء.',
|
||||
'admin.plugins.status.starting': 'جارٍ البدء…',
|
||||
'admin.plugins.type.widget': 'Widget',
|
||||
'admin.plugins.type.widget': 'أداة مصغّرة',
|
||||
'admin.plugins.type.page': 'صفحة',
|
||||
'admin.plugins.type.integration': 'تكامل',
|
||||
'admin.plugins.type.trip-page': 'صفحة الرحلة',
|
||||
'admin.plugins.reviewed': 'تمت مراجعتها',
|
||||
'admin.plugins.unreviewed': 'لم تتم مراجعتها',
|
||||
'admin.plugins.updated': 'تم تحديث الإضافة',
|
||||
'admin.plugins.updateTo': 'Update → v{version}',
|
||||
'admin.plugins.updateTo': 'تحديث ← v{version}',
|
||||
'admin.plugins.enabledToggle': 'تفعيل الإضافة',
|
||||
'plugins.notFound': 'الإضافة غير موجودة',
|
||||
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
|
||||
'plugins.frameLoadFailed': 'تعذّر تحميل هذه الإضافة.',
|
||||
'admin.plugins.title': 'الإضافات الخارجية',
|
||||
'admin.plugins.subtitle': 'ثبّت وأدر إضافات الجهات الخارجية على خادمك.',
|
||||
'admin.plugins.disabledTitle': 'الإضافات الخارجية معطّلة',
|
||||
@@ -680,7 +680,7 @@ const admin: TranslationStrings = {
|
||||
'admin.addons.catalog.mcp.description': 'بروتوكول سياق النموذج لتكامل مساعد الذكاء الاصطناعي',
|
||||
'admin.addons.subtitleBefore': 'فعّل أو عطّل الميزات لتخصيص تجربة ',
|
||||
'admin.addons.subtitleAfter': '.',
|
||||
'admin.addons.catalog.naver_list_import.name': 'Naver List Import',
|
||||
'admin.addons.catalog.naver_list_import.name': 'استيراد قوائم Naver',
|
||||
'admin.addons.catalog.naver_list_import.description': 'استيراد الأماكن من قائمة Naver Maps مشتركة',
|
||||
'admin.addons.catalog.airtrail.name': 'AirTrail',
|
||||
'admin.addons.catalog.airtrail.description': 'مزامنة الرحلات من خادم AirTrail الخاص بك',
|
||||
@@ -819,33 +819,32 @@ const admin: TranslationStrings = {
|
||||
'admin.update.wikiLink': 'فتح دليل التحديث',
|
||||
'admin.update.reloadHint': 'يرجى إعادة تحميل الصفحة بعد بضع ثوانٍ.',
|
||||
'admin.tabs.permissions': 'الصلاحيات',
|
||||
'admin.notifications.webhook': 'Webhook', // en-fallback
|
||||
'admin.notifications.ntfy': 'Ntfy', // en-fallback
|
||||
'admin.notifications.emailPanel.title': 'Email (SMTP)', // en-fallback
|
||||
'admin.notifications.webhookPanel.title': 'Webhook', // en-fallback
|
||||
'admin.notifications.webhook': 'Webhook',
|
||||
'admin.notifications.ntfy': 'Ntfy',
|
||||
'admin.notifications.emailPanel.title': 'البريد الإلكتروني (SMTP)',
|
||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||
'admin.notifications.webPushPanel.title': 'إشعارات الويب الفورية',
|
||||
'admin.notifications.webPushPanel.hint':
|
||||
'تتيح للمستخدمين تلقي الإشعارات على هواتفهم وحواسيبهم عبر المتصفح، حتى عندما يكون TREK مغلقًا. يتطلب HTTPS؛ وعلى iPhone وiPad يجب إضافة TREK إلى الشاشة الرئيسية.',
|
||||
'admin.notifications.inappPanel.title': 'In-App', // en-fallback
|
||||
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh', // en-fallback
|
||||
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts', // en-fallback
|
||||
'admin.authMethods': 'Authentication Methods', // en-fallback
|
||||
'admin.passwordLogin': 'Password Login', // en-fallback
|
||||
'admin.passwordLoginHint': 'Allow users to sign in with email and password', // en-fallback
|
||||
'admin.passwordRegistration': 'Password Registration', // en-fallback
|
||||
'admin.passwordRegistrationHint': 'Allow new users to register with email and password', // en-fallback
|
||||
'admin.oidcLogin': 'SSO Login', // en-fallback
|
||||
'admin.oidcLoginHint': 'Allow users to sign in with SSO', // en-fallback
|
||||
'admin.oidcRegistration': 'SSO Auto-Provisioning', // en-fallback
|
||||
'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users', // en-fallback
|
||||
'admin.notifications.inappPanel.title': 'داخل التطبيق',
|
||||
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||
'admin.authMethods': 'طرق المصادقة',
|
||||
'admin.passwordLogin': 'تسجيل الدخول بكلمة المرور',
|
||||
'admin.passwordLoginHint': 'السماح للمستخدمين بتسجيل الدخول بالبريد الإلكتروني وكلمة المرور',
|
||||
'admin.passwordRegistration': 'التسجيل بكلمة المرور',
|
||||
'admin.passwordRegistrationHint': 'السماح للمستخدمين الجدد بالتسجيل بالبريد الإلكتروني وكلمة المرور',
|
||||
'admin.oidcLogin': 'تسجيل الدخول عبر SSO',
|
||||
'admin.oidcLoginHint': 'السماح للمستخدمين بتسجيل الدخول عبر SSO',
|
||||
'admin.oidcRegistration': 'إنشاء الحسابات تلقائيًا عبر SSO',
|
||||
'admin.oidcRegistrationHint': 'إنشاء حسابات تلقائيًا للمستخدمين الجدد عبر SSO',
|
||||
'admin.envOverrideHint':
|
||||
'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.', // en-fallback
|
||||
'admin.lockoutWarning': 'At least one login method must remain enabled', // en-fallback
|
||||
'admin.addons.catalog.mcp.name': 'MCP', // en-fallback
|
||||
'admin.tabs.github': 'GitHub', // en-fallback
|
||||
'admin.addons.catalog.journey.name': 'Journey', // en-fallback
|
||||
'admin.addons.catalog.journey.description':
|
||||
'Trip tracking & travel journal with check-ins, photos, and daily stories', // en-fallback
|
||||
'يتم التحكم في إعدادات تسجيل الدخول بكلمة المرور عبر متغير البيئة OIDC_ONLY ولا يمكن تغييرها من هنا.',
|
||||
'admin.lockoutWarning': 'يجب أن تبقى طريقة تسجيل دخول واحدة على الأقل مفعّلة',
|
||||
'admin.addons.catalog.mcp.name': 'MCP',
|
||||
'admin.tabs.github': 'GitHub',
|
||||
'admin.addons.catalog.journey.name': 'يوميات',
|
||||
'admin.addons.catalog.journey.description': 'تتبّع الرحلات ويوميات سفر مع تسجيلات الوصول والصور وقصص كل يوم',
|
||||
'admin.addons.catalog.collections.name': 'المجموعات',
|
||||
'admin.addons.catalog.collections.description': 'اجمع الأماكن من أي رحلة في قوائم مسماة وأعد استخدامها',
|
||||
'admin.passkey.title': 'تسجيل الدخول بمفتاح المرور',
|
||||
@@ -886,10 +885,10 @@ const admin: TranslationStrings = {
|
||||
'admin.defaultSettings.cartoKey': 'مفتاح CARTO المشترك',
|
||||
'admin.defaultSettings.cartoKeyHint':
|
||||
'يُستخدم لكل مستخدم لم يُدخل مفتاحه الخاص، حتى يحصل الخادم بأكمله على بلاطات CARTO بدون علامة مائية. يُخزَّن مشفّرًا.',
|
||||
'admin.group.users': 'Users',
|
||||
'admin.group.config': 'Configuration',
|
||||
'admin.group.integration': 'Integrations',
|
||||
'admin.group.maintenance': 'Maintenance',
|
||||
'admin.group.users': 'المستخدمون',
|
||||
'admin.group.config': 'الإعدادات',
|
||||
'admin.group.integration': 'التكاملات',
|
||||
'admin.group.maintenance': 'الصيانة',
|
||||
'admin.invite.tripLabel': 'إضافة إلى رحلة (اختياري)',
|
||||
'admin.invite.tripNone': 'بدون رحلة',
|
||||
'admin.invite.tripHint': 'تتم إضافة المستخدم الجديد تلقائيًا إلى هذه الرحلة عند تسجيله عبر الرابط.',
|
||||
|
||||
@@ -67,7 +67,7 @@ const backup: TranslationStrings = {
|
||||
'سيتم استبدال جميع البيانات الحالية (الرحلات، الأماكن، المستخدمون، المرفوعات) بالنسخة نهائيًا. لا يمكن التراجع عن ذلك.',
|
||||
'backup.restoreTip': 'نصيحة: أنشئ نسخة احتياطية للحالة الحالية قبل الاستعادة.',
|
||||
'backup.restoreConfirm': 'نعم، استعادة',
|
||||
'backup.auto.envLocked': 'Docker', // en-fallback
|
||||
'backup.auto.envLocked': 'Docker',
|
||||
'backup.auto.hourHintTz': 'التوقيت المحلي للخادم (تنسيق {format})، المنطقة الزمنية {timezone}',
|
||||
};
|
||||
export default backup;
|
||||
|
||||
@@ -115,7 +115,7 @@ const collab: TranslationStrings = {
|
||||
'collab.polls.options': 'الخيارات',
|
||||
'collab.polls.delete': 'حذف',
|
||||
'collab.polls.closedSection': 'مغلق',
|
||||
'collab.notes.websitePlaceholder': 'https://...', // en-fallback
|
||||
'collab.notes.websitePlaceholder': 'https://...',
|
||||
'collab.chat.imageLimit': 'يمكنك إرفاق ما يصل إلى {count} صورة لكل رسالة',
|
||||
'collab.chat.imageLimit.zero': 'يمكنك إرفاق ما يصل إلى {count} صورة لكل رسالة',
|
||||
'collab.chat.imageLimit.one': 'يمكنك إرفاق صورة واحدة فقط لكل رسالة',
|
||||
|
||||
@@ -53,7 +53,7 @@ const common: TranslationStrings = {
|
||||
'common.collapse': 'طي',
|
||||
'common.copy': 'نسخ',
|
||||
'common.copied': 'تم النسخ',
|
||||
'common.justNow': 'just now', // en-fallback
|
||||
'common.justNow': 'الآن',
|
||||
'common.hoursAgo': 'منذ {count} ساعة',
|
||||
'common.hoursAgo.zero': 'منذ {count} ساعة',
|
||||
'common.hoursAgo.one': 'منذ ساعة واحدة',
|
||||
@@ -66,18 +66,18 @@ const common: TranslationStrings = {
|
||||
'common.daysAgo.two': 'منذ يومين',
|
||||
'common.daysAgo.few': 'منذ {count} أيام',
|
||||
'common.daysAgo.many': 'منذ {count} يومًا',
|
||||
'common.datepicker.prevMonth': 'Previous month', // en-fallback
|
||||
'common.datepicker.nextMonth': 'Next month', // en-fallback
|
||||
'common.datepicker.prevYear': 'Previous year', // en-fallback
|
||||
'common.datepicker.nextYear': 'Next year', // en-fallback
|
||||
'common.datepicker.prevYears': 'Previous years', // en-fallback
|
||||
'common.datepicker.nextYears': 'Next years', // en-fallback
|
||||
'common.datepicker.selectMonth': 'Select month', // en-fallback
|
||||
'common.datepicker.selectYear': 'Select year', // en-fallback
|
||||
'common.datepicker.enterManually': 'Enter date manually', // en-fallback
|
||||
'common.datepicker.typeDate': 'Type a date', // en-fallback
|
||||
'common.datepicker.dialog': 'Date picker', // en-fallback
|
||||
'common.datepicker.clearDate': 'Clear date', // en-fallback
|
||||
'common.datepicker.prevMonth': 'الشهر السابق',
|
||||
'common.datepicker.nextMonth': 'الشهر التالي',
|
||||
'common.datepicker.prevYear': 'السنة السابقة',
|
||||
'common.datepicker.nextYear': 'السنة التالية',
|
||||
'common.datepicker.prevYears': 'السنوات السابقة',
|
||||
'common.datepicker.nextYears': 'السنوات التالية',
|
||||
'common.datepicker.selectMonth': 'اختر الشهر',
|
||||
'common.datepicker.selectYear': 'اختر السنة',
|
||||
'common.datepicker.enterManually': 'أدخل التاريخ يدويًا',
|
||||
'common.datepicker.typeDate': 'اكتب تاريخًا',
|
||||
'common.datepicker.dialog': 'منتقي التاريخ',
|
||||
'common.datepicker.clearDate': 'مسح التاريخ',
|
||||
'common.errorTitle': 'حدث خطأ ما',
|
||||
'common.errorBody': 'توقف هذا الجزء من التطبيق عن العمل. بياناتك آمنة.',
|
||||
'common.errorPanelTitle': 'تعذّر عرض هذا القسم',
|
||||
|
||||
@@ -68,19 +68,19 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.gpxEmpty': 'لا يوجد شيء للتصدير بعد',
|
||||
'dayplan.gpxFailed': 'فشل تصدير GPX',
|
||||
'dayplan.pdfError': 'فشل تصدير PDF',
|
||||
'dayplan.expandAll': 'توسيع كل الأيام', // en-fallback
|
||||
'dayplan.collapseAll': 'طي كل الأيام', // en-fallback
|
||||
'dayplan.expandAll': 'توسيع كل الأيام',
|
||||
'dayplan.collapseAll': 'طي كل الأيام',
|
||||
'dayplan.export': 'تصدير',
|
||||
'dayplan.exportIntro': 'خذ خطتك معك: كمستند أو في تقويمك أو على جهاز GPS.',
|
||||
'dayplan.exportDocument': 'مستند',
|
||||
'dayplan.exportCalendar': 'التقويم',
|
||||
'dayplan.exportMaps': 'الخرائط وGPS',
|
||||
'dayplan.pdf': 'PDF', // en-fallback
|
||||
'dayplan.mobile.addPlace': 'Add Place', // en-fallback
|
||||
'dayplan.mobile.searchPlaces': 'Search places...', // en-fallback
|
||||
'dayplan.mobile.allAssigned': 'All places assigned', // en-fallback
|
||||
'dayplan.mobile.noMatch': 'No match', // en-fallback
|
||||
'dayplan.mobile.createNew': 'Create new place', // en-fallback
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.mobile.addPlace': 'إضافة مكان',
|
||||
'dayplan.mobile.searchPlaces': 'ابحث عن أماكن...',
|
||||
'dayplan.mobile.allAssigned': 'تم تعيين كل الأماكن',
|
||||
'dayplan.mobile.noMatch': 'لا توجد نتائج',
|
||||
'dayplan.mobile.createNew': 'إنشاء مكان جديد',
|
||||
'dayplan.reorderDays': 'إعادة ترتيب الأيام',
|
||||
'dayplan.reorderTitle': 'إعادة ترتيب الأيام',
|
||||
'dayplan.reorderHint': 'تنتقل أماكن اليوم وملاحظاته وحجوزاته معه.',
|
||||
|
||||
@@ -666,7 +666,7 @@ const help: TranslationStrings = {
|
||||
'help.guide.all-saved.tip.1': 'التسميات والجولات المحفوظة خاصة بكل قائمة، لذا لا يتاح أي منهما في «كل المحفوظات».',
|
||||
|
||||
// ── Screen: journey ───────────────────────────────────────────────────────────────────
|
||||
'help.ctx.journey.title': 'Journey',
|
||||
'help.ctx.journey.title': 'يوميات',
|
||||
'help.ctx.journey.summary':
|
||||
'Journey هو دفتر يوميات سفرك، والصور فيه أولًا. كل يوميات مرتبطة برحلة واحدة أو أكثر، وتملؤها يومًا بعد يوم بمدخلات فيها قصة وصور ومزاج وطقس. تعرض هذه الشاشة يومياتك، فافتح إحداها لتكتب.',
|
||||
'help.ctx.journey.bullet.1':
|
||||
@@ -871,7 +871,7 @@ const help: TranslationStrings = {
|
||||
'help.guide.journey-status.tip.2': 'الحالة التي تضبطها يدويًا تبقى إلى أن تغيّرها، حتى لو تغيّرت تواريخ الرحلة.',
|
||||
|
||||
// ── Screen: journey-studio ────────────────────────────────────────────────────────────
|
||||
'help.ctx.journey-studio.title': 'Studio',
|
||||
'help.ctx.journey-studio.title': 'الاستوديو',
|
||||
'help.ctx.journey-studio.summary':
|
||||
'يحوّل TREK Studio اليوميات إلى كتاب صور قابل للطباعة. يُفتح فوق اليوميات: قائمة الصفحات والمحتوى على اليسار، والصفحتان المتقابلتان اللتان تعمل عليهما في الوسط، وخصائصهما على اليمين. ينشئ Auto layout مسودة أولى من مدخلاتك، وبعدها يمكنك تحريك كل شيء واقتصاصه وإعادة تنسيقه، والتراجع عن أي خطوة.',
|
||||
'help.ctx.journey-studio.bullet.1':
|
||||
|
||||
+272
-270
@@ -71,93 +71,93 @@ const journey: TranslationStrings = {
|
||||
'journey.picker.noAlbums': 'لم يتم العثور على ألبومات',
|
||||
'journey.picker.selectDate': 'اختر تاريخ',
|
||||
'journey.picker.search': 'بحث',
|
||||
'journey.title': 'Journey', // en-fallback
|
||||
'journey.subtitle': 'Track your travels as they happen', // en-fallback
|
||||
'journey.new': 'New Journey', // en-fallback
|
||||
'journey.title': 'يوميات',
|
||||
'journey.subtitle': 'وثّق رحلاتك أولًا بأول',
|
||||
'journey.new': 'يوميات جديدة',
|
||||
'journey.lightbox.zoomIn': 'تكبير',
|
||||
'journey.lightbox.zoomOut': 'تصغير',
|
||||
'journey.lightbox.zoomReset': 'العودة إلى الصورة كاملة',
|
||||
'journey.lightbox.previous': 'الصورة السابقة',
|
||||
'journey.lightbox.next': 'الصورة التالية',
|
||||
'journey.create': 'Create', // en-fallback
|
||||
'journey.titlePlaceholder': 'Where are you going?', // en-fallback
|
||||
'journey.empty': 'No journeys yet', // en-fallback
|
||||
'journey.emptyHint': 'Start documenting your next trip', // en-fallback
|
||||
'journey.deleted': 'Journey deleted', // en-fallback
|
||||
'journey.createError': 'Could not create journey', // en-fallback
|
||||
'journey.deleteError': 'Could not delete journey', // en-fallback
|
||||
'journey.deleteConfirmTitle': 'Delete', // en-fallback
|
||||
'journey.deleteConfirmMessage': 'Delete "{title}"? This cannot be undone.', // en-fallback
|
||||
'journey.deleteConfirmGeneric': 'Are you sure you want to delete this?', // en-fallback
|
||||
'journey.notFound': 'Journey not found', // en-fallback
|
||||
'journey.photos': 'Photos', // en-fallback
|
||||
'journey.timelineEmpty': 'No stops yet', // en-fallback
|
||||
'journey.timelineEmptyHint': 'Add a check-in or write a journal entry to get started', // en-fallback
|
||||
'journey.status.draft': 'Draft', // en-fallback
|
||||
'journey.status.active': 'Active', // en-fallback
|
||||
'journey.status.completed': 'Completed', // en-fallback
|
||||
'journey.status.upcoming': 'Upcoming', // en-fallback
|
||||
'journey.checkin.add': 'Check in', // en-fallback
|
||||
'journey.checkin.namePlaceholder': 'Location name', // en-fallback
|
||||
'journey.checkin.notesPlaceholder': 'Notes (optional)', // en-fallback
|
||||
'journey.checkin.save': 'Save', // en-fallback
|
||||
'journey.checkin.error': 'Could not save check-in', // en-fallback
|
||||
'journey.entry.add': 'Journal', // en-fallback
|
||||
'journey.entry.edit': 'Edit entry', // en-fallback
|
||||
'journey.entry.titlePlaceholder': 'Title (optional)', // en-fallback
|
||||
'journey.entry.bodyPlaceholder': 'What happened today?', // en-fallback
|
||||
'journey.entry.save': 'Save', // en-fallback
|
||||
'journey.entry.error': 'Could not save entry', // en-fallback
|
||||
'journey.photo.add': 'Photo', // en-fallback
|
||||
'journey.photo.uploadError': 'Upload failed', // en-fallback
|
||||
'journey.share.share': 'Share', // en-fallback
|
||||
'journey.share.public': 'Public', // en-fallback
|
||||
'journey.share.linkCopied': 'Public link copied', // en-fallback
|
||||
'journey.share.disabled': 'Public sharing disabled', // en-fallback
|
||||
'journey.editor.titlePlaceholder': 'Give this moment a name...', // en-fallback
|
||||
'journey.editor.bodyPlaceholder': 'Tell the story of this day...', // en-fallback
|
||||
'journey.editor.placePlaceholder': 'Location (optional)', // en-fallback
|
||||
'journey.editor.tagsPlaceholder': 'Tags: hidden gem, best meal, must revisit...', // en-fallback
|
||||
'journey.visibility.private': 'Private', // en-fallback
|
||||
'journey.visibility.shared': 'Shared', // en-fallback
|
||||
'journey.visibility.public': 'Public', // en-fallback
|
||||
'journey.emptyState.title': 'Your story starts here', // en-fallback
|
||||
'journey.emptyState.subtitle': 'Check in at a place or write your first journal entry', // en-fallback
|
||||
'journey.frontpage.subtitle': "Turn your trips into stories you'll never forget", // en-fallback
|
||||
'journey.frontpage.createJourney': 'Create Journey', // en-fallback
|
||||
'journey.frontpage.activeJourney': 'Active Journey', // en-fallback
|
||||
'journey.create': 'إنشاء',
|
||||
'journey.titlePlaceholder': 'إلى أين أنت ذاهب؟',
|
||||
'journey.empty': 'لا توجد يوميات بعد',
|
||||
'journey.emptyHint': 'ابدأ بتوثيق رحلتك القادمة',
|
||||
'journey.deleted': 'تم حذف اليوميات',
|
||||
'journey.createError': 'تعذّر إنشاء اليوميات',
|
||||
'journey.deleteError': 'تعذّر حذف اليوميات',
|
||||
'journey.deleteConfirmTitle': 'حذف',
|
||||
'journey.deleteConfirmMessage': 'هل تريد حذف "{title}"؟ لا يمكن التراجع عن ذلك.',
|
||||
'journey.deleteConfirmGeneric': 'هل أنت متأكد أنك تريد حذف هذا؟',
|
||||
'journey.notFound': 'لم يتم العثور على اليوميات',
|
||||
'journey.photos': 'الصور',
|
||||
'journey.timelineEmpty': 'لا توجد محطات بعد',
|
||||
'journey.timelineEmptyHint': 'أضف تسجيل وصول أو اكتب تدوينة للبدء',
|
||||
'journey.status.draft': 'مسودة',
|
||||
'journey.status.active': 'نشطة',
|
||||
'journey.status.completed': 'مكتملة',
|
||||
'journey.status.upcoming': 'قادمة',
|
||||
'journey.checkin.add': 'تسجيل وصول',
|
||||
'journey.checkin.namePlaceholder': 'اسم الموقع',
|
||||
'journey.checkin.notesPlaceholder': 'ملاحظات (اختياري)',
|
||||
'journey.checkin.save': 'حفظ',
|
||||
'journey.checkin.error': 'تعذّر حفظ تسجيل الوصول',
|
||||
'journey.entry.add': 'تدوينة',
|
||||
'journey.entry.edit': 'تعديل التدوينة',
|
||||
'journey.entry.titlePlaceholder': 'العنوان (اختياري)',
|
||||
'journey.entry.bodyPlaceholder': 'ماذا حدث اليوم؟',
|
||||
'journey.entry.save': 'حفظ',
|
||||
'journey.entry.error': 'تعذّر حفظ التدوينة',
|
||||
'journey.photo.add': 'صورة',
|
||||
'journey.photo.uploadError': 'فشل الرفع',
|
||||
'journey.share.share': 'مشاركة',
|
||||
'journey.share.public': 'عام',
|
||||
'journey.share.linkCopied': 'تم نسخ الرابط العام',
|
||||
'journey.share.disabled': 'تم تعطيل المشاركة العامة',
|
||||
'journey.editor.titlePlaceholder': 'أعطِ هذه اللحظة اسمًا...',
|
||||
'journey.editor.bodyPlaceholder': 'احكِ قصة هذا اليوم...',
|
||||
'journey.editor.placePlaceholder': 'الموقع (اختياري)',
|
||||
'journey.editor.tagsPlaceholder': 'وسوم: جوهرة خفية، أفضل وجبة، يستحق العودة...',
|
||||
'journey.visibility.private': 'خاص',
|
||||
'journey.visibility.shared': 'مشترك',
|
||||
'journey.visibility.public': 'عام',
|
||||
'journey.emptyState.title': 'قصتك تبدأ هنا',
|
||||
'journey.emptyState.subtitle': 'سجّل وصولك إلى مكان أو اكتب أول تدوينة',
|
||||
'journey.frontpage.subtitle': 'حوّل رحلاتك إلى قصص لا تُنسى',
|
||||
'journey.frontpage.createJourney': 'إنشاء يوميات',
|
||||
'journey.frontpage.activeJourney': 'اليوميات النشطة',
|
||||
'journey.frontpage.latestJourney': 'أحدث رحلة',
|
||||
'journey.frontpage.allJourneys': 'All Journeys', // en-fallback
|
||||
'journey.frontpage.allJourneys': 'كل اليوميات',
|
||||
'journey.frontpage.journeys': 'رحلة',
|
||||
'journey.frontpage.journeys.zero': 'رحلات',
|
||||
'journey.frontpage.journeys.one': 'رحلة',
|
||||
'journey.frontpage.journeys.two': 'رحلتان',
|
||||
'journey.frontpage.journeys.few': 'رحلات',
|
||||
'journey.frontpage.journeys.many': 'رحلة',
|
||||
'journey.frontpage.createNew': 'Create a new Journey', // en-fallback
|
||||
'journey.frontpage.createNewSub': 'Pick trips, write stories, share your adventures', // en-fallback
|
||||
'journey.frontpage.live': 'Live', // en-fallback
|
||||
'journey.frontpage.synced': 'Synced', // en-fallback
|
||||
'journey.frontpage.continueWriting': 'Continue writing', // en-fallback
|
||||
'journey.frontpage.updated': 'Updated {time}', // en-fallback
|
||||
'journey.frontpage.suggestionLabel': 'Trip just ended', // en-fallback
|
||||
'journey.frontpage.suggestionText': 'Turn <strong>{title}</strong> into a Journey', // en-fallback
|
||||
'journey.frontpage.dismiss': 'Dismiss', // en-fallback
|
||||
'journey.frontpage.createNew': 'إنشاء يوميات جديدة',
|
||||
'journey.frontpage.createNewSub': 'اختر الرحلات، اكتب القصص، وشارك مغامراتك',
|
||||
'journey.frontpage.live': 'مباشر',
|
||||
'journey.frontpage.synced': 'متزامنة',
|
||||
'journey.frontpage.continueWriting': 'أكمل الكتابة',
|
||||
'journey.frontpage.updated': 'تم التحديث {time}',
|
||||
'journey.frontpage.suggestionLabel': 'انتهت الرحلة للتو',
|
||||
'journey.frontpage.suggestionText': 'حوّل <strong>{title}</strong> إلى يوميات',
|
||||
'journey.frontpage.dismiss': 'تجاهل',
|
||||
'journey.frontpage.dismissError': 'تعذر تجاهل هذا الاقتراح',
|
||||
'journey.frontpage.journeyName': 'Journey Name', // en-fallback
|
||||
'journey.frontpage.namePlaceholder': 'e.g. Southeast Asia 2026', // en-fallback
|
||||
'journey.frontpage.selectTrips': 'Select Trips', // en-fallback
|
||||
'journey.frontpage.tripsSelected': 'trips selected', // en-fallback
|
||||
'journey.frontpage.trips': 'trips', // en-fallback
|
||||
'journey.frontpage.placesImported': 'places will be imported', // en-fallback
|
||||
'journey.frontpage.journeyName': 'اسم اليوميات',
|
||||
'journey.frontpage.namePlaceholder': 'مثال: جنوب شرق آسيا 2026',
|
||||
'journey.frontpage.selectTrips': 'اختر الرحلات',
|
||||
'journey.frontpage.tripsSelected': 'رحلات محددة',
|
||||
'journey.frontpage.trips': 'رحلات',
|
||||
'journey.frontpage.placesImported': 'أماكن سيتم استيرادها',
|
||||
'journey.frontpage.places': 'مكان',
|
||||
'journey.frontpage.places.zero': 'أماكن',
|
||||
'journey.frontpage.places.one': 'مكان',
|
||||
'journey.frontpage.places.two': 'مكانان',
|
||||
'journey.frontpage.places.few': 'أماكن',
|
||||
'journey.frontpage.places.many': 'مكانًا',
|
||||
'journey.detail.syncedWithTrips': 'Synced with Trips', // en-fallback
|
||||
'journey.detail.addEntry': 'Add Entry', // en-fallback
|
||||
'journey.detail.syncedWithTrips': 'متزامنة مع الرحلات',
|
||||
'journey.detail.addEntry': 'إضافة تدوينة',
|
||||
'journey.detail.jumpToTop': 'العودة إلى الأعلى',
|
||||
'journey.detail.jumpToLast': 'الانتقال إلى آخر مدخل',
|
||||
'journey.detail.dayJump': 'الانتقال إلى يوم',
|
||||
@@ -167,103 +167,103 @@ const journey: TranslationStrings = {
|
||||
'journey.detail.dayJumpCount.two': 'يومان',
|
||||
'journey.detail.dayJumpCount.few': '{count} أيام',
|
||||
'journey.detail.dayJumpCount.many': '{count} يومًا',
|
||||
'journey.detail.newEntry': 'New Entry', // en-fallback
|
||||
'journey.detail.editEntry': 'Edit Entry', // en-fallback
|
||||
'journey.detail.noEntries': 'No entries yet', // en-fallback
|
||||
'journey.detail.noEntriesHint': 'Add a trip to get started with skeleton entries', // en-fallback
|
||||
'journey.detail.noPhotos': 'No photos yet', // en-fallback
|
||||
'journey.detail.noPhotosHint': 'Upload photos to entries or browse your Immich/Synology library', // en-fallback
|
||||
'journey.detail.journeyTab': 'Journey', // en-fallback
|
||||
'journey.detail.journeyStats': 'Journey Stats', // en-fallback
|
||||
'journey.detail.syncedTrips': 'Synced Trips', // en-fallback
|
||||
'journey.detail.noTripsLinked': 'No trips linked yet', // en-fallback
|
||||
'journey.detail.contributors': 'Contributors', // en-fallback
|
||||
'journey.detail.readMore': 'Read more', // en-fallback
|
||||
'journey.detail.prosCons': 'Pros & Cons', // en-fallback
|
||||
'journey.stats.days': 'Days', // en-fallback
|
||||
'journey.stats.cities': 'Cities', // en-fallback
|
||||
'journey.stats.entries': 'Entries', // en-fallback
|
||||
'journey.stats.photos': 'Photos', // en-fallback
|
||||
'journey.stats.places': 'Places', // en-fallback
|
||||
'journey.verdict.lovedIt': 'Loved it', // en-fallback
|
||||
'journey.verdict.couldBeBetter': 'Could be better', // en-fallback
|
||||
'journey.detail.newEntry': 'تدوينة جديدة',
|
||||
'journey.detail.editEntry': 'تعديل التدوينة',
|
||||
'journey.detail.noEntries': 'لا توجد تدوينات بعد',
|
||||
'journey.detail.noEntriesHint': 'أضف رحلة للبدء بتدوينات أولية',
|
||||
'journey.detail.noPhotos': 'لا توجد صور بعد',
|
||||
'journey.detail.noPhotosHint': 'ارفع صورًا إلى التدوينات أو تصفّح مكتبة Immich/Synology',
|
||||
'journey.detail.journeyTab': 'اليوميات',
|
||||
'journey.detail.journeyStats': 'إحصائيات اليوميات',
|
||||
'journey.detail.syncedTrips': 'الرحلات المتزامنة',
|
||||
'journey.detail.noTripsLinked': 'لا توجد رحلات مرتبطة بعد',
|
||||
'journey.detail.contributors': 'المساهمون',
|
||||
'journey.detail.readMore': 'اقرأ المزيد',
|
||||
'journey.detail.prosCons': 'الإيجابيات والسلبيات',
|
||||
'journey.stats.days': 'الأيام',
|
||||
'journey.stats.cities': 'المدن',
|
||||
'journey.stats.entries': 'التدوينات',
|
||||
'journey.stats.photos': 'الصور',
|
||||
'journey.stats.places': 'الأماكن',
|
||||
'journey.verdict.lovedIt': 'أعجبني كثيرًا',
|
||||
'journey.verdict.couldBeBetter': 'كان يمكن أن يكون أفضل',
|
||||
'journey.synced.places': 'مكان',
|
||||
'journey.synced.places.zero': 'أماكن',
|
||||
'journey.synced.places.one': 'مكان',
|
||||
'journey.synced.places.two': 'مكانان',
|
||||
'journey.synced.places.few': 'أماكن',
|
||||
'journey.synced.places.many': 'مكانًا',
|
||||
'journey.synced.synced': 'synced', // en-fallback
|
||||
'journey.editor.allPhotosAdded': 'All photos already added', // en-fallback
|
||||
'journey.editor.writeStory': 'Write your story...', // en-fallback
|
||||
'journey.editor.prosCons': 'Pros & Cons', // en-fallback
|
||||
'journey.editor.pros': 'Pros', // en-fallback
|
||||
'journey.editor.cons': 'Cons', // en-fallback
|
||||
'journey.editor.proPlaceholder': 'Something great...', // en-fallback
|
||||
'journey.editor.conPlaceholder': 'Not so great...', // en-fallback
|
||||
'journey.editor.date': 'Date', // en-fallback
|
||||
'journey.editor.location': 'Location', // en-fallback
|
||||
'journey.editor.searchLocation': 'Search location...', // en-fallback
|
||||
'journey.editor.mood': 'Mood', // en-fallback
|
||||
'journey.editor.weather': 'Weather', // en-fallback
|
||||
'journey.editor.photoFirst': '1st', // en-fallback
|
||||
'journey.mood.amazing': 'Amazing', // en-fallback
|
||||
'journey.mood.good': 'Good', // en-fallback
|
||||
'journey.mood.neutral': 'Neutral', // en-fallback
|
||||
'journey.mood.rough': 'Rough', // en-fallback
|
||||
'journey.weather.sunny': 'Sunny', // en-fallback
|
||||
'journey.weather.partly': 'Partly cloudy', // en-fallback
|
||||
'journey.weather.cloudy': 'Cloudy', // en-fallback
|
||||
'journey.weather.rainy': 'Rainy', // en-fallback
|
||||
'journey.weather.stormy': 'Stormy', // en-fallback
|
||||
'journey.weather.cold': 'Snowy', // en-fallback
|
||||
'journey.trips.linkTrip': 'Link Trip', // en-fallback
|
||||
'journey.trips.searchTrip': 'Search Trip', // en-fallback
|
||||
'journey.trips.searchPlaceholder': 'Trip name or destination...', // en-fallback
|
||||
'journey.trips.noTripsAvailable': 'No trips available', // en-fallback
|
||||
'journey.trips.link': 'Link', // en-fallback
|
||||
'journey.trips.tripLinked': 'Trip linked', // en-fallback
|
||||
'journey.trips.linkFailed': 'Failed to link trip', // en-fallback
|
||||
'journey.trips.addTrip': 'Add Trip', // en-fallback
|
||||
'journey.trips.unlinkTrip': 'Unlink Trip', // en-fallback
|
||||
'journey.synced.synced': 'متزامن',
|
||||
'journey.editor.allPhotosAdded': 'تمت إضافة كل الصور بالفعل',
|
||||
'journey.editor.writeStory': 'اكتب قصتك...',
|
||||
'journey.editor.prosCons': 'الإيجابيات والسلبيات',
|
||||
'journey.editor.pros': 'الإيجابيات',
|
||||
'journey.editor.cons': 'السلبيات',
|
||||
'journey.editor.proPlaceholder': 'شيء رائع...',
|
||||
'journey.editor.conPlaceholder': 'ليس رائعًا تمامًا...',
|
||||
'journey.editor.date': 'التاريخ',
|
||||
'journey.editor.location': 'الموقع',
|
||||
'journey.editor.searchLocation': 'ابحث عن موقع...',
|
||||
'journey.editor.mood': 'المزاج',
|
||||
'journey.editor.weather': 'الطقس',
|
||||
'journey.editor.photoFirst': 'الأولى',
|
||||
'journey.mood.amazing': 'رائع',
|
||||
'journey.mood.good': 'جيد',
|
||||
'journey.mood.neutral': 'عادي',
|
||||
'journey.mood.rough': 'صعب',
|
||||
'journey.weather.sunny': 'مشمس',
|
||||
'journey.weather.partly': 'غائم جزئيًا',
|
||||
'journey.weather.cloudy': 'غائم',
|
||||
'journey.weather.rainy': 'ماطر',
|
||||
'journey.weather.stormy': 'عاصف',
|
||||
'journey.weather.cold': 'مثلج',
|
||||
'journey.trips.linkTrip': 'ربط رحلة',
|
||||
'journey.trips.searchTrip': 'البحث عن رحلة',
|
||||
'journey.trips.searchPlaceholder': 'اسم الرحلة أو الوجهة...',
|
||||
'journey.trips.noTripsAvailable': 'لا توجد رحلات متاحة',
|
||||
'journey.trips.link': 'ربط',
|
||||
'journey.trips.tripLinked': 'تم ربط الرحلة',
|
||||
'journey.trips.linkFailed': 'فشل ربط الرحلة',
|
||||
'journey.trips.addTrip': 'إضافة رحلة',
|
||||
'journey.trips.unlinkTrip': 'فك ارتباط الرحلة',
|
||||
'journey.trips.unlinkMessage':
|
||||
'Unlink "{title}"? All synced entries and photos from this trip will be permanently deleted. This cannot be undone.', // en-fallback
|
||||
'journey.trips.unlink': 'Unlink', // en-fallback
|
||||
'journey.trips.tripUnlinked': 'Trip unlinked', // en-fallback
|
||||
'journey.trips.unlinkFailed': 'Failed to unlink trip', // en-fallback
|
||||
'journey.trips.noTripsLinkedSettings': 'No trips linked', // en-fallback
|
||||
'journey.contributors.invite': 'Invite Contributor', // en-fallback
|
||||
'journey.contributors.searchUser': 'Search User', // en-fallback
|
||||
'journey.contributors.searchPlaceholder': 'Username or email...', // en-fallback
|
||||
'journey.contributors.noUsers': 'No users found', // en-fallback
|
||||
'journey.contributors.role': 'Role', // en-fallback
|
||||
'journey.contributors.added': 'Contributor added', // en-fallback
|
||||
'journey.contributors.addFailed': 'Failed to add contributor', // en-fallback
|
||||
'journey.contributors.remove': 'Remove contributor', // en-fallback
|
||||
'journey.contributors.removeConfirm': 'Remove {username} from this journey?', // en-fallback
|
||||
'journey.contributors.removed': 'Contributor removed', // en-fallback
|
||||
'journey.contributors.removeFailed': 'Failed to remove contributor', // en-fallback
|
||||
'هل تريد فك ارتباط "{title}"؟ سيتم حذف كل التدوينات والصور المتزامنة من هذه الرحلة نهائيًا. لا يمكن التراجع عن ذلك.',
|
||||
'journey.trips.unlink': 'فك الارتباط',
|
||||
'journey.trips.tripUnlinked': 'تم فك ارتباط الرحلة',
|
||||
'journey.trips.unlinkFailed': 'فشل فك ارتباط الرحلة',
|
||||
'journey.trips.noTripsLinkedSettings': 'لا توجد رحلات مرتبطة',
|
||||
'journey.contributors.invite': 'دعوة مساهم',
|
||||
'journey.contributors.searchUser': 'البحث عن مستخدم',
|
||||
'journey.contributors.searchPlaceholder': 'اسم المستخدم أو البريد الإلكتروني...',
|
||||
'journey.contributors.noUsers': 'لم يتم العثور على مستخدمين',
|
||||
'journey.contributors.role': 'الدور',
|
||||
'journey.contributors.added': 'تمت إضافة المساهم',
|
||||
'journey.contributors.addFailed': 'فشلت إضافة المساهم',
|
||||
'journey.contributors.remove': 'إزالة المساهم',
|
||||
'journey.contributors.removeConfirm': 'هل تريد إزالة {username} من هذه اليوميات؟',
|
||||
'journey.contributors.removed': 'تمت إزالة المساهم',
|
||||
'journey.contributors.removeFailed': 'فشلت إزالة المساهم',
|
||||
'journey.contributors.viaTrips': 'عبر الرحلات',
|
||||
'journey.contributors.viaTripsHint': 'يمكنهم فتح هذه اليوميات لأن رحلة ترتبط بها. غيّر ذلك من إعدادات الرحلة.',
|
||||
'journey.contributors.viaTrip': 'عبر {trip}',
|
||||
'journey.share.publicShare': 'Public Share', // en-fallback
|
||||
'journey.share.createLink': 'Create share link', // en-fallback
|
||||
'journey.share.linkCreated': 'Share link created', // en-fallback
|
||||
'journey.share.createFailed': 'Failed to create link', // en-fallback
|
||||
'journey.share.timeline': 'Timeline', // en-fallback
|
||||
'journey.share.gallery': 'Gallery', // en-fallback
|
||||
'journey.share.map': 'Map', // en-fallback
|
||||
'journey.share.removeLink': 'Remove share link', // en-fallback
|
||||
'journey.share.linkDeleted': 'Share link deleted', // en-fallback
|
||||
'journey.share.deleteFailed': 'Failed to delete', // en-fallback
|
||||
'journey.share.updateFailed': 'Failed to update', // en-fallback
|
||||
'journey.settings.title': 'Journey Settings', // en-fallback
|
||||
'journey.settings.coverImage': 'Cover Image', // en-fallback
|
||||
'journey.settings.changeCover': 'Change cover', // en-fallback
|
||||
'journey.settings.addCover': 'Add cover image', // en-fallback
|
||||
'journey.settings.name': 'Name', // en-fallback
|
||||
'journey.settings.subtitle': 'Subtitle', // en-fallback
|
||||
'journey.settings.subtitlePlaceholder': 'e.g. Thailand, Vietnam & Cambodia', // en-fallback
|
||||
'journey.share.publicShare': 'مشاركة عامة',
|
||||
'journey.share.createLink': 'إنشاء رابط مشاركة',
|
||||
'journey.share.linkCreated': 'تم إنشاء رابط المشاركة',
|
||||
'journey.share.createFailed': 'فشل إنشاء الرابط',
|
||||
'journey.share.timeline': 'الخط الزمني',
|
||||
'journey.share.gallery': 'المعرض',
|
||||
'journey.share.map': 'الخريطة',
|
||||
'journey.share.removeLink': 'إزالة رابط المشاركة',
|
||||
'journey.share.linkDeleted': 'تم حذف رابط المشاركة',
|
||||
'journey.share.deleteFailed': 'فشل الحذف',
|
||||
'journey.share.updateFailed': 'فشل التحديث',
|
||||
'journey.settings.title': 'إعدادات اليوميات',
|
||||
'journey.settings.coverImage': 'صورة الغلاف',
|
||||
'journey.settings.changeCover': 'تغيير الغلاف',
|
||||
'journey.settings.addCover': 'إضافة صورة غلاف',
|
||||
'journey.settings.name': 'الاسم',
|
||||
'journey.settings.subtitle': 'العنوان الفرعي',
|
||||
'journey.settings.subtitlePlaceholder': 'مثال: تايلاند وفيتنام وكمبوديا',
|
||||
'journey.settings.tracks': 'مسارات GPX',
|
||||
'journey.settings.recordings': 'الأيام المسجلة',
|
||||
'journey.settings.showTripTracks': 'إظهار جميع مسارات GPX للرحلات',
|
||||
@@ -279,47 +279,47 @@ const journey: TranslationStrings = {
|
||||
'journey.settings.photoLocation': 'تحديد موقع الإدخالات من صورها',
|
||||
'journey.settings.photoLocationHint':
|
||||
'يأخذ الإدخال الذي بلا مكان موقع أول صورة له تحمل GPS. لا تُنقل الأماكن التي حددتها بنفسك أبدًا.',
|
||||
'journey.settings.delete': 'Delete', // en-fallback
|
||||
'journey.settings.deleteJourney': 'Delete Journey', // en-fallback
|
||||
'journey.settings.deleteMessage': 'Delete "{title}"? All entries and photos will be lost.', // en-fallback
|
||||
'journey.settings.saved': 'Settings saved', // en-fallback
|
||||
'journey.settings.saveFailed': 'Failed to save', // en-fallback
|
||||
'journey.settings.coverUpdated': 'Cover updated', // en-fallback
|
||||
'journey.settings.coverFailed': 'Upload failed', // en-fallback
|
||||
'journey.public.notFound': 'Not Found', // en-fallback
|
||||
'journey.public.notFoundMessage': "This journey doesn't exist or the link has expired.", // en-fallback
|
||||
'journey.public.readOnly': 'Read-only · Public Journey', // en-fallback
|
||||
'journey.public.tagline': 'Travel Resource & Exploration Kit', // en-fallback
|
||||
'journey.public.sharedVia': 'Shared via', // en-fallback
|
||||
'journey.public.madeWith': 'Made with', // en-fallback
|
||||
'journey.pdf.journeyBook': 'Journey Book', // en-fallback
|
||||
'journey.pdf.madeWith': 'Made with TREK', // en-fallback
|
||||
'journey.pdf.day': 'Day', // en-fallback
|
||||
'journey.pdf.theEnd': 'The End', // en-fallback
|
||||
'journey.pdf.saveAsPdf': 'Save as PDF', // en-fallback
|
||||
'journey.pdf.pages': 'pages', // en-fallback
|
||||
'journey.editor.externalPhotos': 'External photos', // en-fallback
|
||||
'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback
|
||||
'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback
|
||||
'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback
|
||||
'journey.editor.externalPhotosQueued': 'queued', // en-fallback
|
||||
'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback
|
||||
'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback
|
||||
'journey.picker.day': 'This day', // en-fallback
|
||||
'journey.studio.title': 'TREK Studio', // en-fallback
|
||||
'journey.studio.open': 'Studio', // en-fallback
|
||||
'journey.studio.openAria': 'Open the photo book studio', // en-fallback
|
||||
'journey.studio.backToJourney': 'Back to the journey', // en-fallback
|
||||
'journey.studio.format': 'Page format', // en-fallback
|
||||
'journey.studio.formatA4Landscape': 'A4 landscape', // en-fallback
|
||||
'journey.studio.formatA4Portrait': 'A4 portrait', // en-fallback
|
||||
'journey.studio.formatSquare21': 'Square 21 × 21 cm', // en-fallback
|
||||
'journey.studio.formatSquare30': 'Square 30 × 30 cm', // en-fallback
|
||||
'journey.settings.delete': 'حذف',
|
||||
'journey.settings.deleteJourney': 'حذف اليوميات',
|
||||
'journey.settings.deleteMessage': 'هل تريد حذف "{title}"؟ ستفقد كل التدوينات والصور.',
|
||||
'journey.settings.saved': 'تم حفظ الإعدادات',
|
||||
'journey.settings.saveFailed': 'فشل الحفظ',
|
||||
'journey.settings.coverUpdated': 'تم تحديث الغلاف',
|
||||
'journey.settings.coverFailed': 'فشل الرفع',
|
||||
'journey.public.notFound': 'غير موجود',
|
||||
'journey.public.notFoundMessage': 'هذه اليوميات غير موجودة أو انتهت صلاحية الرابط.',
|
||||
'journey.public.readOnly': 'للقراءة فقط، يوميات عامة',
|
||||
'journey.public.tagline': 'عدّة السفر والاستكشاف',
|
||||
'journey.public.sharedVia': 'تمت المشاركة عبر',
|
||||
'journey.public.madeWith': 'صُنع باستخدام',
|
||||
'journey.pdf.journeyBook': 'كتاب اليوميات',
|
||||
'journey.pdf.madeWith': 'صُنع باستخدام TREK',
|
||||
'journey.pdf.day': 'اليوم',
|
||||
'journey.pdf.theEnd': 'النهاية',
|
||||
'journey.pdf.saveAsPdf': 'حفظ بصيغة PDF',
|
||||
'journey.pdf.pages': 'صفحات',
|
||||
'journey.editor.externalPhotos': 'صور خارجية',
|
||||
'journey.editor.externalPhotosFor': 'صور يوم {date}',
|
||||
'journey.editor.externalPhotosNearby': 'الصور القريبة أولًا',
|
||||
'journey.editor.externalPhotosNoLocation': 'كل صور هذا اليوم',
|
||||
'journey.editor.externalPhotosQueued': 'في قائمة الانتظار',
|
||||
'journey.editor.externalPhotosUnavailable': 'لا يوجد مزوّدو صور متصلون.',
|
||||
'journey.editor.externalPhotosPartialFailed': 'فشلت {failed} مجموعات صور، احفظ مرة أخرى لإعادة المحاولة',
|
||||
'journey.picker.day': 'هذا اليوم',
|
||||
'journey.studio.title': 'TREK Studio',
|
||||
'journey.studio.open': 'الاستوديو',
|
||||
'journey.studio.openAria': 'فتح استوديو ألبوم الصور',
|
||||
'journey.studio.backToJourney': 'العودة إلى اليوميات',
|
||||
'journey.studio.format': 'حجم الصفحة',
|
||||
'journey.studio.formatA4Landscape': 'A4 أفقي',
|
||||
'journey.studio.formatA4Portrait': 'A4 عمودي',
|
||||
'journey.studio.formatSquare21': 'مربع 21 × 21 سم',
|
||||
'journey.studio.formatSquare30': 'مربع 30 × 30 سم',
|
||||
'journey.studio.pages': 'الصفحات',
|
||||
'journey.studio.cover': 'Cover', // en-fallback
|
||||
'journey.studio.inspectorEmpty': 'Select something on the page to edit it.', // en-fallback
|
||||
'journey.studio.autoLayout': 'Auto layout', // en-fallback
|
||||
'journey.studio.export': 'Export', // en-fallback
|
||||
'journey.studio.cover': 'الغلاف',
|
||||
'journey.studio.inspectorEmpty': 'حدّد عنصرًا في الصفحة لتعديله.',
|
||||
'journey.studio.autoLayout': 'تخطيط تلقائي',
|
||||
'journey.studio.export': 'تصدير',
|
||||
'journey.studio.day': 'اليوم {day}',
|
||||
'journey.studio.stations': 'المحطات',
|
||||
'journey.studio.peersHere': 'هنا',
|
||||
@@ -347,97 +347,97 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.exportSheetCount.two': 'ورقتان',
|
||||
'journey.studio.exportSheetCount.few': '{count} أوراق',
|
||||
'journey.studio.exportSheetCount.many': '{count} ورقة',
|
||||
'journey.studio.undo': 'Undo', // en-fallback
|
||||
'journey.studio.redo': 'Redo', // en-fallback
|
||||
'journey.studio.zoomIn': 'Zoom in', // en-fallback
|
||||
'journey.studio.zoomOut': 'Zoom out', // en-fallback
|
||||
'journey.studio.zoomFit': 'Fit to view', // en-fallback
|
||||
'journey.studio.undo': 'تراجع',
|
||||
'journey.studio.redo': 'إعادة',
|
||||
'journey.studio.zoomIn': 'تكبير',
|
||||
'journey.studio.zoomOut': 'تصغير',
|
||||
'journey.studio.zoomFit': 'ملاءمة العرض',
|
||||
'journey.studio.downloadSpread': 'تنزيل هذه الصفحة المزدوجة',
|
||||
'journey.studio.downloadSpreadHint': 'يحفظ تصميم هذه الصفحة كملف، بدون الصور، لمشاركته أو إعادة استخدامه',
|
||||
'journey.studio.importSpread': 'استيراد',
|
||||
'journey.studio.importSpreadHint': 'إضافة صفحة مزدوجة من ملف تصميم منزّل',
|
||||
'journey.studio.importSpreadFailed': 'هذا الملف ليس صفحة من TREK Studio',
|
||||
'journey.studio.desktopOnly': 'Studio needs a bigger screen', // en-fallback
|
||||
'journey.studio.desktopOnly': 'يحتاج الاستوديو إلى شاشة أكبر',
|
||||
'journey.studio.desktopOnlyHint':
|
||||
'تصميم كتاب يحتاج مساحة للعمل، لذلك يعمل الاستوديو على سطح المكتب فقط، وكذلك إنشاء ملف PDF. كل شيء آخر في رحلتك يعمل هنا كالمعتاد.', // en-fallback
|
||||
'journey.studio.formatA5Landscape': 'A5 landscape', // en-fallback
|
||||
'journey.studio.bookView': 'Book view', // en-fallback
|
||||
'journey.studio.multiple': 'Several', // en-fallback
|
||||
'journey.studio.kind.photo': 'Photo', // en-fallback
|
||||
'journey.studio.kind.text': 'Text', // en-fallback
|
||||
'journey.studio.kind.shape': 'Shape', // en-fallback
|
||||
'journey.studio.position': 'Position', // en-fallback
|
||||
'journey.studio.width': 'W', // en-fallback
|
||||
'journey.studio.height': 'H', // en-fallback
|
||||
'تصميم كتاب يحتاج مساحة للعمل، لذلك يعمل الاستوديو على سطح المكتب فقط، وكذلك إنشاء ملف PDF. كل شيء آخر في يومياتك يعمل هنا كالمعتاد.',
|
||||
'journey.studio.formatA5Landscape': 'A5 أفقي',
|
||||
'journey.studio.bookView': 'عرض الكتاب',
|
||||
'journey.studio.multiple': 'عدة عناصر',
|
||||
'journey.studio.kind.photo': 'صورة',
|
||||
'journey.studio.kind.text': 'نص',
|
||||
'journey.studio.kind.shape': 'شكل',
|
||||
'journey.studio.position': 'الموضع',
|
||||
'journey.studio.width': 'العرض',
|
||||
'journey.studio.height': 'الارتفاع',
|
||||
'journey.studio.text': 'النص',
|
||||
'journey.studio.typography': 'Type', // en-fallback
|
||||
'journey.studio.leading': 'Line', // en-fallback
|
||||
'journey.studio.colour': 'Colour', // en-fallback
|
||||
'journey.studio.typography': 'الخط',
|
||||
'journey.studio.leading': 'التباعد',
|
||||
'journey.studio.colour': 'اللون',
|
||||
'journey.studio.autoColour': 'تلقائي',
|
||||
'journey.studio.countryNames': 'الأسماء',
|
||||
'journey.studio.crop': 'Crop', // en-fallback
|
||||
'journey.studio.look': 'Look', // en-fallback
|
||||
'journey.studio.radius': 'Corner', // en-fallback
|
||||
'journey.studio.shape': 'Shape', // en-fallback
|
||||
'journey.studio.arrange': 'Arrange', // en-fallback
|
||||
'journey.studio.toFront': 'Bring to front', // en-fallback
|
||||
'journey.studio.forward': 'Bring forward', // en-fallback
|
||||
'journey.studio.backward': 'Send backward', // en-fallback
|
||||
'journey.studio.toBack': 'Send to back', // en-fallback
|
||||
'journey.studio.lock': 'Lock', // en-fallback
|
||||
'journey.studio.unlock': 'Unlock', // en-fallback
|
||||
'journey.studio.delete': 'Delete', // en-fallback
|
||||
'journey.studio.pageHint': 'Page', // en-fallback
|
||||
'journey.studio.boundHint': 'Follows the journal entry. Editing it here breaks that link.', // en-fallback
|
||||
'journey.studio.fit.cover': 'Fill', // en-fallback
|
||||
'journey.studio.fit.contain': 'Fit', // en-fallback
|
||||
'journey.studio.filter.none': 'Original', // en-fallback
|
||||
'journey.studio.filter.bw': 'Black & white', // en-fallback
|
||||
'journey.studio.filter.warm': 'Warm', // en-fallback
|
||||
'journey.studio.shapeKind.rect': 'Rectangle', // en-fallback
|
||||
'journey.studio.shapeKind.ellipse': 'Ellipse', // en-fallback
|
||||
'journey.studio.focalHint': 'Drag the point to choose what stays in frame.', // en-fallback
|
||||
'journey.studio.backCover': 'Back cover', // en-fallback
|
||||
'journey.studio.sections': 'Sections', // en-fallback
|
||||
'journey.studio.crop': 'اقتصاص',
|
||||
'journey.studio.look': 'المظهر',
|
||||
'journey.studio.radius': 'الزوايا',
|
||||
'journey.studio.shape': 'الشكل',
|
||||
'journey.studio.arrange': 'ترتيب',
|
||||
'journey.studio.toFront': 'إحضار إلى الأمام',
|
||||
'journey.studio.forward': 'تقديم خطوة',
|
||||
'journey.studio.backward': 'إرجاع خطوة',
|
||||
'journey.studio.toBack': 'إرسال إلى الخلف',
|
||||
'journey.studio.lock': 'قفل',
|
||||
'journey.studio.unlock': 'إلغاء القفل',
|
||||
'journey.studio.delete': 'حذف',
|
||||
'journey.studio.pageHint': 'الصفحة',
|
||||
'journey.studio.boundHint': 'يتبع التدوينة. تعديله هنا يقطع هذا الارتباط.',
|
||||
'journey.studio.fit.cover': 'ملء',
|
||||
'journey.studio.fit.contain': 'احتواء',
|
||||
'journey.studio.filter.none': 'الأصلية',
|
||||
'journey.studio.filter.bw': 'أبيض وأسود',
|
||||
'journey.studio.filter.warm': 'دافئة',
|
||||
'journey.studio.shapeKind.rect': 'مستطيل',
|
||||
'journey.studio.shapeKind.ellipse': 'بيضاوي',
|
||||
'journey.studio.focalHint': 'اسحب النقطة لاختيار ما يبقى داخل الإطار.',
|
||||
'journey.studio.backCover': 'الغلاف الخلفي',
|
||||
'journey.studio.sections': 'الأقسام',
|
||||
'journey.studio.content': 'المحتوى',
|
||||
'journey.studio.elements': 'العناصر',
|
||||
'journey.studio.templates': 'التخطيطات',
|
||||
'journey.studio.photos': 'الصور',
|
||||
'journey.studio.entries': 'المدخلات',
|
||||
'journey.studio.addToPage': 'Add to this page', // en-fallback
|
||||
'journey.studio.noPhotos': 'This journey has no photos yet.', // en-fallback
|
||||
'journey.studio.untitled': 'Untitled', // en-fallback
|
||||
'journey.studio.addTitle': 'Title', // en-fallback
|
||||
'journey.studio.addStory': 'Story', // en-fallback
|
||||
'journey.studio.addPlace': 'Place', // en-fallback
|
||||
'journey.studio.addToPage': 'إضافة إلى هذه الصفحة',
|
||||
'journey.studio.noPhotos': 'لا توجد صور في هذه اليوميات بعد.',
|
||||
'journey.studio.untitled': 'بدون عنوان',
|
||||
'journey.studio.addTitle': 'عنوان',
|
||||
'journey.studio.addStory': 'قصة',
|
||||
'journey.studio.addPlace': 'مكان',
|
||||
'journey.studio.shapes': 'الأشكال',
|
||||
'journey.studio.frames': 'إطارات',
|
||||
'journey.studio.emptyFrame': 'Empty frame', // en-fallback
|
||||
'journey.studio.frameHint': 'An empty frame marks where a picture goes. Drop one on it from Content.', // en-fallback
|
||||
'journey.studio.shapeKind.line': 'Line', // en-fallback
|
||||
'journey.studio.styleTitle': 'Heading', // en-fallback
|
||||
'journey.studio.styleSubtitle': 'Subheading', // en-fallback
|
||||
'journey.studio.styleBody': 'Body text', // en-fallback
|
||||
'journey.studio.styleCaption': 'Caption', // en-fallback
|
||||
'journey.studio.sampleHeading': 'A heading', // en-fallback
|
||||
'journey.studio.sampleSubheading': 'A subheading', // en-fallback
|
||||
'journey.studio.sampleBody': 'Write something about this day.', // en-fallback
|
||||
'journey.studio.sampleCaption': 'Caption', // en-fallback
|
||||
'journey.studio.tpl.heroStory': 'Hero and story', // en-fallback
|
||||
'journey.studio.tpl.fullBleed': 'One picture, full spread', // en-fallback
|
||||
'journey.studio.tpl.twoUp': 'Two full pages', // en-fallback
|
||||
'journey.studio.tpl.grid4': 'Four up', // en-fallback
|
||||
'journey.studio.tpl.grid6': 'Six up', // en-fallback
|
||||
'journey.studio.tpl.strip': 'Strip and text', // en-fallback
|
||||
'journey.studio.tpl.quietText': 'Text only', // en-fallback
|
||||
'journey.studio.tpl.portraitPair': 'A pair', // en-fallback
|
||||
'journey.studio.emptyFrame': 'إطار فارغ',
|
||||
'journey.studio.frameHint': 'الإطار الفارغ يحدد مكان الصورة. أفلت صورة عليه من المحتوى.',
|
||||
'journey.studio.shapeKind.line': 'خط',
|
||||
'journey.studio.styleTitle': 'عنوان رئيسي',
|
||||
'journey.studio.styleSubtitle': 'عنوان فرعي',
|
||||
'journey.studio.styleBody': 'نص أساسي',
|
||||
'journey.studio.styleCaption': 'تعليق',
|
||||
'journey.studio.sampleHeading': 'عنوان رئيسي',
|
||||
'journey.studio.sampleSubheading': 'عنوان فرعي',
|
||||
'journey.studio.sampleBody': 'اكتب شيئًا عن هذا اليوم.',
|
||||
'journey.studio.sampleCaption': 'تعليق',
|
||||
'journey.studio.tpl.heroStory': 'صورة كبيرة وقصة',
|
||||
'journey.studio.tpl.fullBleed': 'صورة واحدة بعرض الصفحتين',
|
||||
'journey.studio.tpl.twoUp': 'صفحتان كاملتان',
|
||||
'journey.studio.tpl.grid4': 'أربع صور',
|
||||
'journey.studio.tpl.grid6': 'ست صور',
|
||||
'journey.studio.tpl.strip': 'شريط ونص',
|
||||
'journey.studio.tpl.quietText': 'نص فقط',
|
||||
'journey.studio.tpl.portraitPair': 'زوج من الصور',
|
||||
'journey.studio.dropPhotoHere': 'اسحب صورتك\nوأفلتها هنا',
|
||||
'journey.studio.searchContent': 'Search photos and entries', // en-fallback
|
||||
'journey.studio.noMatches': 'Nothing matches that.', // en-fallback
|
||||
'journey.studio.searchContent': 'ابحث في الصور والتدوينات',
|
||||
'journey.studio.noMatches': 'لا توجد نتائج مطابقة.',
|
||||
'journey.studio.quoteMark': 'علامة اقتباس',
|
||||
'journey.studio.shapeKind.rounded': 'Rounded rectangle', // en-fallback
|
||||
'journey.studio.shapeKind.triangle': 'Triangle', // en-fallback
|
||||
'journey.studio.shapeKind.outline': 'Outline only', // en-fallback
|
||||
'journey.studio.shapeKind.rounded': 'مستطيل بزوايا مستديرة',
|
||||
'journey.studio.shapeKind.triangle': 'مثلث',
|
||||
'journey.studio.shapeKind.outline': 'حدود فقط',
|
||||
'journey.studio.travel': 'الرحلة',
|
||||
'journey.studio.travelEmpty': 'أرقام هذه الرحلة ليست جاهزة بعد.',
|
||||
'journey.studio.grids': 'الشبكات',
|
||||
@@ -493,6 +493,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.copyToPage': 'نسخ للصق في أي صفحة (Ctrl+C)',
|
||||
'journey.studio.paste': 'لصق (Ctrl+V)',
|
||||
'journey.studio.pasteEmpty': 'انسخ شيئًا أولًا، ثم الصقه في أي صفحة',
|
||||
'journey.studio.pageFull':
|
||||
'هذه الصفحة ممتلئة: تحتوي بالفعل على أقصى عدد من العناصر. احذف شيئًا أولًا أو استخدم صفحة أخرى.',
|
||||
'journey.studio.myLayouts': 'تخطيطاتي',
|
||||
'journey.studio.myLayoutsEmpty': 'احتفظ بصفحة رتّبتها، ورتّب صفحات أخرى بالطريقة نفسها. تبقى صورها ونصوصها كما هي.',
|
||||
'journey.studio.saveLayout': 'حفظ هذه الصفحة كتخطيط',
|
||||
@@ -570,8 +572,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.deletePage': 'حذف الصفحة',
|
||||
'journey.studio.movePageUp': 'نقل للأمام',
|
||||
'journey.studio.movePageDown': 'نقل للخلف',
|
||||
'journey.studio.beta': 'Beta',
|
||||
'journey.studio.addProsCons': 'Pros & Cons',
|
||||
'journey.studio.beta': 'تجريبي',
|
||||
'journey.studio.addProsCons': 'الإيجابيات والسلبيات',
|
||||
'journey.studio.showMarks': 'علامات',
|
||||
'journey.studio.formatCustom': 'مقاس مخصص',
|
||||
'journey.studio.document': 'المستند',
|
||||
|
||||
@@ -70,12 +70,12 @@ const login: TranslationStrings = {
|
||||
'login.resetPasswordInvalidLink': 'رابط إعادة تعيين غير صالح',
|
||||
'login.resetPasswordInvalidLinkBody': 'هذا الرابط مفقود أو تالف. اطلب رابطًا جديدًا للمتابعة.',
|
||||
'login.resetPasswordFailed': 'فشلت إعادة التعيين. ربما انتهت صلاحية الرابط.',
|
||||
'login.emailPlaceholder': 'your@email.com', // en-fallback
|
||||
'login.emailPlaceholder': 'your@email.com',
|
||||
'login.passkey.signIn': 'تسجيل الدخول باستخدام مفتاح المرور',
|
||||
'login.passkey.failed': 'فشل تسجيل الدخول بمفتاح المرور. يرجى المحاولة مرة أخرى.',
|
||||
'login.insecureCookie.title': "Login won't stick over HTTP",
|
||||
'login.insecureCookie.title': 'لن يستمر تسجيل الدخول عبر HTTP',
|
||||
'login.insecureCookie.body':
|
||||
'You’re connecting over plain HTTP, so your browser drops TREK’s secure session cookie — the next request fails with "Access token required". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.',
|
||||
'login.insecureCookie.link': 'Open the Troubleshooting guide',
|
||||
'أنت تتصل عبر HTTP العادي، لذلك يتجاهل المتصفح ملف تعريف الارتباط الآمن للجلسة في TREK، وسيفشل الطلب التالي مع الرسالة "Access token required". الحل: استخدم HTTPS، أو اضبط COOKIE_SECURE=false في المختبر المنزلي.',
|
||||
'login.insecureCookie.link': 'افتح دليل استكشاف الأخطاء',
|
||||
};
|
||||
export default login;
|
||||
|
||||
@@ -14,6 +14,6 @@ const nav: TranslationStrings = {
|
||||
'nav.bottomAdmin': 'إعدادات المسؤول',
|
||||
'nav.bottomLogout': 'تسجيل الخروج',
|
||||
'nav.bottomAdminBadge': 'مسؤول',
|
||||
'nav.help': 'Help',
|
||||
'nav.help': 'المساعدة',
|
||||
};
|
||||
export default nav;
|
||||
|
||||
+19
-19
@@ -72,32 +72,32 @@ const oauth: TranslationStrings = {
|
||||
'oauth.scope.journey:write.description': 'إنشاء مذكرات السفر وتحديثها وحذفها وإدخالاتها',
|
||||
'oauth.scope.journey:share.label': 'إدارة روابط مذكرات السفر',
|
||||
'oauth.scope.journey:share.description': 'إنشاء روابط مشاركة عامة لمذكرات السفر وتحديثها وإلغاؤها',
|
||||
'oauth.scope.group.atlas': 'Atlas', // en-fallback
|
||||
'oauth.scope.group.geo': 'Geo', // en-fallback
|
||||
'oauth.authorize.authorizing': 'Authorizing…', // en-fallback
|
||||
'oauth.authorize.loading': 'Loading…', // en-fallback
|
||||
'oauth.authorize.errorTitle': 'Authorization Error', // en-fallback
|
||||
'oauth.authorize.loginTitle': 'Sign in to continue', // en-fallback
|
||||
'oauth.authorize.loginDescription': '{client} wants access to your TREK account. Please sign in first.', // en-fallback
|
||||
'oauth.authorize.loginButton': 'Sign in to TREK', // en-fallback
|
||||
'oauth.authorize.requestLabel': 'Authorization Request', // en-fallback
|
||||
'oauth.authorize.requestDescription': 'This application is requesting access to your TREK account.', // en-fallback
|
||||
'oauth.scope.group.atlas': 'Atlas',
|
||||
'oauth.scope.group.geo': 'الموقع الجغرافي',
|
||||
'oauth.authorize.authorizing': 'جارٍ التفويض…',
|
||||
'oauth.authorize.loading': 'جارٍ التحميل…',
|
||||
'oauth.authorize.errorTitle': 'خطأ في التفويض',
|
||||
'oauth.authorize.loginTitle': 'سجّل الدخول للمتابعة',
|
||||
'oauth.authorize.loginDescription': 'يريد {client} الوصول إلى حسابك في TREK. يرجى تسجيل الدخول أولًا.',
|
||||
'oauth.authorize.loginButton': 'تسجيل الدخول إلى TREK',
|
||||
'oauth.authorize.requestLabel': 'طلب تفويض',
|
||||
'oauth.authorize.requestDescription': 'يطلب هذا التطبيق الوصول إلى حسابك في TREK.',
|
||||
'oauth.authorize.verifiedHost': 'نطاق موثَّق: {host}',
|
||||
'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.trustNote': 'امنح الوصول فقط للتطبيقات التي تثق بها. تبقى بياناتك على خادمك.',
|
||||
'oauth.authorize.selectScope': 'اختر نطاق صلاحيات واحدًا على الأقل',
|
||||
'oauth.authorize.approveScopes': 'موافقة ({count} نطاق)',
|
||||
'oauth.authorize.approveScopes.zero': 'موافقة (بلا نطاقات)',
|
||||
'oauth.authorize.approveScopes.one': 'موافقة (نطاق واحد)',
|
||||
'oauth.authorize.approveScopes.two': 'موافقة (نطاقان)',
|
||||
'oauth.authorize.approveScopes.few': 'موافقة ({count} نطاقات)',
|
||||
'oauth.authorize.approveScopes.many': 'موافقة ({count} نطاقًا)',
|
||||
'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
|
||||
'oauth.authorize.approveAccess': 'الموافقة على الوصول',
|
||||
'oauth.authorize.deny': 'رفض',
|
||||
'oauth.authorize.choosePermissions': 'اختر الصلاحيات التي تريد منحها',
|
||||
'oauth.authorize.permissionsRequested': 'الصلاحيات المطلوبة',
|
||||
'oauth.authorize.alwaysIncluded': 'مشمولة دائمًا',
|
||||
'oauth.authorize.alwaysTool.listTrips': 'عرض رحلاتك ليتمكن الذكاء الاصطناعي من معرفة معرّفات الرحلات',
|
||||
'oauth.authorize.alwaysTool.getTripSummary': 'قراءة نظرة عامة على الرحلة، وهي لازمة لاستخدام أي أداة أخرى',
|
||||
'oauth.scope.group.files': 'الملفات',
|
||||
'oauth.scope.group.settings': 'الإعدادات',
|
||||
'oauth.scope.files:read.label': 'عرض ملفات الرحلة',
|
||||
|
||||
@@ -83,8 +83,9 @@ const settings: TranslationStrings = {
|
||||
'settings.bookingLabels': 'تسميات مسارات الحجوزات',
|
||||
'settings.bookingLabelsHint': 'عرض أسماء المحطات/المطارات على الخريطة. عند الإيقاف، يتم عرض الرمز فقط.',
|
||||
'settings.blurBookingCodes': 'إخفاء رموز الحجز',
|
||||
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
|
||||
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
|
||||
'settings.aiAlwaysRetry': 'إعادة محاولة استيراد الحجوزات دائمًا بالذكاء الاصطناعي',
|
||||
'settings.aiAlwaysRetryHint':
|
||||
'عندما يتعذّر على المحلّل القياسي قراءة ملف، تتم إعادة المحاولة تلقائيًا بالذكاء الاصطناعي.',
|
||||
'settings.optimizeFromAccommodation': 'تحسين المسار انطلاقًا من مكان الإقامة',
|
||||
'settings.optimizeFromAccommodationHint':
|
||||
'عند تحسين يوم ما، يبدأ المسار من الفندق الذي تستيقظ فيه وينتهي عند الفندق الذي تسجّل الوصول إليه في تلك الليلة.',
|
||||
@@ -220,9 +221,9 @@ const settings: TranslationStrings = {
|
||||
'settings.about.featureRequestHint': 'اقترح ميزة جديدة',
|
||||
'settings.about.wikiHint': 'التوثيق والأدلة',
|
||||
'settings.about.descriptionManaged':
|
||||
'TREK helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place.',
|
||||
'settings.about.sourceTitle': 'Source code',
|
||||
'settings.about.sourceHint': 'TREK is open source, licensed AGPL-3.0',
|
||||
'يساعدك TREK على تنظيم رحلاتك من الفكرة الأولى حتى آخر ذكرى. تخطيط الأيام والميزانية وقوائم الأمتعة والصور وأكثر من ذلك بكثير، كل ذلك في مكان واحد.',
|
||||
'settings.about.sourceTitle': 'الشيفرة المصدرية',
|
||||
'settings.about.sourceHint': 'TREK مفتوح المصدر بترخيص AGPL-3.0',
|
||||
'settings.about.supporters.badge': 'الداعمون الشهريون',
|
||||
'settings.about.supporters.title': 'رفاق رحلة TREK',
|
||||
'settings.about.supporters.subtitle':
|
||||
@@ -302,10 +303,10 @@ const settings: TranslationStrings = {
|
||||
'settings.mfa.toastEnabled': 'تم تفعيل المصادقة الثنائية',
|
||||
'settings.mfa.toastDisabled': 'تم تعطيل المصادقة الثنائية',
|
||||
'settings.mfa.demoBlocked': 'غير متاح في الوضع التجريبي',
|
||||
'settings.tabs.offline': 'Offline', // en-fallback
|
||||
'settings.notificationPreferences.email': 'Email', // en-fallback
|
||||
'settings.notificationPreferences.webhook': 'Webhook', // en-fallback
|
||||
'settings.notificationPreferences.inapp': 'In-App', // en-fallback
|
||||
'settings.tabs.offline': 'دون اتصال',
|
||||
'settings.notificationPreferences.email': 'البريد الإلكتروني',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
'settings.notificationPreferences.inapp': 'داخل التطبيق',
|
||||
'settings.notificationPreferences.notConfigured': 'لم يتم الإعداد بعد — قم بإعداده من إعدادات الإضافة',
|
||||
'settings.notificationPreferences.lockedByAdmin': 'أوقفه المسؤول للجميع',
|
||||
'settings.plugins.actions': 'الإجراءات',
|
||||
@@ -315,7 +316,7 @@ const settings: TranslationStrings = {
|
||||
'settings.notificationPreferences.testSuccess': 'تم إرسال إشعار اختباري.',
|
||||
'settings.notificationPreferences.testFailed': 'فشل الاختبار.',
|
||||
'settings.notificationPreferences.pluginConfigured': 'تم الإعداد. أدر بيانات الاعتماد من صفحة إعدادات الإضافة.',
|
||||
'settings.notificationPreferences.ntfy': 'Ntfy', // en-fallback
|
||||
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||
'settings.notificationPreferences.push': 'إشعارات فورية',
|
||||
'settings.webPush.title': 'الإشعارات الفورية على هذا الجهاز',
|
||||
'settings.webPush.hint':
|
||||
@@ -328,15 +329,15 @@ const settings: TranslationStrings = {
|
||||
'settings.webPush.iosInstall': 'على iPhone وiPad، أضف TREK أولًا إلى الشاشة الرئيسية ثم افتحه من هناك.',
|
||||
'settings.webPush.denied': 'الإشعارات محظورة لـ TREK في هذا المتصفح. اسمح بها في إعدادات المتصفح، ثم حاول مرة أخرى.',
|
||||
'settings.webPush.failed': 'تعذّر تشغيل الإشعارات الفورية على هذا الجهاز.',
|
||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', // en-fallback
|
||||
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts', // en-fallback
|
||||
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh', // en-fallback
|
||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
||||
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
|
||||
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
|
||||
'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth', // en-fallback
|
||||
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket', // en-fallback
|
||||
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP', // en-fallback
|
||||
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer', // en-fallback
|
||||
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller', // en-fallback
|
||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate', // en-fallback
|
||||
'settings.about.supporter.tier.noReturnTicket': 'بلا تذكرة عودة',
|
||||
'settings.about.supporter.tier.lostLuggageVip': 'VIP الحقائب المفقودة',
|
||||
'settings.about.supporter.tier.businessClassDreamer': 'حالم بدرجة رجال الأعمال',
|
||||
'settings.about.supporter.tier.budgetTraveller': 'مسافر اقتصادي',
|
||||
'settings.about.supporter.tier.hostelBunkmate': 'رفيق سرير الهوستل',
|
||||
'settings.currency': 'عملة العرض',
|
||||
'settings.currencyHint': 'تُعرض المبالغ في التكاليف محوّلة إلى هذه العملة للعرض فقط؛ تبقى المبالغ الأصلية دون تغيير.',
|
||||
'settings.currencyTrip': 'عملة الرحلة',
|
||||
@@ -411,70 +412,68 @@ const settings: TranslationStrings = {
|
||||
'settings.aiParsing.multimodalHint': 'فعّله لنموذج يقرأ الصور، ليمكن استيراد صورة أو مسحها.',
|
||||
'settings.aiParsing.toast.saved': 'تم حفظ إعدادات الذكاء الاصطناعي',
|
||||
'settings.aiParsing.toast.saveError': 'تعذّر حفظ إعدادات الذكاء الاصطناعي',
|
||||
'settings.tabs.appearance': 'Appearance',
|
||||
'settings.appearance.theme': 'Theme',
|
||||
'settings.appearance.scheme': 'Color scheme',
|
||||
'settings.appearance.scheme.default': 'Default',
|
||||
'settings.appearance.scheme.highContrast': 'High contrast',
|
||||
'settings.appearance.scheme.indigo': 'Indigo',
|
||||
'settings.appearance.scheme.teal': 'Teal',
|
||||
'settings.appearance.scheme.rose': 'Rose',
|
||||
'settings.appearance.scheme.amber': 'Amber',
|
||||
'settings.appearance.scheme.violet': 'Violet',
|
||||
'settings.appearance.scheme.custom': 'Custom',
|
||||
'settings.appearance.customAccent': 'Custom accent',
|
||||
'settings.appearance.contrastOk': 'Good contrast',
|
||||
'settings.appearance.contrastLow': 'Low contrast',
|
||||
'settings.appearance.readability': 'Readability',
|
||||
'settings.appearance.transparency': 'Transparency',
|
||||
'settings.appearance.transparencyHint':
|
||||
'Glassy translucent surfaces. Turn off for solid, higher-contrast backgrounds.',
|
||||
'settings.appearance.reduceMotion': 'Reduce motion',
|
||||
'settings.appearance.reduceMotionHint': 'Minimize animations and transitions.',
|
||||
'settings.appearance.density': 'Density',
|
||||
'settings.appearance.comfortable': 'Comfortable',
|
||||
'settings.appearance.compact': 'Compact',
|
||||
'settings.appearance.textSize': 'Text size',
|
||||
'settings.appearance.advancedTextSizes': 'Advanced text sizes',
|
||||
'settings.appearance.hideAdvanced': 'Hide advanced',
|
||||
'settings.appearance.tier.title': 'Titles',
|
||||
'settings.appearance.tier.subtitle': 'Subtitles',
|
||||
'settings.appearance.tier.body': 'Body',
|
||||
'settings.appearance.tier.caption': 'Captions',
|
||||
'settings.appearance.dashboardWidgets': 'Dashboard widgets',
|
||||
'settings.appearance.dashboardWidgetsHint': 'Show or hide dashboard widgets independently on desktop and mobile.',
|
||||
'settings.appearance.desktop': 'Desktop',
|
||||
'settings.appearance.mobile': 'Mobile',
|
||||
'settings.appearance.widget.sidebar': 'Right sidebar',
|
||||
'settings.appearance.widget.currency': 'Currency',
|
||||
'settings.tabs.appearance': 'المظهر',
|
||||
'settings.appearance.theme': 'السمة',
|
||||
'settings.appearance.scheme': 'نظام الألوان',
|
||||
'settings.appearance.scheme.default': 'افتراضي',
|
||||
'settings.appearance.scheme.highContrast': 'تباين عالٍ',
|
||||
'settings.appearance.scheme.indigo': 'نيلي',
|
||||
'settings.appearance.scheme.teal': 'أزرق مخضر',
|
||||
'settings.appearance.scheme.rose': 'وردي',
|
||||
'settings.appearance.scheme.amber': 'كهرماني',
|
||||
'settings.appearance.scheme.violet': 'بنفسجي',
|
||||
'settings.appearance.scheme.custom': 'مخصص',
|
||||
'settings.appearance.customAccent': 'لون مميز مخصص',
|
||||
'settings.appearance.contrastOk': 'تباين جيد',
|
||||
'settings.appearance.contrastLow': 'تباين منخفض',
|
||||
'settings.appearance.readability': 'سهولة القراءة',
|
||||
'settings.appearance.transparency': 'الشفافية',
|
||||
'settings.appearance.transparencyHint': 'أسطح زجاجية شبه شفافة. أوقفها للحصول على خلفيات صلبة بتباين أعلى.',
|
||||
'settings.appearance.reduceMotion': 'تقليل الحركة',
|
||||
'settings.appearance.reduceMotionHint': 'تقليل الرسوم المتحركة والانتقالات.',
|
||||
'settings.appearance.density': 'الكثافة',
|
||||
'settings.appearance.comfortable': 'مريحة',
|
||||
'settings.appearance.compact': 'مضغوطة',
|
||||
'settings.appearance.textSize': 'حجم النص',
|
||||
'settings.appearance.advancedTextSizes': 'أحجام نص متقدمة',
|
||||
'settings.appearance.hideAdvanced': 'إخفاء المتقدم',
|
||||
'settings.appearance.tier.title': 'العناوين',
|
||||
'settings.appearance.tier.subtitle': 'العناوين الفرعية',
|
||||
'settings.appearance.tier.body': 'النص الأساسي',
|
||||
'settings.appearance.tier.caption': 'التعليقات',
|
||||
'settings.appearance.dashboardWidgets': 'أدوات لوحة التحكم',
|
||||
'settings.appearance.dashboardWidgetsHint': 'أظهر أدوات لوحة التحكم أو أخفِها بشكل مستقل على سطح المكتب والجوال.',
|
||||
'settings.appearance.desktop': 'سطح المكتب',
|
||||
'settings.appearance.mobile': 'الجوال',
|
||||
'settings.appearance.widget.sidebar': 'الشريط الجانبي الأيمن',
|
||||
'settings.appearance.widget.currency': 'العملة',
|
||||
'settings.appearance.widget.collections': 'المجموعات',
|
||||
'settings.appearance.widget.timezones': 'Timezones',
|
||||
'settings.appearance.widget.upcomingReservations': 'Upcoming reservations',
|
||||
'settings.appearance.widget.atlas': 'Atlas / countries',
|
||||
'settings.appearance.widget.tripsTotal': 'Trips total',
|
||||
'settings.appearance.widget.daysTraveled': 'Days traveled',
|
||||
'settings.appearance.widget.distanceFlown': 'Distance flown',
|
||||
'settings.appearance.reset': 'Reset to defaults',
|
||||
'settings.appearance.group.belowHero': 'Below the hero',
|
||||
'settings.appearance.group.bottomOfPage': 'Bottom of page',
|
||||
'settings.appearance.sidebarHint': 'The whole right column. Turn off and the dashboard centers.',
|
||||
'settings.appearance.densityHint':
|
||||
'Compact tightens spacing and padding for a denser layout that fits more on screen.',
|
||||
'settings.appearance.textSizeAll': 'Everything',
|
||||
'settings.appearance.perSize': 'Adjust each size separately',
|
||||
'settings.appearance.size.large': 'Large',
|
||||
'settings.appearance.size.medium': 'Medium',
|
||||
'settings.appearance.size.normal': 'Normal',
|
||||
'settings.appearance.size.small': 'Small',
|
||||
'settings.appearance.preview.large': 'Large heading',
|
||||
'settings.appearance.preview.medium': 'Medium subtitle',
|
||||
'settings.appearance.preview.normal': 'Normal body text',
|
||||
'settings.appearance.preview.small': 'Small caption / address',
|
||||
'settings.appearance.example.large': 'Headings, big numbers',
|
||||
'settings.appearance.example.medium': 'Sub-headings',
|
||||
'settings.appearance.example.normal': 'Place names, descriptions',
|
||||
'settings.appearance.example.small': 'Addresses, labels',
|
||||
'settings.appearance.experimental': 'Experimental',
|
||||
'settings.appearance.widget.timezones': 'المناطق الزمنية',
|
||||
'settings.appearance.widget.upcomingReservations': 'الحجوزات القادمة',
|
||||
'settings.appearance.widget.atlas': 'Atlas / الدول',
|
||||
'settings.appearance.widget.tripsTotal': 'إجمالي الرحلات',
|
||||
'settings.appearance.widget.daysTraveled': 'أيام السفر',
|
||||
'settings.appearance.widget.distanceFlown': 'المسافة المقطوعة جوًا',
|
||||
'settings.appearance.reset': 'استعادة الإعدادات الافتراضية',
|
||||
'settings.appearance.group.belowHero': 'أسفل الواجهة الرئيسية',
|
||||
'settings.appearance.group.bottomOfPage': 'أسفل الصفحة',
|
||||
'settings.appearance.sidebarHint': 'العمود الأيمن بالكامل. عند إيقافه تتوسط لوحة التحكم.',
|
||||
'settings.appearance.densityHint': 'المضغوطة تقلل المسافات والحشوة لتخطيط أكثر كثافة يتسع لمحتوى أكثر على الشاشة.',
|
||||
'settings.appearance.textSizeAll': 'الكل',
|
||||
'settings.appearance.perSize': 'اضبط كل حجم على حدة',
|
||||
'settings.appearance.size.large': 'كبير',
|
||||
'settings.appearance.size.medium': 'متوسط',
|
||||
'settings.appearance.size.normal': 'عادي',
|
||||
'settings.appearance.size.small': 'صغير',
|
||||
'settings.appearance.preview.large': 'عنوان كبير',
|
||||
'settings.appearance.preview.medium': 'عنوان فرعي متوسط',
|
||||
'settings.appearance.preview.normal': 'نص أساسي عادي',
|
||||
'settings.appearance.preview.small': 'تعليق صغير / عنوان',
|
||||
'settings.appearance.example.large': 'العناوين والأرقام الكبيرة',
|
||||
'settings.appearance.example.medium': 'العناوين الفرعية',
|
||||
'settings.appearance.example.normal': 'أسماء الأماكن والأوصاف',
|
||||
'settings.appearance.example.small': 'العناوين والتسميات',
|
||||
'settings.appearance.experimental': 'تجريبي',
|
||||
'settings.appearance.mobileNav': 'شريط التنقّل السفلي',
|
||||
'settings.appearance.mobileNav.hint':
|
||||
'اختَر العناصر التي تظهر في الشريط وتلك التي توضَع ضمن «المزيد». تبقى لوحة التحكّم دائماً في المقدّمة.',
|
||||
@@ -489,8 +488,8 @@ const settings: TranslationStrings = {
|
||||
'أعد ترتيب تسلسل قائمة الرحلات والأدوات على لوحة تحكم هاتفك. تبقى الرحلة المميّزة دائمًا في الأعلى.',
|
||||
'settings.appearance.dashOrder.trips': 'الرحلات',
|
||||
'settings.appearance.dashOrder.hidden': 'مخفي',
|
||||
'settings.general.languageRegion': 'Language & region',
|
||||
'settings.general.travelMap': 'Travel & map',
|
||||
'settings.general.languageRegion': 'اللغة والمنطقة',
|
||||
'settings.general.travelMap': 'السفر والخريطة',
|
||||
'settings.general.startup': 'البدء',
|
||||
'settings.dayDateFirst': 'التاريخ أولًا في عناوين الأيام',
|
||||
'settings.compactUnplanned': 'علامات مصغّرة للأماكن غير المخطط لها',
|
||||
|
||||
@@ -6,7 +6,7 @@ const storage: TranslationStrings = {
|
||||
'storage.field.endpoint': 'عنوان URL لنقطة النهاية',
|
||||
'storage.help.endpoint':
|
||||
'عنوان URL الأساسي للخدمة المتوافقة مع S3، مثل https://s3.example.com أو http://127.0.0.1:9000.',
|
||||
'storage.field.bucket': 'Bucket',
|
||||
'storage.field.bucket': 'الحاوية (Bucket)',
|
||||
'storage.field.accessKeyId': 'معرّف مفتاح الوصول',
|
||||
'storage.field.secretAccessKey': 'مفتاح الوصول السري',
|
||||
'storage.field.region': 'المنطقة',
|
||||
|
||||
@@ -42,17 +42,17 @@ const system_notice: TranslationStrings = {
|
||||
'system_notice.pager.next': 'الإشعار التالي',
|
||||
'system_notice.pager.goto': 'الانتقال إلى الإشعار {n}',
|
||||
'system_notice.pager.position': 'الإشعار {current} من {total}',
|
||||
'system_notice.dev_test_modal.title': '[Dev] Test notice', // en-fallback
|
||||
'system_notice.dev_test_modal.body': 'This is a dev-only test notice.', // en-fallback
|
||||
'system_notice.dev_test_modal.title': '[Dev] إشعار تجريبي',
|
||||
'system_notice.dev_test_modal.body': 'هذا إشعار تجريبي للمطورين فقط.',
|
||||
'system_notice.thank_you_support.title': 'شكرًا لاستخدامك TREK',
|
||||
'system_notice.thank_you_support.body':
|
||||
'شكرًا سريعًا على تثبيتك TREK — هذا يعني لي الكثير حقًا.\n\nأنا مطوّر منفرد أبني TREK في وقت فراغي. بدأ كأداة صغيرة لرحلاتي الخاصة فحسب، وصدقًا أنا مندهش من الدعم والاهتمام اللذين أبداهما المجتمع منذ ذلك الحين. TREK مصنوع بكثير من الحب من جانبي — ولكن أيضًا بفضل العديد من المساهمين الخارجيين الرائعين الذين ساعدوا في تشكيله.\n\n**TREK مفتوح المصدر ومجاني تمامًا — وسيبقى كذلك إلى الأبد. لا باقات مدفوعة، لا اشتراكات، لا شروط خفية. أعدكم بذلك.**\n\nإذا كان TREK مفيدًا لك وأردت دعم تطويره، فإن فنجان قهوة صغيرًا يساعدني حقًا على مواصلة البناء — لا ضغط على الإطلاق، لكن كل فنجان يبقي الليالي المتأخرة مستمرة.\n\nشكرًا لوجودك هنا.\n\n— Maurice',
|
||||
'system_notice.thank_you_support.highlight_opensource': 'مفتوح المصدر 100% على GitHub',
|
||||
'system_notice.thank_you_support.highlight_free': 'مجاني للأبد — لا باقات مدفوعة أبدًا',
|
||||
'system_notice.thank_you_support.highlight_community': 'مبني بالتعاون مع المجتمع',
|
||||
'system_notice.thank_you_support.cta_bmc': 'Buy Me a Coffee',
|
||||
'system_notice.thank_you_support.cta_bmc': 'اشترِ لي قهوة',
|
||||
'system_notice.thank_you_support.cta_kofi': 'ادعمني على Ko-fi',
|
||||
'system_notice.pager.counter': '{current} / {total}', // en-fallback
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.release_notes.eyebrow': 'تم تثبيت التحديث',
|
||||
'system_notice.release_notes.headline': 'أربعة أشياء يقوم بها TREK الآن بنفسه.',
|
||||
'system_notice.release_notes.intro':
|
||||
@@ -84,7 +84,7 @@ const system_notice: TranslationStrings = {
|
||||
'صحيح أن TREK مجاني وسيبقى كذلك دائمًا، لكن الخوادم والنطاقات والكثير من الليالي المتأخرة ليست مجانية.',
|
||||
'system_notice.release_notes.support_text':
|
||||
'إن كان TREK قد استحق مكانًا في رحلاتك، فاشترِ لي فنجان قهوة وساعد في أن يرى الإصدار القادم النور.',
|
||||
'system_notice.release_notes.cta_bmc': 'Buy me a coffee',
|
||||
'system_notice.release_notes.cta_bmc': 'اشترِ لي قهوة',
|
||||
'system_notice.release_notes.cta_kofi': 'ادعمني على Ko-fi',
|
||||
'system_notice.release_notes.feature_docsync_title': 'مزامنة المستندات',
|
||||
'system_notice.release_notes.feature_docsync_body':
|
||||
|
||||
@@ -10,7 +10,7 @@ const undo: TranslationStrings = {
|
||||
'undo.optimize': 'تم تحسين المسار',
|
||||
'undo.deletePlace': 'تم حذف المكان',
|
||||
'undo.deletePlaces': 'تم حذف الأماكن',
|
||||
'undo.changeCategory': 'Category changed',
|
||||
'undo.changeCategory': 'تم تغيير الفئة',
|
||||
'undo.moveDay': 'تم نقل المكان إلى يوم آخر',
|
||||
'undo.lock': 'تم تبديل قفل المكان',
|
||||
'undo.importGpx': 'استيراد GPX',
|
||||
|
||||
@@ -70,14 +70,14 @@ const vacay: TranslationStrings = {
|
||||
'vacay.sat': 'السبت',
|
||||
'vacay.sun': 'الأحد',
|
||||
'vacay.publicHolidays': 'العطل الرسمية',
|
||||
'vacay.schoolHolidays': 'School Holidays',
|
||||
'vacay.schoolHolidaysHint': 'Mark school holidays as a visual calendar layer',
|
||||
'vacay.schoolHolidays': 'العطل المدرسية',
|
||||
'vacay.schoolHolidaysHint': 'تمييز العطل المدرسية كطبقة مرئية في التقويم',
|
||||
'vacay.selectCountry': 'اختر الدولة',
|
||||
'vacay.selectRegion': 'اختر المنطقة (مطلوب)',
|
||||
'vacay.addCalendar': 'إضافة تقويم',
|
||||
'vacay.calendarLabel': 'التسمية',
|
||||
'vacay.calendarColor': 'اللون',
|
||||
'vacay.noSchoolCalendars': 'No school holiday calendars added yet',
|
||||
'vacay.noSchoolCalendars': 'لم تتم إضافة تقاويم عطل مدرسية بعد',
|
||||
'vacay.companyHolidays': 'عطل الشركة',
|
||||
'vacay.companyHolidaysHint': 'السماح بوضع علامة على أيام عطلات الشركة',
|
||||
'vacay.companyHolidaysNoDeduct': 'لا تُخصم عطل الشركة من أيام الإجازة.',
|
||||
@@ -148,7 +148,7 @@ const vacay: TranslationStrings = {
|
||||
'vacay.hideFromCalendar': 'إخفاء من التقويم',
|
||||
'vacay.sharedEmpty': 'اعرضا تقويميكما لبعضكما البعض دون دمجهما.',
|
||||
'vacay.sharedLegend': 'مشترك (عرض فقط)',
|
||||
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
|
||||
'vacay.noCalendars': 'No holiday calendars added yet',
|
||||
'vacay.publicHolidaysHint': 'تمييز العطل الرسمية في التقويم',
|
||||
'vacay.noCalendars': 'لم تتم إضافة تقاويم عطل بعد',
|
||||
};
|
||||
export default vacay;
|
||||
|
||||
@@ -41,7 +41,7 @@ const admin: TranslationStrings = {
|
||||
'İstifadəçi ntfy bildirişləri üçün standart server kimi də istifadə edilir. Standart olaraq ntfy.sh istifadə etmək üçün boş saxlayın. İstifadəçilər bunu öz tənzimləmələrində dəyişə bilərlər.',
|
||||
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||
'admin.notifications.adminNtfyPanel.topicLabel': 'Administrator mövzusu',
|
||||
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts', // same-as-en
|
||||
'admin.notifications.adminNtfyPanel.tokenLabel': 'Giriş tokeni (istəyə bağlı)',
|
||||
'admin.notifications.adminNtfyPanel.tokenCleared': 'Administrator giriş tokeni təmizləndi',
|
||||
'admin.notifications.adminNtfyPanel.saved': 'Administrator ntfy tənzimləmələri yadda saxlanıldı',
|
||||
@@ -77,7 +77,7 @@ const admin: TranslationStrings = {
|
||||
'admin.tabs.categories': 'Kateqoriyalar',
|
||||
'admin.tabs.backup': 'Ehtiyat nüsxə',
|
||||
'admin.tabs.notifications': 'Bildirişlər',
|
||||
'admin.tabs.audit': 'Audit',
|
||||
'admin.tabs.audit': 'Audit', // same-as-en
|
||||
'admin.stats.users': 'İstifadəçi',
|
||||
'admin.stats.users.one': 'İstifadəçi',
|
||||
'admin.stats.trips': 'Səyahət',
|
||||
@@ -191,7 +191,7 @@ const admin: TranslationStrings = {
|
||||
'TREK-in öz indeksi və OpenStreetMap hər axtarışa cavab verir. Onlar heç nə tapmadıqda başqa kimə sorğu göndəriləcəyini bu seçim müəyyən edir: Avtomatik rejim açar mövcud olduqda əvvəlcə Google-a, sonra Amap-a üstünlük verir.',
|
||||
'admin.placesProvider.auto': 'Avtomatik',
|
||||
'admin.placesProvider.google': 'Google Places',
|
||||
'admin.placesProvider.amap': 'Amap (高德地图)',
|
||||
'admin.placesProvider.amap': 'Amap (高德地图)', // same-as-en
|
||||
'admin.placesProvider.openstreetmap': 'OpenStreetMap',
|
||||
'admin.placesProvider.missingKey':
|
||||
'Seçilmiş provayder üçün API açarı konfiqurasiya edilməyib, buna görə məkan axtarışına yalnız TREK indeksi və OpenStreetMap cavab verir.',
|
||||
@@ -705,7 +705,7 @@ const admin: TranslationStrings = {
|
||||
'admin.addons.catalog.vacay.description': 'Təqvim görünüşlü şəxsi məzuniyyət planlayıcısı',
|
||||
'admin.addons.catalog.atlas.name': 'Atlas',
|
||||
'admin.addons.catalog.atlas.description': 'Ziyarət edilmiş ölkələr və səyahət statistikası ilə dünya xəritəsi',
|
||||
'admin.addons.catalog.collab.name': 'Collab',
|
||||
'admin.addons.catalog.collab.name': 'Collab', // same-as-en
|
||||
'admin.addons.catalog.collab.description': 'Birgə planlaşdırma üçün qeydlər, sorğular, çat və təkliflər',
|
||||
'admin.addons.catalog.roadtrip.name': 'Avtomobil səyahəti',
|
||||
'admin.addons.catalog.roadtrip.description':
|
||||
|
||||
@@ -46,7 +46,7 @@ const atlas: TranslationStrings = {
|
||||
'atlas.cities': 'Şəhər',
|
||||
'atlas.cities.one': 'Şəhər',
|
||||
'atlas.regions': 'Region',
|
||||
'atlas.regions.one': 'Region',
|
||||
'atlas.regions.one': 'Region', // same-as-en
|
||||
'atlas.noData': 'Hələ səyahət məlumatı yoxdur',
|
||||
'atlas.noDataHint': 'Dünya xəritənizi görmək üçün səyahət yaradın və məkanlar əlavə edin',
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ const budget: TranslationStrings = {
|
||||
'costs.table.byCategory': 'Kateqoriya',
|
||||
'costs.table.byDate': 'Gün',
|
||||
'costs.table.byPayer': 'Ödəyən',
|
||||
'costs.table.byStatus': 'Status',
|
||||
'costs.table.byStatus': 'Status', // same-as-en
|
||||
'costs.table.noPayer': 'Hələ ödəyən yoxdur',
|
||||
'costs.table.paid': 'Ödənilib',
|
||||
'costs.table.open': 'Açıq',
|
||||
|
||||
@@ -38,7 +38,7 @@ const dayplan: TranslationStrings = {
|
||||
'notes.format.italic': 'Maili',
|
||||
'notes.format.strike': 'Üstündən xətt çəkilmiş',
|
||||
'notes.format.code': 'Kod',
|
||||
'notes.format.link': 'Link',
|
||||
'notes.format.link': 'Keçid',
|
||||
'notes.format.list': 'Markerli siyahı',
|
||||
'notes.format.orderedList': 'Nömrələnmiş siyahı',
|
||||
'notes.format.quote': 'Sitat',
|
||||
|
||||
@@ -971,7 +971,7 @@ const help: TranslationStrings = {
|
||||
'Əl ilə təyin etdiyiniz status siz dəyişənə qədər qalır, səyahət tarixləri dəyişsə belə.',
|
||||
|
||||
// ── Screen: journey-studio ─────────────────────────────────────────────────
|
||||
'help.ctx.journey-studio.title': 'Studio',
|
||||
'help.ctx.journey-studio.title': 'Studio', // same-as-en
|
||||
'help.ctx.journey-studio.summary':
|
||||
'TREK Studio səyahət gündəliyini çap edilə bilən foto kitab kimi tərtib edir. Studio gündəliyin üzərində açılır: səhifələr siyahısı və məzmun solda, üzərində işlədiyiniz ikiqat səhifə ortada, onun xüsusiyyətləri isə sağdadır. «Avtomatik tərtibat» qeydlərinizdən ilk qaralamanı hazırlayır. Bundan sonra hər şeyin yerini dəyişə, kəsə və üslubunu yeniləyə bilərsiniz, hər addımı da geri almaq olar.',
|
||||
'help.ctx.journey-studio.bullet.1':
|
||||
@@ -1531,7 +1531,7 @@ const help: TranslationStrings = {
|
||||
'«Ehtiyat nüsxəni yüklə» «Bərpa et» ilə eyni sualdan sonra başqa instansiyada və ya əvvəlki gündə yaradılmış fayldan bərpa edir.',
|
||||
'help.ctx.admin-backup.bullet.3':
|
||||
'«Avtomatik ehtiyat nüsxələmə»: aktiv və ya deaktiv, «İnterval», «İcra saatı» və gün, həmçinin «Köhnə ehtiyat nüsxələri bu müddətdən sonra sil» (1 gündən «Həmişə saxla»ya qədər); hamısı «Yadda saxla» ilə tətbiq olunur.',
|
||||
'help.ctx.admin-audit.title': 'Audit',
|
||||
'help.ctx.admin-audit.title': 'Audit', // same-as-en
|
||||
'help.ctx.admin-audit.summary':
|
||||
'Təhlükəsizlik və idarəetmə baxımından vacib hadisələrin jurnalı: girişlər və uğursuz cəhdlər, MFA dəyişiklikləri, istifadəçi və tənzimləmə dəyişiklikləri, ehtiyat nüsxələr və bərpalar. Yalnız oxumaq üçündür, ən yenilər yuxarıdadır.',
|
||||
'help.ctx.admin-audit.bullet.1':
|
||||
@@ -3856,7 +3856,7 @@ const help: TranslationStrings = {
|
||||
'help.guide.files-sync.tip.3':
|
||||
'«İndi sinxronlaşdır» yanındakı ikon, «Bağlantını kəs», bir dəfə təsdiq istəyir. Sonra bağlantını bitirir və bütün sənədləri hər iki tərəfdə saxlayır. Etiketi, qovluğu və ya sahəni ikinci dəfə qoşsanız, TREK onu yeni kimi qəbul edir və içindəki hər şeyi yenidən idxal edir. Ona görə bağlantını kəsdikdən sonra köhnəsini yox, boş birini qoşun.',
|
||||
// ── Screen: trip-collab ───────────────────────────────────────────────────────────────
|
||||
'help.ctx.trip-collab.title': 'Collab',
|
||||
'help.ctx.trip-collab.title': 'Collab', // same-as-en
|
||||
'help.ctx.trip-collab.summary':
|
||||
'Qrupun birlikdə planlaşdırdığı bölmə beş kartdan ibarətdir: solda çat, yanında «Qeydlər» və «Linklər», onların altında «Sorğular» və «Növbədə nə var». Burada yazılan hər şey səhifəni yeniləmədən dərhal digər üzvlərin ekranında görünür.',
|
||||
'help.ctx.trip-collab.bullet.1':
|
||||
|
||||
@@ -248,7 +248,7 @@ const journey: TranslationStrings = {
|
||||
'journey.settings.autoPrepare': 'Qeydə alınmış günləri avtomatik hazırla',
|
||||
'journey.settings.autoPrepareHint':
|
||||
'Bağlı səyahət davam etdikcə Dawarich-dəki dayanacaqların öz günlərində yoxlamanı gözləyir. Öz bağlantısı olan redaktorlar özlərininkini alır. Jurnala heç nə öz-özünə yazılmır.',
|
||||
'journey.settings.status': 'Status',
|
||||
'journey.settings.status': 'Status', // same-as-en
|
||||
'journey.settings.statusAuto': 'Avtomatik',
|
||||
'journey.settings.statusAutoHint': 'Bağlı səfərlərin tarixlərinə uyğunlaşır. Səfər olmadan jurnal qaralama qalır.',
|
||||
'journey.settings.statusManualHint': 'Əllə təyin olunub. Avtomatikə qaytarana qədər səfər tarixləri onu dəyişmir.',
|
||||
@@ -308,8 +308,8 @@ const journey: TranslationStrings = {
|
||||
'journey.picker.selectDate': 'Tarix seç',
|
||||
'journey.picker.search': 'Axtar',
|
||||
|
||||
'journey.studio.title': 'TREK Studio',
|
||||
'journey.studio.open': 'Studio',
|
||||
'journey.studio.title': 'TREK Studio', // same-as-en
|
||||
'journey.studio.open': 'Studio', // same-as-en
|
||||
'journey.studio.openAria': 'Foto kitab studiyasını aç',
|
||||
'journey.studio.backToJourney': 'Səyahət gündəliyinə qayıt',
|
||||
'journey.studio.format': 'Səhifə formatı',
|
||||
@@ -459,7 +459,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.polaroidFrame': 'Polaroid',
|
||||
'journey.studio.whiteFrame': 'Ağ haşiyə',
|
||||
'journey.studio.shadowFrame': 'Kölgəli',
|
||||
'journey.studio.filmFrame': 'Film',
|
||||
'journey.studio.filmFrame': 'Film', // same-as-en
|
||||
'journey.studio.tapeFrame': 'Lentli',
|
||||
|
||||
'journey.studio.shapeGroup.basic': 'Əsas',
|
||||
@@ -473,7 +473,7 @@ const journey: TranslationStrings = {
|
||||
|
||||
'journey.studio.summary': 'Xülasə',
|
||||
'journey.studio.routeMap': 'Marşrut xəritəsi',
|
||||
'journey.studio.mapStyle.minimal': 'Minimal',
|
||||
'journey.studio.mapStyle.minimal': 'Minimal', // same-as-en
|
||||
'journey.studio.mapStyle.outline': 'Kontur',
|
||||
'journey.studio.mapStyle.paper': 'Kağız',
|
||||
'journey.studio.mapStyle.dark': 'Tünd',
|
||||
@@ -510,6 +510,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.copyToPage': 'İstənilən səhifəyə yapışdırmaq üçün kopyala (Ctrl+C)',
|
||||
'journey.studio.paste': 'Yapışdır (Ctrl+V)',
|
||||
'journey.studio.pasteEmpty': 'Əvvəlcə nəyisə kopyalayın, sonra onu istənilən səhifəyə yapışdırın',
|
||||
'journey.studio.pageFull':
|
||||
'Bu səhifə doludur: bir səhifəyə daha çox element sığmır. Əvvəlcə nəyisə silin və ya başqa səhifədən istifadə edin.',
|
||||
'journey.studio.myLayouts': 'Mənim tərtibatlarım',
|
||||
'journey.studio.myLayoutsEmpty':
|
||||
'Tərtib etdiyiniz səhifəni saxlayın və digər səhifələri eyni şəkildə düzün. Onların fotoları və mətnləri qalır.',
|
||||
@@ -594,7 +596,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.deletePage': 'Açılımı sil',
|
||||
'journey.studio.movePageUp': 'Əvvələ daşı',
|
||||
'journey.studio.movePageDown': 'Sonraya daşı',
|
||||
'journey.studio.beta': 'Beta',
|
||||
'journey.studio.beta': 'Beta', // same-as-en
|
||||
'journey.studio.addProsCons': 'Müsbət və mənfi cəhətlər',
|
||||
'journey.studio.showMarks': 'Nişanlar',
|
||||
|
||||
@@ -672,7 +674,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.mapFitStops': 'Dayanacaqlar',
|
||||
'journey.studio.mapFitCountry': 'Bütün ölkə',
|
||||
'journey.studio.mapPadTight': 'Dar',
|
||||
'journey.studio.mapPadNormal': 'Normal',
|
||||
'journey.studio.mapPadNormal': 'Normal', // same-as-en
|
||||
'journey.studio.mapPadWide': 'Geniş',
|
||||
'journey.studio.mapPadFar': 'Çox geniş',
|
||||
'journey.studio.mapClipRect': 'Çərçivə daxilində',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const mobileCollections: TranslationStrings = {
|
||||
'mobileCollections.status': 'Status',
|
||||
'mobileCollections.status': 'Status', // same-as-en
|
||||
};
|
||||
|
||||
export default mobileCollections;
|
||||
|
||||
@@ -2,18 +2,18 @@ import type { TranslationStrings } from '../types';
|
||||
|
||||
const nav: TranslationStrings = {
|
||||
'nav.settings': 'Tənzimləmələr',
|
||||
'nav.admin': 'Admin',
|
||||
'nav.admin': 'Admin', // same-as-en
|
||||
'nav.logout': 'Çıxış et',
|
||||
'nav.lightMode': 'İşıqlı rejim',
|
||||
'nav.darkMode': 'Qaranlıq rejim',
|
||||
'nav.autoMode': 'Avtomatik rejim',
|
||||
'nav.administrator': 'Administrator',
|
||||
'nav.administrator': 'Administrator', // same-as-en
|
||||
'nav.myTrips': 'Səyahətlərim',
|
||||
'nav.profile': 'Profil',
|
||||
'nav.bottomSettings': 'Tənzimləmələr',
|
||||
'nav.bottomAdmin': 'Admin tənzimləmələri',
|
||||
'nav.bottomLogout': 'Çıxış et',
|
||||
'nav.bottomAdminBadge': 'Admin',
|
||||
'nav.bottomAdminBadge': 'Admin', // same-as-en
|
||||
'nav.help': 'Kömək',
|
||||
};
|
||||
export default nav;
|
||||
|
||||
@@ -15,7 +15,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.showFiles': 'Faylları göstər',
|
||||
|
||||
'reservations.editTitle': 'Rezervasiyanı redaktə et',
|
||||
'reservations.status': 'Status',
|
||||
'reservations.status': 'Status', // same-as-en
|
||||
'reservations.datetime': 'Tarix və vaxt',
|
||||
'reservations.startTime': 'Başlanğıc vaxtı',
|
||||
'reservations.endTime': 'Bitmə vaxtı',
|
||||
@@ -24,7 +24,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.timeAlt': 'Vaxt (alternativ, məsələn, 19:30)',
|
||||
'reservations.notes': 'Qeydlər',
|
||||
'reservations.notesPlaceholder': 'Əlavə qeydlər...',
|
||||
'reservations.urlLabel': 'Link',
|
||||
'reservations.urlLabel': 'Keçid',
|
||||
'reservations.urlPlaceholder': 'https://...',
|
||||
|
||||
'reservations.meta.airline': 'Aviaşirkət',
|
||||
@@ -254,7 +254,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.export.notes': 'Qeydlər',
|
||||
'reservations.resetView': 'Görünüşü sıfırla',
|
||||
'reservations.group.label': 'Qruplaşdır',
|
||||
'reservations.group.status': 'Status',
|
||||
'reservations.group.status': 'Status', // same-as-en
|
||||
'reservations.group.day': 'Gün',
|
||||
'reservations.group.type': 'Növ',
|
||||
'reservations.group.none': 'Qruplaşdırmadan',
|
||||
@@ -266,7 +266,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.sort.date': 'Tarix',
|
||||
'reservations.sort.title': 'Başlıq',
|
||||
'reservations.sort.type': 'Növ',
|
||||
'reservations.sort.status': 'Status',
|
||||
'reservations.sort.status': 'Status', // same-as-en
|
||||
'reservations.sort.earlyFirst': 'Əvvəlcə ən erkən',
|
||||
'reservations.sort.lateFirst': 'Əvvəlcə ən gec',
|
||||
'reservations.sort.aToZ': 'A-dan Z-yə',
|
||||
|
||||
@@ -155,7 +155,7 @@ const settings: TranslationStrings = {
|
||||
'settings.notificationPreferences.pluginConfigured':
|
||||
'Konfiqurasiya edilib. Giriş məlumatlarını plaginin tənzimləmələr səhifəsindən idarə edin.',
|
||||
'settings.notificationPreferences.ntfy': 'Ntfy',
|
||||
'settings.notificationPreferences.push': 'Push',
|
||||
'settings.notificationPreferences.push': 'Push', // same-as-en
|
||||
'settings.webPush.title': 'Bu cihazda push bildirişləri',
|
||||
'settings.webPush.hint':
|
||||
'TREK bağlı olanda belə TREK bildirişlərini bu cihazda göstərir. Hansı hadisələrin gəldiyini Push sütunu müəyyən edir.',
|
||||
@@ -180,7 +180,7 @@ const settings: TranslationStrings = {
|
||||
'settings.webhookUrl.testFailed': 'Test webhook-u uğursuz oldu',
|
||||
|
||||
'settings.ntfyUrl.topicLabel': 'Ntfy mövzusu',
|
||||
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts',
|
||||
'settings.ntfyUrl.topicPlaceholder': 'my-trek-alerts', // same-as-en
|
||||
'settings.ntfyUrl.serverLabel': 'Ntfy server URL-i (istəyə bağlı)',
|
||||
'settings.ntfyUrl.serverPlaceholder': 'https://ntfy.sh',
|
||||
'settings.ntfyUrl.hint':
|
||||
@@ -323,7 +323,7 @@ const settings: TranslationStrings = {
|
||||
'settings.username': 'İstifadəçi adı',
|
||||
'settings.email': 'E-poçt',
|
||||
'settings.role': 'Rol',
|
||||
'settings.roleAdmin': 'Administrator',
|
||||
'settings.roleAdmin': 'Administrator', // same-as-en
|
||||
'settings.oidcLinked': 'Əlaqələndirilib:',
|
||||
'settings.changePassword': 'Parolu dəyiş',
|
||||
'settings.currentPassword': 'Cari parol',
|
||||
@@ -461,7 +461,7 @@ const settings: TranslationStrings = {
|
||||
'settings.aiParsing.providerAnthropic': 'Anthropic',
|
||||
'settings.aiParsing.localAdminOnly':
|
||||
'Lokal Ollama son nöqtəsi administrator tənzimləmələrində bütün instansiya üçün bir dəfə quraşdırılır. Burada öz OpenAI və ya Anthropic açarınızdan istifadə edə bilərsiniz.',
|
||||
'settings.aiParsing.model': 'Model',
|
||||
'settings.aiParsing.model': 'Model', // same-as-en
|
||||
'settings.aiParsing.baseUrl': 'Əsas URL',
|
||||
'settings.aiParsing.baseUrlHint': 'Modelin işlədiyi yer — lokal Ollama serveri və ya OpenAI ilə uyğun son nöqtə.',
|
||||
'settings.aiParsing.apiKey': 'API açarı',
|
||||
@@ -526,7 +526,7 @@ const settings: TranslationStrings = {
|
||||
'settings.appearance.perSize': 'Hər ölçünü ayrıca tənzimlə',
|
||||
'settings.appearance.size.large': 'Böyük',
|
||||
'settings.appearance.size.medium': 'Orta',
|
||||
'settings.appearance.size.normal': 'Normal',
|
||||
'settings.appearance.size.normal': 'Normal', // same-as-en
|
||||
'settings.appearance.size.small': 'Kiçik',
|
||||
|
||||
'settings.appearance.preview.large': 'Böyük başlıq',
|
||||
|
||||
@@ -8,7 +8,7 @@ const shared: TranslationStrings = {
|
||||
'Bu, linkin vaxtının bitdiyi anlamına gəlmir. İnternet bağlantınızı yoxlayın və bir azdan yenidən cəhd edin.',
|
||||
'shared.retry': 'Yenidən cəhd et',
|
||||
'shared.readOnly': 'Yalnız oxuma üçün paylaşılan görünüş',
|
||||
'shared.tabPlan': 'Plan',
|
||||
'shared.tabPlan': 'Plan', // same-as-en
|
||||
'shared.tabBookings': 'Rezervasiyalar',
|
||||
'shared.tabPacking': 'Baqaj',
|
||||
'shared.tabBudget': 'Xərclər',
|
||||
|
||||
@@ -8,10 +8,10 @@ const storage: TranslationStrings = {
|
||||
'storage.field.endpoint': 'Son nöqtə URL-si',
|
||||
'storage.help.endpoint':
|
||||
'S3 uyğun xidmətin əsas URL-si, məsələn, https://s3.example.com və ya http://127.0.0.1:9000.',
|
||||
'storage.field.bucket': 'Bucket',
|
||||
'storage.field.bucket': 'Bucket', // same-as-en
|
||||
'storage.field.accessKeyId': 'Giriş açarı ID-si',
|
||||
'storage.field.secretAccessKey': 'Gizli giriş açarı',
|
||||
'storage.field.region': 'Region',
|
||||
'storage.field.region': 'Region', // same-as-en
|
||||
'storage.help.region': 'Provayderiniz xüsusi region tələb etmirsə, standart dəyəri saxlayın.',
|
||||
'storage.field.keyPrefix': 'Açar prefiksi',
|
||||
'storage.help.keyPrefix': 'Hər obyekt açarına əlavə edilən istəyə bağlı prefiks, məsələn, trek/prod.',
|
||||
|
||||
@@ -59,7 +59,7 @@ const system_notice: TranslationStrings = {
|
||||
'Öz məkan API-si, başlanğıcdan sona qədər planlaşdırılan avtomobil səyahətləri, məkan tarixçənizin yenidən sizin nəzarətinizdə olması və sənədlərinizin sinxronlaşdırılması.',
|
||||
'system_notice.release_notes.features_label': 'Əsas yeniliklər',
|
||||
'system_notice.release_notes.features_aside': 'Bundan əlavə',
|
||||
'system_notice.release_notes.feature_places_title': 'TREK Places API',
|
||||
'system_notice.release_notes.feature_places_title': 'TREK Places API', // same-as-en
|
||||
'system_notice.release_notes.feature_places_body':
|
||||
'Öz məkan API-sinə malik ilk açıq mənbəli səyahət planlayıcısı. 73,6 milyon məkan, açar və kvota yoxdur.',
|
||||
'system_notice.release_notes.feature_roadtrip_title': 'Avtomobil səyahəti əlavəsi',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const trip: TranslationStrings = {
|
||||
'trip.tabs.plan': 'Plan',
|
||||
'trip.tabs.plan': 'Plan', // same-as-en
|
||||
'trip.tabs.transports': 'Nəqliyyat',
|
||||
'trip.tabs.reservations': 'Rezervasiyalar',
|
||||
'trip.tabs.reservationsShort': 'Rezervasiya',
|
||||
@@ -18,7 +18,7 @@ const trip: TranslationStrings = {
|
||||
'trip.loadingSteps.road': 'Yola çıxırıq...',
|
||||
'trip.loadingSteps.arrive': 'Az qaldı...',
|
||||
|
||||
'trip.mobilePlan': 'Plan',
|
||||
'trip.mobilePlan': 'Plan', // same-as-en
|
||||
'trip.mobilePlaces': 'Məkanlar',
|
||||
'trip.panelWidth': 'Panelin eni',
|
||||
|
||||
|
||||
+28
-28
@@ -21,7 +21,7 @@ const admin: TranslationStrings = {
|
||||
'admin.subtitle': 'Gestão de usuários e configurações do sistema',
|
||||
'admin.tabs.users': 'Usuários',
|
||||
'admin.tabs.categories': 'Categorias',
|
||||
'admin.tabs.backup': 'Backup',
|
||||
'admin.tabs.backup': 'Backup', // same-as-en
|
||||
'admin.stats.users': 'Usuários',
|
||||
'admin.stats.users.one': 'Usuário',
|
||||
'admin.stats.trips': 'Viagens',
|
||||
@@ -76,18 +76,18 @@ const admin: TranslationStrings = {
|
||||
'admin.tabs.settings': 'Configurações',
|
||||
'admin.allowRegistration': 'Permitir cadastro',
|
||||
'admin.allowRegistrationHint': 'Novos usuários podem se cadastrar sozinhos',
|
||||
'admin.authMethods': 'Authentication Methods',
|
||||
'admin.passwordLogin': 'Password Login',
|
||||
'admin.passwordLoginHint': 'Allow users to sign in with email and password',
|
||||
'admin.passwordRegistration': 'Password Registration',
|
||||
'admin.passwordRegistrationHint': 'Allow new users to register with email and password',
|
||||
'admin.oidcLogin': 'SSO Login',
|
||||
'admin.oidcLoginHint': 'Allow users to sign in with SSO',
|
||||
'admin.authMethods': 'Métodos de autenticação',
|
||||
'admin.passwordLogin': 'Login com senha',
|
||||
'admin.passwordLoginHint': 'Permitir que os usuários entrem com e-mail e senha',
|
||||
'admin.passwordRegistration': 'Cadastro com senha',
|
||||
'admin.passwordRegistrationHint': 'Permitir que novos usuários se cadastrem com e-mail e senha',
|
||||
'admin.oidcLogin': 'Login SSO',
|
||||
'admin.oidcLoginHint': 'Permitir que os usuários entrem com SSO',
|
||||
'admin.oidcRegistration': 'SSO Auto-Provisioning',
|
||||
'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users',
|
||||
'admin.oidcRegistrationHint': 'Criar contas automaticamente para novos usuários SSO',
|
||||
'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',
|
||||
'As configurações de login com senha são controladas pela variável de ambiente OIDC_ONLY e não podem ser alteradas aqui.',
|
||||
'admin.lockoutWarning': 'Pelo menos um método de login deve permanecer ativado',
|
||||
'admin.requireMfa': 'Exigir autenticação em dois fatores (2FA)',
|
||||
'admin.requireMfaHint': 'Usuários sem 2FA precisam concluir a configuração em Configurações antes de usar o app.',
|
||||
'admin.apiKeys': 'Chaves de API',
|
||||
@@ -134,7 +134,7 @@ const admin: TranslationStrings = {
|
||||
'O índice do próprio TREK e o OpenStreetMap respondem a cada busca. Aqui se escolhe quem mais é consultado quando eles não acham nada: Automático prefere o Google se houver chave, depois Amap.',
|
||||
'admin.placesProvider.auto': 'Automático',
|
||||
'admin.placesProvider.google': 'Google Places',
|
||||
'admin.placesProvider.amap': 'Amap (高德地图)',
|
||||
'admin.placesProvider.amap': 'Amap (高德地图)', // same-as-en
|
||||
'admin.placesProvider.openstreetmap': 'OpenStreetMap',
|
||||
'admin.placesProvider.missingKey':
|
||||
'O provedor selecionado não tem chave de API configurada, então a busca de lugares é respondida apenas pelo índice do TREK e pelo OpenStreetMap.',
|
||||
@@ -201,7 +201,7 @@ const admin: TranslationStrings = {
|
||||
'Registar qual resultado de pesquisa foi escolhido, para que outro índice de locais possa ser avaliado depois com pesquisas reais. Nada sai desta instância e um administrador pode exportar ou apagar o registo a qualquer momento.',
|
||||
'admin.bagTracking.title': 'Rastreamento de malas',
|
||||
'admin.bagTracking.subtitle': 'Ativar peso e atribuição de mala para itens da lista',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
'admin.collab.chat.title': 'Chat', // same-as-en
|
||||
'admin.collab.chat.subtitle': 'Mensagens em tempo real para colaboração',
|
||||
'admin.collab.notes.title': 'Notas',
|
||||
'admin.collab.notes.subtitle': 'Notas e documentos compartilhados',
|
||||
@@ -224,7 +224,7 @@ const admin: TranslationStrings = {
|
||||
'admin.packingTemplates.namePlaceholder': 'Nome do modelo (ex.: Praia)',
|
||||
'admin.packingTemplates.empty': 'Nenhum modelo criado ainda',
|
||||
'admin.packingTemplates.items': 'itens',
|
||||
'admin.packingTemplates.items.one': 'item',
|
||||
'admin.packingTemplates.items.one': 'item', // same-as-en
|
||||
'admin.packingTemplates.categories': 'categorias',
|
||||
'admin.packingTemplates.categories.one': 'categoria',
|
||||
'admin.packingTemplates.itemName': 'Nome do item',
|
||||
@@ -240,7 +240,7 @@ const admin: TranslationStrings = {
|
||||
'admin.packingTemplates.deleteItemError': 'Falha ao excluir o item',
|
||||
'admin.packingTemplates.saveError': 'Falha ao salvar',
|
||||
'admin.tabs.addons': 'Complementos',
|
||||
'admin.tabs.plugins': 'Plugins',
|
||||
'admin.tabs.plugins': 'Plugins', // same-as-en
|
||||
'admin.tabs.storage': 'Armazenamento',
|
||||
'admin.plugins.rescan': 'Reescanear',
|
||||
'admin.plugins.rescanned': 'Pasta de plugins reescaneada',
|
||||
@@ -286,7 +286,7 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.uninstallBody':
|
||||
'Isso interrompe o plugin, remove seu código e exclui todos os dados dele. Esta ação não pode ser desfeita.',
|
||||
'admin.plugins.status.starting': 'Iniciando…',
|
||||
'admin.plugins.type.widget': 'Widget',
|
||||
'admin.plugins.type.widget': 'Widget', // same-as-en
|
||||
'admin.plugins.type.page': 'Página',
|
||||
'admin.plugins.type.integration': 'Integração',
|
||||
'admin.plugins.type.trip-page': 'Página da viagem',
|
||||
@@ -296,8 +296,8 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.updateTo': 'Atualizar → v{version}',
|
||||
'admin.plugins.enabledToggle': 'Ativar plugin',
|
||||
'plugins.notFound': 'Plugin não encontrado',
|
||||
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'plugins.frameLoadFailed': 'Não foi possível carregar este plugin.',
|
||||
'admin.plugins.title': 'Plugins', // same-as-en
|
||||
'admin.plugins.subtitle': 'Instale e gerencie plugins de terceiros na sua instância.',
|
||||
'admin.plugins.disabledTitle': 'Os plugins estão desativados',
|
||||
'admin.plugins.disabledBody':
|
||||
@@ -495,7 +495,7 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.tabDiscover': 'Descobrir',
|
||||
'admin.plugins.searchPlaceholder': 'Buscar plugins…',
|
||||
'admin.plugins.filterType': 'Tipo',
|
||||
'admin.plugins.filterStatus': 'Status',
|
||||
'admin.plugins.filterStatus': 'Status', // same-as-en
|
||||
'admin.plugins.sortBy': 'Ordenar',
|
||||
'admin.plugins.allTypes': 'Todos os tipos',
|
||||
'admin.plugins.allStatuses': 'Todos',
|
||||
@@ -602,7 +602,7 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.metaSize': 'Tamanho',
|
||||
'admin.plugins.metaRequires': 'Requer',
|
||||
'admin.plugins.metaReviewed': 'Revisado em',
|
||||
'admin.plugins.downloads': 'Downloads',
|
||||
'admin.plugins.downloads': 'Downloads', // same-as-en
|
||||
'admin.addons.title': 'Complementos',
|
||||
'admin.addons.subtitle': 'Ative ou desative recursos para personalizar sua experiência no TREK.',
|
||||
'admin.addons.catalog.memories.name': 'Memórias',
|
||||
@@ -628,7 +628,7 @@ const admin: TranslationStrings = {
|
||||
'admin.addons.catalog.mcp.description': 'Model Context Protocol para integração com assistentes de IA',
|
||||
'admin.addons.subtitleBefore': 'Ative ou desative recursos para personalizar sua ',
|
||||
'admin.addons.subtitleAfter': ' experiência.',
|
||||
'admin.addons.catalog.naver_list_import.name': 'Naver List Import',
|
||||
'admin.addons.catalog.naver_list_import.name': 'Importação de listas do Naver',
|
||||
'admin.addons.catalog.naver_list_import.description': 'Importe lugares de uma lista compartilhada do Naver Maps',
|
||||
'admin.addons.catalog.airtrail.name': 'AirTrail',
|
||||
'admin.addons.catalog.airtrail.description': 'Sincronize voos da sua instância AirTrail',
|
||||
@@ -650,7 +650,7 @@ const admin: TranslationStrings = {
|
||||
'admin.addons.enabled': 'Ativado',
|
||||
'admin.addons.disabled': 'Desativado',
|
||||
'admin.addons.type.trip': 'Viagem',
|
||||
'admin.addons.type.global': 'Global',
|
||||
'admin.addons.type.global': 'Global', // same-as-en
|
||||
'admin.addons.type.integration': 'Integração',
|
||||
'admin.addons.tripHint': 'Disponível como aba em cada viagem',
|
||||
'admin.addons.globalHint': 'Disponível como seção própria na navegação principal',
|
||||
@@ -762,7 +762,7 @@ const admin: TranslationStrings = {
|
||||
'admin.oauthSessions.revokeSuccess': 'Sessão revogada',
|
||||
'admin.oauthSessions.revokeError': 'Falha ao revogar sessão',
|
||||
'admin.oauthSessions.loadError': 'Falha ao carregar sessões OAuth',
|
||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||
'admin.notifications.emailPanel.title': 'E-mail (SMTP)',
|
||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||
'admin.notifications.webPushPanel.title': 'Web Push',
|
||||
'admin.notifications.webPushPanel.hint':
|
||||
@@ -792,7 +792,7 @@ const admin: TranslationStrings = {
|
||||
'Também usado como servidor padrão para notificações ntfy dos usuários. Deixe em branco para usar ntfy.sh. Os usuários podem substituir isso em suas próprias configurações.',
|
||||
'admin.notifications.adminNtfyPanel.serverPlaceholder': 'https://ntfy.sh',
|
||||
'admin.notifications.adminNtfyPanel.topicLabel': 'Tópico de admin',
|
||||
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts',
|
||||
'admin.notifications.adminNtfyPanel.topicPlaceholder': 'trek-admin-alerts', // same-as-en
|
||||
'admin.notifications.adminNtfyPanel.tokenLabel': 'Token de acesso (opcional)',
|
||||
'admin.notifications.adminNtfyPanel.tokenCleared': 'Token de acesso admin removido',
|
||||
'admin.notifications.adminNtfyPanel.saved': 'Configurações de Ntfy de admin salvas',
|
||||
@@ -855,10 +855,10 @@ const admin: TranslationStrings = {
|
||||
'admin.defaultSettings.cartoKey': 'Chave CARTO compartilhada',
|
||||
'admin.defaultSettings.cartoKeyHint':
|
||||
"Usada para todos os usuários que não inseriram a própria chave, assim toda a instância recebe blocos do CARTO sem marca d'água. Armazenada de forma criptografada.",
|
||||
'admin.group.users': 'Users',
|
||||
'admin.group.config': 'Configuration',
|
||||
'admin.group.integration': 'Integrations',
|
||||
'admin.group.maintenance': 'Maintenance',
|
||||
'admin.group.users': 'Usuários',
|
||||
'admin.group.config': 'Configuração',
|
||||
'admin.group.integration': 'Integrações',
|
||||
'admin.group.maintenance': 'Manutenção',
|
||||
'admin.invite.tripLabel': 'Adicionar à viagem (opcional)',
|
||||
'admin.invite.tripNone': 'Nenhuma viagem',
|
||||
'admin.invite.tripHint': 'O novo usuário é adicionado automaticamente a esta viagem ao se registrar pelo link.',
|
||||
|
||||
@@ -59,7 +59,7 @@ const atlas: TranslationStrings = {
|
||||
'atlas.northAmerica': 'América do Norte',
|
||||
'atlas.southAmerica': 'América do Sul',
|
||||
'atlas.africa': 'África',
|
||||
'atlas.oceania': 'Oceania',
|
||||
'atlas.oceania': 'Oceania', // same-as-en
|
||||
'atlas.other': 'Outro',
|
||||
'atlas.firstVisit': 'Primeira viagem',
|
||||
'atlas.lastVisitLabel': 'Última viagem',
|
||||
|
||||
@@ -13,7 +13,7 @@ const budget: TranslationStrings = {
|
||||
'budget.categoryName': 'Nome da categoria',
|
||||
'budget.addCategory': 'Adicionar categoria',
|
||||
'budget.table.name': 'Nome',
|
||||
'budget.table.total': 'Total',
|
||||
'budget.table.total': 'Total', // same-as-en
|
||||
'budget.table.persons': 'Pessoas',
|
||||
'budget.table.days': 'Dias',
|
||||
'budget.table.perPerson': 'Por pessoa',
|
||||
@@ -24,7 +24,7 @@ const budget: TranslationStrings = {
|
||||
'budget.newEntry': 'Novo lançamento',
|
||||
'budget.defaultEntry': 'Novo lançamento',
|
||||
'budget.defaultCategory': 'Nova categoria',
|
||||
'budget.total': 'Total',
|
||||
'budget.total': 'Total', // same-as-en
|
||||
'budget.totalBudget': 'Orçamento total',
|
||||
'budget.byCategory': 'Por categoria',
|
||||
'budget.editTooltip': 'Clique para editar',
|
||||
@@ -77,7 +77,7 @@ const budget: TranslationStrings = {
|
||||
'costs.table.byCategory': 'Categoria',
|
||||
'costs.table.byDate': 'Dia',
|
||||
'costs.table.byPayer': 'Pagador',
|
||||
'costs.table.byStatus': 'Status',
|
||||
'costs.table.byStatus': 'Status', // same-as-en
|
||||
'costs.table.noPayer': 'Sem pagador ainda',
|
||||
'costs.table.paid': 'Pago',
|
||||
'costs.table.open': 'Em aberto',
|
||||
|
||||
@@ -9,10 +9,10 @@ const collab: TranslationStrings = {
|
||||
'collab.polls.countdownHoursMinutes': 'Faltam {h}h {m}min',
|
||||
'collab.polls.countdownMinutes': 'Faltam {m}min',
|
||||
'collab.polls.clearDeadline': 'Remover prazo',
|
||||
'collab.tabs.chat': 'Chat',
|
||||
'collab.tabs.chat': 'Chat', // same-as-en
|
||||
'collab.tabs.notes': 'Notas',
|
||||
'collab.tabs.polls': 'Enquetes',
|
||||
'collab.tabs.links': 'Links',
|
||||
'collab.tabs.links': 'Links', // same-as-en
|
||||
'collab.links.add': 'Adicionar link',
|
||||
'collab.chat.imageRejected': 'Apenas imagens JPEG, PNG, GIF e WebP de até 10 MB são permitidas',
|
||||
'collab.links.cancel': 'Cancelar',
|
||||
|
||||
@@ -9,7 +9,7 @@ const collection: TranslationStrings = {
|
||||
'collections.saveToListHint': 'Duplicados são ignorados automaticamente',
|
||||
'collections.role.label': 'Função',
|
||||
'collections.role.viewer': 'Visualizador',
|
||||
'collections.role.editor': 'Editor',
|
||||
'collections.role.editor': 'Editor', // same-as-en
|
||||
'collections.role.admin': 'Administrador',
|
||||
'collections.picker.allLists': 'Todas as listas',
|
||||
'collections.select': 'Escolher',
|
||||
@@ -36,7 +36,7 @@ const collection: TranslationStrings = {
|
||||
'collections.changeCover': 'Alterar capa',
|
||||
'collections.description': 'Descrição',
|
||||
'collections.descriptionPlaceholder': 'Adicione uma descrição…',
|
||||
'collections.links': 'Links',
|
||||
'collections.links': 'Links', // same-as-en
|
||||
'collections.linkLabel': 'Rótulo',
|
||||
'collections.addLink': 'Adicionar link',
|
||||
'collections.addDetails': 'Adicionar detalhes',
|
||||
|
||||
@@ -58,18 +58,18 @@ const common: TranslationStrings = {
|
||||
'common.hoursAgo.one': 'há {count}h',
|
||||
'common.daysAgo': 'há {count}d',
|
||||
'common.daysAgo.one': 'há {count}d',
|
||||
'common.datepicker.prevMonth': 'Previous month', // en-fallback
|
||||
'common.datepicker.nextMonth': 'Next month', // en-fallback
|
||||
'common.datepicker.prevYear': 'Previous year', // en-fallback
|
||||
'common.datepicker.nextYear': 'Next year', // en-fallback
|
||||
'common.datepicker.prevYears': 'Previous years', // en-fallback
|
||||
'common.datepicker.nextYears': 'Next years', // en-fallback
|
||||
'common.datepicker.selectMonth': 'Select month', // en-fallback
|
||||
'common.datepicker.selectYear': 'Select year', // en-fallback
|
||||
'common.datepicker.enterManually': 'Enter date manually', // en-fallback
|
||||
'common.datepicker.typeDate': 'Type a date', // en-fallback
|
||||
'common.datepicker.dialog': 'Date picker', // en-fallback
|
||||
'common.datepicker.clearDate': 'Clear date', // en-fallback
|
||||
'common.datepicker.prevMonth': 'Mês anterior',
|
||||
'common.datepicker.nextMonth': 'Próximo mês',
|
||||
'common.datepicker.prevYear': 'Ano anterior',
|
||||
'common.datepicker.nextYear': 'Próximo ano',
|
||||
'common.datepicker.prevYears': 'Anos anteriores',
|
||||
'common.datepicker.nextYears': 'Próximos anos',
|
||||
'common.datepicker.selectMonth': 'Selecionar mês',
|
||||
'common.datepicker.selectYear': 'Selecionar ano',
|
||||
'common.datepicker.enterManually': 'Digitar a data manualmente',
|
||||
'common.datepicker.typeDate': 'Digite uma data',
|
||||
'common.datepicker.dialog': 'Seletor de data',
|
||||
'common.datepicker.clearDate': 'Limpar data',
|
||||
'common.errorTitle': 'Algo deu errado',
|
||||
'common.errorBody': 'Esta parte do aplicativo parou de funcionar. Seus dados estão seguros.',
|
||||
'common.errorPanelTitle': 'Não foi possível exibir esta seção',
|
||||
|
||||
@@ -19,7 +19,7 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.listView': 'Lista',
|
||||
'dashboard.currency': 'Moeda',
|
||||
'dashboard.timezone': 'Fusos horários',
|
||||
'dashboard.localTime': 'Local',
|
||||
'dashboard.localTime': 'Local', // same-as-en
|
||||
'dashboard.timezoneCustomTitle': 'Fuso personalizado',
|
||||
'dashboard.timezoneCustomLabelPlaceholder': 'Rótulo (opcional)',
|
||||
'dashboard.timezoneCustomTzPlaceholder': 'ex.: America/Sao_Paulo',
|
||||
|
||||
@@ -16,9 +16,9 @@ const day: TranslationStrings = {
|
||||
'day.hotelDayRange': 'Aplicar aos dias',
|
||||
'day.noPlacesForHotel': 'Adicione lugares à viagem primeiro',
|
||||
'day.allDays': 'Todos',
|
||||
'day.checkIn': 'Check-in',
|
||||
'day.checkIn': 'Check-in', // same-as-en
|
||||
'day.checkInUntil': 'Até',
|
||||
'day.checkOut': 'Check-out',
|
||||
'day.checkOut': 'Check-out', // same-as-en
|
||||
'day.openStayBooking': 'Abrir reserva',
|
||||
'day.confirmation': 'Confirmação',
|
||||
'day.editAccommodation': 'Editar hospedagem',
|
||||
|
||||
@@ -21,7 +21,7 @@ const dayplan: TranslationStrings = {
|
||||
'notes.format.italic': 'Itálico',
|
||||
'notes.format.strike': 'Tachado',
|
||||
'notes.format.code': 'Código',
|
||||
'notes.format.link': 'Link',
|
||||
'notes.format.link': 'Link', // same-as-en
|
||||
'notes.format.list': 'Lista com marcadores',
|
||||
'notes.format.orderedList': 'Lista numerada',
|
||||
'notes.format.quote': 'Citação',
|
||||
@@ -80,8 +80,8 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.mobile.allAssigned': 'Todos os lugares atribuídos',
|
||||
'dayplan.mobile.noMatch': 'Sem correspondência',
|
||||
'dayplan.mobile.createNew': 'Criar novo lugar',
|
||||
'dayplan.expandAll': 'Expandir todos os dias', // en-fallback
|
||||
'dayplan.collapseAll': 'Recolher todos os dias', // en-fallback
|
||||
'dayplan.expandAll': 'Expandir todos os dias',
|
||||
'dayplan.collapseAll': 'Recolher todos os dias',
|
||||
'dayplan.reorderDays': 'Reordenar dias',
|
||||
'dayplan.reorderTitle': 'Reordenar dias',
|
||||
'dayplan.reorderHint': 'Os lugares, notas e reservas de um dia se movem junto com ele.',
|
||||
|
||||
+109
-106
@@ -193,7 +193,7 @@ const journey: TranslationStrings = {
|
||||
'journey.share.updateFailed': 'Não foi possível atualizar',
|
||||
'journey.invite.role': 'Função',
|
||||
'journey.invite.viewer': 'Visualizador',
|
||||
'journey.invite.editor': 'Editor',
|
||||
'journey.invite.editor': 'Editor', // same-as-en
|
||||
'journey.invite.invite': 'Convidar',
|
||||
'journey.invite.inviting': 'Convidando...',
|
||||
'journey.settings.title': 'Configurações da jornada',
|
||||
@@ -211,7 +211,7 @@ const journey: TranslationStrings = {
|
||||
'journey.settings.autoPrepare': 'Preparar dias gravados automaticamente',
|
||||
'journey.settings.autoPrepareHint':
|
||||
'Enquanto uma viagem vinculada está em andamento, as paradas do seu Dawarich esperam no dia delas pela sua revisão. Editores com conexão própria recebem as deles. Nada é escrito no diário sozinho.',
|
||||
'journey.settings.status': 'Status',
|
||||
'journey.settings.status': 'Status', // same-as-en
|
||||
'journey.settings.statusAuto': 'Automático',
|
||||
'journey.settings.statusAutoHint':
|
||||
'Segue as datas das viagens vinculadas. Sem viagem, a jornada continua como rascunho.',
|
||||
@@ -267,42 +267,43 @@ const journey: TranslationStrings = {
|
||||
'journey.picker.noAlbums': 'Nenhum álbum encontrado',
|
||||
'journey.picker.selectDate': 'Selecionar data',
|
||||
'journey.picker.search': 'Pesquisar',
|
||||
'journey.detail.journeyTab': 'Journey', // en-fallback
|
||||
'journey.contributors.remove': 'Remove contributor', // en-fallback
|
||||
'journey.contributors.removeConfirm': 'Remove {username} from this journey?', // en-fallback
|
||||
'journey.contributors.removed': 'Contributor removed', // en-fallback
|
||||
'journey.contributors.removeFailed': 'Failed to remove contributor', // en-fallback
|
||||
'journey.detail.journeyTab': 'Jornada',
|
||||
'journey.contributors.remove': 'Remover colaborador',
|
||||
'journey.contributors.removeConfirm': 'Remover {username} desta jornada?',
|
||||
'journey.contributors.removed': 'Colaborador removido',
|
||||
'journey.contributors.removeFailed': 'Não foi possível remover o colaborador',
|
||||
'journey.contributors.viaTrips': 'Por meio de viagens',
|
||||
'journey.contributors.viaTripsHint':
|
||||
'Podem abrir este diário porque uma viagem o vincula. Altere isso nas configurações da viagem.',
|
||||
'journey.contributors.viaTrip': 'por {trip}',
|
||||
'journey.editor.externalPhotos': 'External photos', // en-fallback
|
||||
'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback
|
||||
'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback
|
||||
'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback
|
||||
'journey.editor.externalPhotosQueued': 'queued', // en-fallback
|
||||
'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback
|
||||
'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback
|
||||
'journey.picker.day': 'This day', // en-fallback
|
||||
'journey.studio.title': 'TREK Studio', // en-fallback
|
||||
'journey.studio.open': 'Studio', // en-fallback
|
||||
'journey.studio.openAria': 'Open the photo book studio', // en-fallback
|
||||
'journey.studio.backToJourney': 'Back to the journey', // en-fallback
|
||||
'journey.studio.format': 'Page format', // en-fallback
|
||||
'journey.studio.formatA4Landscape': 'A4 landscape', // en-fallback
|
||||
'journey.studio.formatA4Portrait': 'A4 portrait', // en-fallback
|
||||
'journey.studio.formatSquare21': 'Square 21 × 21 cm', // en-fallback
|
||||
'journey.studio.formatSquare30': 'Square 30 × 30 cm', // en-fallback
|
||||
'journey.editor.externalPhotos': 'Fotos externas',
|
||||
'journey.editor.externalPhotosFor': 'Fotos de {date}',
|
||||
'journey.editor.externalPhotosNearby': 'Fotos próximas primeiro',
|
||||
'journey.editor.externalPhotosNoLocation': 'Todas as fotos deste dia',
|
||||
'journey.editor.externalPhotosQueued': 'na fila',
|
||||
'journey.editor.externalPhotosUnavailable': 'Nenhum provedor de fotos conectado está disponível.',
|
||||
'journey.editor.externalPhotosPartialFailed':
|
||||
'{failed} grupos de fotos falharam: salve novamente para tentar de novo',
|
||||
'journey.picker.day': 'Este dia',
|
||||
'journey.studio.title': 'TREK Studio',
|
||||
'journey.studio.open': 'Studio',
|
||||
'journey.studio.openAria': 'Abrir o estúdio de fotolivro',
|
||||
'journey.studio.backToJourney': 'Voltar à jornada',
|
||||
'journey.studio.format': 'Formato da página',
|
||||
'journey.studio.formatA4Landscape': 'A4 paisagem',
|
||||
'journey.studio.formatA4Portrait': 'A4 retrato',
|
||||
'journey.studio.formatSquare21': 'Quadrado 21 × 21 cm',
|
||||
'journey.studio.formatSquare30': 'Quadrado 30 × 30 cm',
|
||||
'journey.studio.pages': 'Páginas',
|
||||
'journey.studio.cover': 'Cover', // en-fallback
|
||||
'journey.studio.inspectorEmpty': 'Select something on the page to edit it.', // en-fallback
|
||||
'journey.studio.autoLayout': 'Auto layout', // en-fallback
|
||||
'journey.studio.export': 'Export', // en-fallback
|
||||
'journey.studio.cover': 'Capa',
|
||||
'journey.studio.inspectorEmpty': 'Selecione algo na página para editar.',
|
||||
'journey.studio.autoLayout': 'Layout automático',
|
||||
'journey.studio.export': 'Exportar',
|
||||
'journey.studio.day': 'DIA {day}',
|
||||
'journey.studio.stations': 'Etapas',
|
||||
'journey.studio.peersHere': 'aqui',
|
||||
'journey.studio.folioAuto': 'Automático',
|
||||
'journey.studio.exportLayout': 'Layout',
|
||||
'journey.studio.exportLayout': 'Layout', // same-as-en
|
||||
'journey.studio.exportPages': 'Páginas avulsas',
|
||||
'journey.studio.exportPagesHint': 'Uma folha por página, na ordem de leitura. É o que a gráfica quer.',
|
||||
'journey.studio.exportSpreads': 'Páginas duplas',
|
||||
@@ -319,98 +320,98 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.exportPreparing': 'Preparando',
|
||||
'journey.studio.exportSheetCount': '{count} folhas',
|
||||
'journey.studio.exportSheetCount.one': '{count} folha',
|
||||
'journey.studio.undo': 'Undo', // en-fallback
|
||||
'journey.studio.redo': 'Redo', // en-fallback
|
||||
'journey.studio.zoomIn': 'Zoom in', // en-fallback
|
||||
'journey.studio.zoomOut': 'Zoom out', // en-fallback
|
||||
'journey.studio.zoomFit': 'Fit to view', // en-fallback
|
||||
'journey.studio.undo': 'Desfazer',
|
||||
'journey.studio.redo': 'Refazer',
|
||||
'journey.studio.zoomIn': 'Aumentar zoom',
|
||||
'journey.studio.zoomOut': 'Diminuir zoom',
|
||||
'journey.studio.zoomFit': 'Ajustar à tela',
|
||||
'journey.studio.downloadSpread': 'Baixar esta página dupla',
|
||||
'journey.studio.downloadSpreadHint':
|
||||
'Salva o design desta página dupla como arquivo, sem as fotos, para compartilhar ou reutilizar',
|
||||
'journey.studio.importSpread': 'Importar',
|
||||
'journey.studio.importSpreadHint': 'Adiciona uma página dupla a partir de um arquivo de design baixado',
|
||||
'journey.studio.importSpreadFailed': 'Esse arquivo não é uma página do TREK Studio',
|
||||
'journey.studio.desktopOnly': 'Studio needs a bigger screen', // en-fallback
|
||||
'journey.studio.desktopOnly': 'O Studio precisa de uma tela maior',
|
||||
'journey.studio.desktopOnlyHint':
|
||||
'Montar um livro pede espaço de trabalho, então o Studio existe só no computador, e o PDF também. Todo o resto da sua viagem funciona aqui como sempre.', // en-fallback
|
||||
'journey.studio.formatA5Landscape': 'A5 landscape', // en-fallback
|
||||
'journey.studio.bookView': 'Book view', // en-fallback
|
||||
'journey.studio.multiple': 'Several', // en-fallback
|
||||
'journey.studio.kind.photo': 'Photo', // en-fallback
|
||||
'journey.studio.kind.text': 'Text', // en-fallback
|
||||
'journey.studio.kind.shape': 'Shape', // en-fallback
|
||||
'journey.studio.position': 'Position', // en-fallback
|
||||
'journey.studio.width': 'W', // en-fallback
|
||||
'journey.studio.height': 'H', // en-fallback
|
||||
'Montar um livro pede espaço de trabalho, então o Studio existe só no computador, e o PDF também. Todo o resto da sua jornada funciona aqui como sempre.',
|
||||
'journey.studio.formatA5Landscape': 'A5 paisagem',
|
||||
'journey.studio.bookView': 'Visão do livro',
|
||||
'journey.studio.multiple': 'Vários',
|
||||
'journey.studio.kind.photo': 'Foto',
|
||||
'journey.studio.kind.text': 'Texto',
|
||||
'journey.studio.kind.shape': 'Forma',
|
||||
'journey.studio.position': 'Posição',
|
||||
'journey.studio.width': 'L',
|
||||
'journey.studio.height': 'A',
|
||||
'journey.studio.text': 'Texto',
|
||||
'journey.studio.typography': 'Type', // en-fallback
|
||||
'journey.studio.leading': 'Line', // en-fallback
|
||||
'journey.studio.colour': 'Colour', // en-fallback
|
||||
'journey.studio.typography': 'Fonte',
|
||||
'journey.studio.leading': 'Linha',
|
||||
'journey.studio.colour': 'Cor',
|
||||
'journey.studio.autoColour': 'Automático',
|
||||
'journey.studio.countryNames': 'Nomes',
|
||||
'journey.studio.crop': 'Crop', // en-fallback
|
||||
'journey.studio.look': 'Look', // en-fallback
|
||||
'journey.studio.radius': 'Corner', // en-fallback
|
||||
'journey.studio.shape': 'Shape', // en-fallback
|
||||
'journey.studio.arrange': 'Arrange', // en-fallback
|
||||
'journey.studio.toFront': 'Bring to front', // en-fallback
|
||||
'journey.studio.forward': 'Bring forward', // en-fallback
|
||||
'journey.studio.backward': 'Send backward', // en-fallback
|
||||
'journey.studio.toBack': 'Send to back', // en-fallback
|
||||
'journey.studio.lock': 'Lock', // en-fallback
|
||||
'journey.studio.unlock': 'Unlock', // en-fallback
|
||||
'journey.studio.delete': 'Delete', // en-fallback
|
||||
'journey.studio.pageHint': 'Page', // en-fallback
|
||||
'journey.studio.boundHint': 'Follows the journal entry. Editing it here breaks that link.', // en-fallback
|
||||
'journey.studio.fit.cover': 'Fill', // en-fallback
|
||||
'journey.studio.fit.contain': 'Fit', // en-fallback
|
||||
'journey.studio.filter.none': 'Original', // en-fallback
|
||||
'journey.studio.filter.bw': 'Black & white', // en-fallback
|
||||
'journey.studio.filter.warm': 'Warm', // en-fallback
|
||||
'journey.studio.shapeKind.rect': 'Rectangle', // en-fallback
|
||||
'journey.studio.shapeKind.ellipse': 'Ellipse', // en-fallback
|
||||
'journey.studio.focalHint': 'Drag the point to choose what stays in frame.', // en-fallback
|
||||
'journey.studio.backCover': 'Back cover', // en-fallback
|
||||
'journey.studio.sections': 'Sections', // en-fallback
|
||||
'journey.studio.crop': 'Cortar',
|
||||
'journey.studio.look': 'Aparência',
|
||||
'journey.studio.radius': 'Canto',
|
||||
'journey.studio.shape': 'Forma',
|
||||
'journey.studio.arrange': 'Organizar',
|
||||
'journey.studio.toFront': 'Trazer para frente',
|
||||
'journey.studio.forward': 'Avançar',
|
||||
'journey.studio.backward': 'Recuar',
|
||||
'journey.studio.toBack': 'Enviar para trás',
|
||||
'journey.studio.lock': 'Bloquear',
|
||||
'journey.studio.unlock': 'Desbloquear',
|
||||
'journey.studio.delete': 'Excluir',
|
||||
'journey.studio.pageHint': 'Página',
|
||||
'journey.studio.boundHint': 'Acompanha a entrada do diário. Editar aqui desfaz esse vínculo.',
|
||||
'journey.studio.fit.cover': 'Preencher',
|
||||
'journey.studio.fit.contain': 'Ajustar',
|
||||
'journey.studio.filter.none': 'Original',
|
||||
'journey.studio.filter.bw': 'Preto e branco',
|
||||
'journey.studio.filter.warm': 'Quente',
|
||||
'journey.studio.shapeKind.rect': 'Retângulo',
|
||||
'journey.studio.shapeKind.ellipse': 'Elipse',
|
||||
'journey.studio.focalHint': 'Arraste o ponto para escolher o que fica no enquadramento.',
|
||||
'journey.studio.backCover': 'Contracapa',
|
||||
'journey.studio.sections': 'Seções',
|
||||
'journey.studio.content': 'Conteúdo',
|
||||
'journey.studio.elements': 'Elementos',
|
||||
'journey.studio.templates': 'Layouts', // same-as-en
|
||||
'journey.studio.photos': 'Fotos',
|
||||
'journey.studio.entries': 'Registros',
|
||||
'journey.studio.addToPage': 'Add to this page', // en-fallback
|
||||
'journey.studio.noPhotos': 'This journey has no photos yet.', // en-fallback
|
||||
'journey.studio.untitled': 'Untitled', // en-fallback
|
||||
'journey.studio.addTitle': 'Title', // en-fallback
|
||||
'journey.studio.addStory': 'Story', // en-fallback
|
||||
'journey.studio.addPlace': 'Place', // en-fallback
|
||||
'journey.studio.addToPage': 'Adicionar a esta página',
|
||||
'journey.studio.noPhotos': 'Esta jornada ainda não tem fotos.',
|
||||
'journey.studio.untitled': 'Sem título',
|
||||
'journey.studio.addTitle': 'Título',
|
||||
'journey.studio.addStory': 'História',
|
||||
'journey.studio.addPlace': 'Lugar',
|
||||
'journey.studio.shapes': 'Formas',
|
||||
'journey.studio.frames': 'Molduras',
|
||||
'journey.studio.emptyFrame': 'Empty frame', // en-fallback
|
||||
'journey.studio.frameHint': 'An empty frame marks where a picture goes. Drop one on it from Content.', // en-fallback
|
||||
'journey.studio.shapeKind.line': 'Line', // en-fallback
|
||||
'journey.studio.styleTitle': 'Heading', // en-fallback
|
||||
'journey.studio.styleSubtitle': 'Subheading', // en-fallback
|
||||
'journey.studio.styleBody': 'Body text', // en-fallback
|
||||
'journey.studio.styleCaption': 'Caption', // en-fallback
|
||||
'journey.studio.sampleHeading': 'A heading', // en-fallback
|
||||
'journey.studio.sampleSubheading': 'A subheading', // en-fallback
|
||||
'journey.studio.sampleBody': 'Write something about this day.', // en-fallback
|
||||
'journey.studio.sampleCaption': 'Caption', // en-fallback
|
||||
'journey.studio.tpl.heroStory': 'Hero and story', // en-fallback
|
||||
'journey.studio.tpl.fullBleed': 'One picture, full spread', // en-fallback
|
||||
'journey.studio.tpl.twoUp': 'Two full pages', // en-fallback
|
||||
'journey.studio.tpl.grid4': 'Four up', // en-fallback
|
||||
'journey.studio.tpl.grid6': 'Six up', // en-fallback
|
||||
'journey.studio.tpl.strip': 'Strip and text', // en-fallback
|
||||
'journey.studio.tpl.quietText': 'Text only', // en-fallback
|
||||
'journey.studio.tpl.portraitPair': 'A pair', // en-fallback
|
||||
'journey.studio.emptyFrame': 'Moldura vazia',
|
||||
'journey.studio.frameHint': 'Uma moldura vazia marca onde vai uma imagem. Arraste uma até ela a partir de Conteúdo.',
|
||||
'journey.studio.shapeKind.line': 'Linha',
|
||||
'journey.studio.styleTitle': 'Título',
|
||||
'journey.studio.styleSubtitle': 'Subtítulo',
|
||||
'journey.studio.styleBody': 'Texto do corpo',
|
||||
'journey.studio.styleCaption': 'Legenda',
|
||||
'journey.studio.sampleHeading': 'Um título',
|
||||
'journey.studio.sampleSubheading': 'Um subtítulo',
|
||||
'journey.studio.sampleBody': 'Escreva algo sobre este dia.',
|
||||
'journey.studio.sampleCaption': 'Legenda',
|
||||
'journey.studio.tpl.heroStory': 'Destaque e história',
|
||||
'journey.studio.tpl.fullBleed': 'Uma imagem, página dupla inteira',
|
||||
'journey.studio.tpl.twoUp': 'Duas páginas inteiras',
|
||||
'journey.studio.tpl.grid4': 'Quatro por página',
|
||||
'journey.studio.tpl.grid6': 'Seis por página',
|
||||
'journey.studio.tpl.strip': 'Faixa e texto',
|
||||
'journey.studio.tpl.quietText': 'Só texto',
|
||||
'journey.studio.tpl.portraitPair': 'Um par',
|
||||
'journey.studio.dropPhotoHere': 'Arraste sua foto\npara cá',
|
||||
'journey.studio.searchContent': 'Search photos and entries', // en-fallback
|
||||
'journey.studio.noMatches': 'Nothing matches that.', // en-fallback
|
||||
'journey.studio.searchContent': 'Buscar fotos e entradas',
|
||||
'journey.studio.noMatches': 'Nada corresponde a isso.',
|
||||
'journey.studio.quoteMark': 'Aspas',
|
||||
'journey.studio.shapeKind.rounded': 'Rounded rectangle', // en-fallback
|
||||
'journey.studio.shapeKind.triangle': 'Triangle', // en-fallback
|
||||
'journey.studio.shapeKind.outline': 'Outline only', // en-fallback
|
||||
'journey.studio.shapeKind.rounded': 'Retângulo arredondado',
|
||||
'journey.studio.shapeKind.triangle': 'Triângulo',
|
||||
'journey.studio.shapeKind.outline': 'Só contorno',
|
||||
'journey.studio.travel': 'Viagem',
|
||||
'journey.studio.travelEmpty': 'Os números desta jornada ainda não estão prontos.',
|
||||
'journey.studio.grids': 'Grades',
|
||||
@@ -446,7 +447,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.dayMark': 'Contador de dias',
|
||||
'journey.studio.coordsMark': 'Coordenadas',
|
||||
'journey.studio.coordsDms': 'Graus',
|
||||
'journey.studio.coordsDecimal': 'Decimal',
|
||||
'journey.studio.coordsDecimal': 'Decimal', // same-as-en
|
||||
'journey.studio.flagMark': 'Bandeira',
|
||||
'journey.studio.distanceMark': 'Distância',
|
||||
'journey.studio.metric.distance': 'Distância',
|
||||
@@ -466,6 +467,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.copyToPage': 'Copiar, para colar em qualquer página (Ctrl+C)',
|
||||
'journey.studio.paste': 'Colar (Ctrl+V)',
|
||||
'journey.studio.pasteEmpty': 'Copie algo primeiro e depois cole em qualquer página',
|
||||
'journey.studio.pageFull':
|
||||
'Esta página está cheia: ela já tem o máximo de elementos de uma página. Remova algo primeiro ou use outra página.',
|
||||
'journey.studio.myLayouts': 'Minhas disposições',
|
||||
'journey.studio.myLayoutsEmpty':
|
||||
'Guarde uma página que você organizou e disponha outras páginas do mesmo jeito. As fotos e os textos delas continuam.',
|
||||
@@ -544,7 +547,7 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.deletePage': 'Excluir página',
|
||||
'journey.studio.movePageUp': 'Mover para antes',
|
||||
'journey.studio.movePageDown': 'Mover para depois',
|
||||
'journey.studio.beta': 'Beta',
|
||||
'journey.studio.beta': 'Beta', // same-as-en
|
||||
'journey.studio.addProsCons': 'Prós e contras',
|
||||
'journey.studio.showMarks': 'Marcas',
|
||||
'journey.studio.formatCustom': 'Tamanho próprio',
|
||||
@@ -609,12 +612,12 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.mapSourceTiles': 'Blocos de mapa',
|
||||
'journey.studio.mapSourceStatic': 'Mapbox',
|
||||
'journey.studio.mapSourceHint': 'Baixado ao renderizar e impresso com o crédito',
|
||||
'journey.studio.mapZoom': 'Zoom',
|
||||
'journey.studio.mapZoom': 'Zoom', // same-as-en
|
||||
'journey.studio.mapFraming': 'Enquadramento',
|
||||
'journey.studio.mapFitStops': 'Paradas',
|
||||
'journey.studio.mapFitCountry': 'País inteiro',
|
||||
'journey.studio.mapPadTight': 'Justo',
|
||||
'journey.studio.mapPadNormal': 'Normal',
|
||||
'journey.studio.mapPadNormal': 'Normal', // same-as-en
|
||||
'journey.studio.mapPadWide': 'Amplo',
|
||||
'journey.studio.mapPadFar': 'Muito amplo',
|
||||
'journey.studio.mapClipRect': 'Em moldura',
|
||||
|
||||
@@ -73,9 +73,9 @@ const login: TranslationStrings = {
|
||||
'login.resetPasswordFailed': 'Falha na redefinição. O link pode ter expirado.',
|
||||
'login.passkey.signIn': 'Entrar com uma passkey',
|
||||
'login.passkey.failed': 'Falha ao entrar com passkey. Tente novamente.',
|
||||
'login.insecureCookie.title': "Login won't stick over HTTP",
|
||||
'login.insecureCookie.title': 'O login não vai se manter por HTTP',
|
||||
'login.insecureCookie.body':
|
||||
'You’re connecting over plain HTTP, so your browser drops TREK’s secure session cookie — the next request fails with "Access token required". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.',
|
||||
'login.insecureCookie.link': 'Open the Troubleshooting guide',
|
||||
'Você está se conectando por HTTP simples, então seu navegador descarta o cookie de sessão seguro do TREK e a próxima requisição falha com "Access token required". Solução: use HTTPS ou, em um home lab, defina COOKIE_SECURE=false.',
|
||||
'login.insecureCookie.link': 'Abrir o guia de solução de problemas',
|
||||
};
|
||||
export default login;
|
||||
|
||||
@@ -6,7 +6,7 @@ const map: TranslationStrings = {
|
||||
'map.hideConnections': 'Ocultar rotas de reservas',
|
||||
'poi.searchThisArea': 'Pesquisar nesta área',
|
||||
'poi.cat.restaurants': 'Restaurantes',
|
||||
'poi.cat.cafes': 'Cafés',
|
||||
'poi.cat.cafes': 'Cafés', // same-as-en
|
||||
'poi.cat.bars': 'Bares e vida noturna',
|
||||
'poi.cat.hotels': 'Hospedagem',
|
||||
'poi.cat.sights': 'Pontos turísticos',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const mobileCollections: TranslationStrings = {
|
||||
'mobileCollections.status': 'Status',
|
||||
'mobileCollections.status': 'Status', // same-as-en
|
||||
};
|
||||
|
||||
export default mobileCollections;
|
||||
|
||||
@@ -12,7 +12,7 @@ const mobileJourney: TranslationStrings = {
|
||||
'mobileJourney.daysCount': '{count} dias',
|
||||
'mobileJourney.daysCount.one': '{count} dia',
|
||||
'mobileJourney.time': 'Hora',
|
||||
'mobileJourney.tags': 'Tags',
|
||||
'mobileJourney.tags': 'Tags', // same-as-en
|
||||
'mobileJourney.addTag': 'Adicionar tag',
|
||||
'mobileJourney.uploadFromDevice': 'Enviar do dispositivo',
|
||||
'mobileJourney.browseProvider': 'Navegar em {name}',
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { TranslationStrings } from '../types';
|
||||
|
||||
const nav: TranslationStrings = {
|
||||
'nav.settings': 'Configurações',
|
||||
'nav.admin': 'Admin',
|
||||
'nav.admin': 'Admin', // same-as-en
|
||||
'nav.logout': 'Sair',
|
||||
'nav.lightMode': 'Modo claro',
|
||||
'nav.darkMode': 'Modo escuro',
|
||||
@@ -13,7 +13,7 @@ const nav: TranslationStrings = {
|
||||
'nav.bottomSettings': 'Configurações',
|
||||
'nav.bottomAdmin': 'Administração',
|
||||
'nav.bottomLogout': 'Sair',
|
||||
'nav.bottomAdminBadge': 'Admin',
|
||||
'nav.help': 'Help',
|
||||
'nav.bottomAdminBadge': 'Admin', // same-as-en
|
||||
'nav.help': 'Ajuda',
|
||||
};
|
||||
export default nav;
|
||||
|
||||
+21
-19
@@ -12,7 +12,7 @@ const oauth: TranslationStrings = {
|
||||
'oauth.scope.group.collab': 'Colaboração',
|
||||
'oauth.scope.group.notifications': 'Notificações',
|
||||
'oauth.scope.group.vacay': 'Férias',
|
||||
'oauth.scope.group.geo': 'Geo',
|
||||
'oauth.scope.group.geo': 'Geo', // same-as-en
|
||||
'oauth.scope.group.weather': 'Clima',
|
||||
'oauth.scope.group.journey': 'Jornada',
|
||||
'oauth.scope.trips:read.label': 'Ver viagens e itinerários',
|
||||
@@ -76,26 +76,28 @@ const oauth: TranslationStrings = {
|
||||
'oauth.scope.journey:share.label': 'Gerenciar links de jornadas',
|
||||
'oauth.scope.journey:share.description':
|
||||
'Criar, atualizar e revogar links de compartilhamento públicos para jornadas',
|
||||
'oauth.authorize.authorizing': 'Authorizing…', // en-fallback
|
||||
'oauth.authorize.loading': 'Loading…', // en-fallback
|
||||
'oauth.authorize.errorTitle': 'Authorization Error', // en-fallback
|
||||
'oauth.authorize.loginTitle': 'Sign in to continue', // en-fallback
|
||||
'oauth.authorize.loginDescription': '{client} wants access to your TREK account. Please sign in first.', // en-fallback
|
||||
'oauth.authorize.loginButton': 'Sign in to TREK', // en-fallback
|
||||
'oauth.authorize.requestLabel': 'Authorization Request', // en-fallback
|
||||
'oauth.authorize.requestDescription': 'This application is requesting access to your TREK account.', // en-fallback
|
||||
'oauth.authorize.authorizing': 'Autorizando…',
|
||||
'oauth.authorize.loading': 'Carregando…',
|
||||
'oauth.authorize.errorTitle': 'Erro de autorização',
|
||||
'oauth.authorize.loginTitle': 'Entre para continuar',
|
||||
'oauth.authorize.loginDescription': '{client} quer acessar sua conta do TREK. Entre primeiro, por favor.',
|
||||
'oauth.authorize.loginButton': 'Entrar no TREK',
|
||||
'oauth.authorize.requestLabel': 'Solicitação de autorização',
|
||||
'oauth.authorize.requestDescription': 'Este aplicativo está solicitando acesso à sua conta do TREK.',
|
||||
'oauth.authorize.verifiedHost': 'Domínio verificado: {host}',
|
||||
'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.trustNote':
|
||||
'Conceda acesso apenas a aplicativos em que você confia. Seus dados ficam no seu servidor.',
|
||||
'oauth.authorize.selectScope': 'Selecione pelo menos um escopo',
|
||||
'oauth.authorize.approveScopes': 'Aprovar ({count} escopos)',
|
||||
'oauth.authorize.approveScopes.one': 'Aprovar ({count} escopo)',
|
||||
'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
|
||||
'oauth.authorize.approveAccess': 'Aprovar acesso',
|
||||
'oauth.authorize.deny': 'Negar',
|
||||
'oauth.authorize.choosePermissions': 'Escolha quais permissões conceder',
|
||||
'oauth.authorize.permissionsRequested': 'Permissões solicitadas',
|
||||
'oauth.authorize.alwaysIncluded': 'Sempre incluído',
|
||||
'oauth.authorize.alwaysTool.listTrips': 'Listar suas viagens para que a IA descubra os IDs das viagens',
|
||||
'oauth.authorize.alwaysTool.getTripSummary':
|
||||
'Ler o resumo de uma viagem, necessário para usar qualquer outra ferramenta',
|
||||
'oauth.scope.group.files': 'Arquivos',
|
||||
'oauth.scope.group.settings': 'Configurações',
|
||||
'oauth.scope.files:read.label': 'Ver arquivos da viagem',
|
||||
@@ -112,7 +114,7 @@ const oauth: TranslationStrings = {
|
||||
'oauth.scope.settings:write.label': 'Alterar suas preferências',
|
||||
'oauth.scope.settings:write.description':
|
||||
'Alterar unidades, formato de hora, idioma, moeda padrão e página inicial. Nunca as chaves de API salvas',
|
||||
'oauth.scope.group.plugins': 'Plugins',
|
||||
'oauth.scope.group.plugins': 'Plugins', // same-as-en
|
||||
'oauth.scope.plugins:use.label': 'Executar ferramentas de plugins',
|
||||
'oauth.scope.plugins:use.description':
|
||||
'Permite que este cliente chame ferramentas publicadas pelos plugins que um administrador instalou e aprovou. Cada plugin age com as permissões que já recebeu, não com os escopos deste token',
|
||||
|
||||
@@ -89,8 +89,8 @@ const places: TranslationStrings = {
|
||||
'places.formAddressPlaceholder': 'Rua, cidade, país',
|
||||
'places.formLat': 'Latitude (ex.: -23.5505)',
|
||||
'places.formLng': 'Longitude (ex.: -46.6333)',
|
||||
'places.formLatLabel': 'Latitude',
|
||||
'places.formLngLabel': 'Longitude',
|
||||
'places.formLatLabel': 'Latitude', // same-as-en
|
||||
'places.formLngLabel': 'Longitude', // same-as-en
|
||||
'places.formCategory': 'Categoria',
|
||||
'places.noCategory': 'Sem categoria',
|
||||
'places.newCategory': 'Nova categoria',
|
||||
@@ -103,7 +103,7 @@ const places: TranslationStrings = {
|
||||
'places.timeCollision': 'Sobreposição de horário com:',
|
||||
'places.formWebsite': 'Site',
|
||||
'places.formPhone': 'Telefone',
|
||||
'places.formEmail': 'E-mail',
|
||||
'places.formEmail': 'E-mail', // same-as-en
|
||||
'places.hoursAdd': 'Adicionar horário de funcionamento',
|
||||
'places.hoursClosed': 'Fechado',
|
||||
'places.hoursOpens': 'abre',
|
||||
|
||||
@@ -12,7 +12,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.summary': '{confirmed} confirmada(s), {pending} pendente(s)',
|
||||
'reservations.showFiles': 'Mostrar arquivos',
|
||||
'reservations.editTitle': 'Editar reserva',
|
||||
'reservations.status': 'Status',
|
||||
'reservations.status': 'Status', // same-as-en
|
||||
'reservations.datetime': 'Data e hora',
|
||||
'reservations.startTime': 'Horário de início',
|
||||
'reservations.endTime': 'Horário de término',
|
||||
@@ -40,9 +40,9 @@ const reservations: TranslationStrings = {
|
||||
'reservations.meta.trainNumber': 'Nº do trem',
|
||||
'reservations.meta.platform': 'Plataforma',
|
||||
'reservations.meta.seat': 'Assento',
|
||||
'reservations.meta.checkIn': 'Check-in',
|
||||
'reservations.meta.checkIn': 'Check-in', // same-as-en
|
||||
'reservations.meta.checkInUntil': 'Check-in até',
|
||||
'reservations.meta.checkOut': 'Check-out',
|
||||
'reservations.meta.checkOut': 'Check-out', // same-as-en
|
||||
'reservations.meta.linkAccommodation': 'Hospedagem',
|
||||
'reservations.meta.pickAccommodation': 'Vincular à hospedagem',
|
||||
'reservations.meta.noAccommodation': 'Nenhuma',
|
||||
@@ -192,7 +192,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.linkExpense': 'Vincular despesa existente',
|
||||
'reservations.unlinkExpense': 'Desvincular, manter a despesa',
|
||||
'reservations.noUnlinkedExpenses': 'Nenhuma despesa sem vínculo',
|
||||
'reservations.urlLabel': 'Link',
|
||||
'reservations.urlLabel': 'Link', // same-as-en
|
||||
'reservations.urlPlaceholder': 'https://...',
|
||||
'reservations.travelers.label': 'Viajantes',
|
||||
'reservations.travelers.assign': 'Atribuir viajantes',
|
||||
@@ -220,7 +220,7 @@ const reservations: TranslationStrings = {
|
||||
'reservations.export.notes': 'Notas',
|
||||
'reservations.resetView': 'Redefinir visualização',
|
||||
'reservations.group.label': 'Agrupar por',
|
||||
'reservations.group.status': 'Status',
|
||||
'reservations.group.status': 'Status', // same-as-en
|
||||
'reservations.group.day': 'Dia',
|
||||
'reservations.group.type': 'Tipo',
|
||||
'reservations.group.none': 'Sem agrupamento',
|
||||
@@ -232,14 +232,14 @@ const reservations: TranslationStrings = {
|
||||
'reservations.sort.date': 'Data',
|
||||
'reservations.sort.title': 'Título',
|
||||
'reservations.sort.type': 'Tipo',
|
||||
'reservations.sort.status': 'Status',
|
||||
'reservations.sort.status': 'Status', // same-as-en
|
||||
'reservations.sort.earlyFirst': 'Mais cedo primeiro',
|
||||
'reservations.sort.lateFirst': 'Mais tarde primeiro',
|
||||
'reservations.sort.aToZ': 'A a Z',
|
||||
'reservations.sort.zToA': 'Z a A',
|
||||
'reservations.timeline.dayCount': '{count} dias',
|
||||
'reservations.timeline.dayCount.one': '{count} dia',
|
||||
'reservations.timeline.zoom': 'Zoom',
|
||||
'reservations.timeline.zoom': 'Zoom', // same-as-en
|
||||
'reservations.timeline.trip': 'Viagem',
|
||||
'reservations.timeline.day': 'Dia',
|
||||
'reservations.timeline.prevDay': 'Dia anterior',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user