mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-27 17:13:01 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4da05bbc9 | |||
| bd1de9e1d5 | |||
| c33eb5b593 | |||
| dcfbf89234 | |||
| 6b6d971f07 | |||
| 9fc98b5d49 | |||
| daaa6c425d | |||
| b33ef6bec2 | |||
| eb9f6a3438 | |||
| 7823ab1550 | |||
| 47fdd3588c | |||
| a9ebd2d1ac | |||
| 9c32b42256 | |||
| 26611b46e4 | |||
| e3eec8d6d1 | |||
| 2d398568da |
@@ -234,12 +234,15 @@ the details.
|
||||
## Get started in 30 seconds
|
||||
|
||||
```bash
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d --name trek -p 3000:3000 \
|
||||
-e ENCRYPTION_KEY=$ENCRYPTION_KEY \
|
||||
-e COOKIE_SECURE=false \
|
||||
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
|
||||
```
|
||||
|
||||
Open `http://localhost:3000`. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
|
||||
Open `http://localhost:3000`, or `http://<server-ip>:3000` from another machine. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
|
||||
|
||||
`COOKIE_SECURE=false` is only for trying TREK over plain HTTP: without it the browser drops the session cookie and the login does not stick. Once TREK sits behind HTTPS (see [Reverse Proxy](#reverse-proxy)), remove it.
|
||||
|
||||
<div align="center">
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 4.3.4
|
||||
version: 5.0.0
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "4.3.4"
|
||||
appVersion: "5.0.0"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "4.3.4",
|
||||
"version": "5.0.0",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 384,
|
||||
"@typescript-eslint/no-non-null-assertion": 382,
|
||||
"@typescript-eslint/no-misused-promises": 849,
|
||||
"no-console": 88,
|
||||
"complexity": 221,
|
||||
@@ -20,7 +20,7 @@
|
||||
"no-useless-assignment": 7,
|
||||
"no-empty": 19,
|
||||
"trek/no-window-globals": 41,
|
||||
"react-refresh/only-export-components": 45,
|
||||
"react-refresh/only-export-components": 44,
|
||||
"unused-disable-directive": 6,
|
||||
"max-depth": 21,
|
||||
"react-hooks/void-use-memo": 1,
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
},
|
||||
{
|
||||
"feature": "AI assistant chat",
|
||||
"hooks": ["src/components/Assistant/useAssistantThread.ts"],
|
||||
"hooks": ["src/components/Assistant/useAssistantThread.ts", "src/components/Assistant/useAssistantStatus.ts"],
|
||||
"desktop": ["src/components/Assistant/AssistantWindow.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/assistant/MAssistantScreen.tsx"]
|
||||
},
|
||||
|
||||
@@ -59,8 +59,6 @@
|
||||
"components/Studio/StudioSavedLayouts.tsx": 1,
|
||||
"components/Studio/TravelElements.tsx": 4,
|
||||
"components/Studio/TravelPreview.tsx": 1,
|
||||
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 3,
|
||||
"components/SystemNotices/releaseNotice.css": 2,
|
||||
"components/Vacay/VacayCalendar.tsx": 3,
|
||||
"components/Vacay/VacayMonthCard.tsx": 7,
|
||||
"components/Vacay/VacaySettings.tsx": 1,
|
||||
|
||||
@@ -105,7 +105,6 @@
|
||||
"components/Studio/TravelElements.tsx": 5,
|
||||
"components/Studio/printSheets.ts": 5,
|
||||
"components/Studio/sidebar/contentDrops.ts": 2,
|
||||
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 4,
|
||||
"components/SystemNotices/SystemNoticeBanner.tsx": 28,
|
||||
"components/SystemNotices/SystemNoticeModal.tsx": 107,
|
||||
"components/Todo/TodoListPanel.tsx": 6,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, I
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import AirTrailIcon from '../shared/AirTrailIcon'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../shared/DocumentProviderIcons'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../shared/documentProviderIconMap'
|
||||
import AddonTile from './AddonTile'
|
||||
import AddonSubRow from './AddonSubRow'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function AddonSubRow({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
badge,
|
||||
enabled,
|
||||
onToggle,
|
||||
}: {
|
||||
@@ -26,6 +27,8 @@ export default function AddonSubRow({
|
||||
icon?: ReactNode
|
||||
title: string
|
||||
description?: string
|
||||
/** A small pill after the title, e.g. the beta mark on the assistant. */
|
||||
badge?: ReactNode
|
||||
enabled: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
@@ -34,8 +37,11 @@ export default function AddonSubRow({
|
||||
{icon
|
||||
? <span className={`grid h-6 w-6 flex-none place-items-center ${enabled ? 'text-content-muted' : 'text-content-faint'}`}>{icon}</span>
|
||||
: <span className="w-6 flex-none" aria-hidden />}
|
||||
<span className={`min-w-0 flex-1 truncate font-medium ${enabled ? 'text-content' : 'text-content-muted'}`} style={fs(12.5, 'body')} title={description}>
|
||||
{title}
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className={`min-w-0 truncate font-medium ${enabled ? 'text-content' : 'text-content-muted'}`} style={fs(12.5, 'body')} title={description}>
|
||||
{title}
|
||||
</span>
|
||||
{badge}
|
||||
</span>
|
||||
<ToggleSwitch on={enabled} onToggle={onToggle} label={title} />
|
||||
</li>
|
||||
|
||||
@@ -66,6 +66,7 @@ export default function AiAddonTile({
|
||||
icon={<Icon size={14} />}
|
||||
title={label.name}
|
||||
description={label.description}
|
||||
badge={addon.id === 'ai_assistant' ? <BetaBadge label={t('assistant.setup.beta')} /> : undefined}
|
||||
enabled={addon.enabled}
|
||||
onToggle={() => onToggle(addon)}
|
||||
/>
|
||||
@@ -93,3 +94,16 @@ export default function AiAddonTile({
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/** The assistant is still young; its switch says so with a small accent pill. */
|
||||
function BetaBadge({ label }: { label: string }): React.ReactElement {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex flex-none items-center gap-1 rounded-full bg-accent px-[7px] py-[2px] font-geist font-bold uppercase leading-none tracking-[.08em] text-accent-text shadow-sm"
|
||||
style={fs(9)}
|
||||
>
|
||||
<Sparkles size={9} strokeWidth={2.6} aria-hidden />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import { MASKED_SETTING_VALUE } from '@trek/shared';
|
||||
import { createElement, type ReactNode } from 'react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { seedStore } from '../../../../tests/helpers/store';
|
||||
import { TranslationProvider } from '../../../i18n/TranslationContext';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
|
||||
const repo = vi.hoisted(() => ({
|
||||
@@ -59,6 +61,8 @@ async function renderLoaded() {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAssistantStore.setState({ status: null });
|
||||
// The settings show while the assistant is on, and only then is its status asked for.
|
||||
seedStore(useAddonStore, { loaded: true, addons: [{ id: 'ai_assistant', enabled: true }] });
|
||||
repo.status.mockRejectedValue(new Error('not needed'));
|
||||
repo.adminSettings.mockResolvedValue(SETTINGS);
|
||||
repo.saveAdminSettings.mockImplementation(async (next: AiAdminSettings) => next);
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { AssistantChatTrip } from './assistantViewContext';
|
||||
import type { EntityTarget } from './cards/cardTypes';
|
||||
import { chatPagePath, useAssistantHome, useRouteJournalChat } from './useAssistantHome';
|
||||
import { useAssistantHotkey } from './useAssistantHotkey';
|
||||
import { useAssistantStatus } from './useAssistantStatus';
|
||||
import { useAssistantThread } from './useAssistantThread';
|
||||
|
||||
/**
|
||||
@@ -202,15 +203,12 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
|
||||
const available = useAssistantStore((s) => s.status?.available === true);
|
||||
const open = useAssistantStore((s) => s.window.open);
|
||||
const minimized = useAssistantStore((s) => s.window.minimized);
|
||||
const loadStatus = useAssistantStore((s) => s.loadStatus);
|
||||
const toggle = useAssistantStore((s) => s.toggle);
|
||||
const [picked, setPicked] = useState<WindowTrip | null>(null);
|
||||
const [wasOpen, setWasOpen] = useState(open);
|
||||
const home = useAssistantHome(setPicked);
|
||||
|
||||
useEffect(() => {
|
||||
void loadStatus();
|
||||
}, [loadStatus]);
|
||||
useAssistantStatus();
|
||||
useAssistantHotkey(available, toggle);
|
||||
|
||||
// A trip picked outside the planner is only for this opening; the next one opens on the home chat again.
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* FE-AI-STATUS-001 to FE-AI-STATUS-003
|
||||
*
|
||||
* The views of the assistant ask for its status through this hook: not while
|
||||
* both addons that open /api/ai are off, where the server answers 404, and
|
||||
* as soon as one comes on, which the addon list can say after the view
|
||||
* mounted (the first load, an admin's switch). The assistant's own addon
|
||||
* switching asks again while AI parsing keeps /api/ai open.
|
||||
*/
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { AiStatusResponse } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { aiConnectionsRepo } from '../../repo/aiConnectionsRepo';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import { useAssistantStatus } from './useAssistantStatus';
|
||||
|
||||
const INITIAL = useAssistantStore.getState();
|
||||
const STATUS = { available: true, retention: { mode: 'days', days: 90 } } as AiStatusResponse;
|
||||
|
||||
/** The addon list as it lands from the server, with these addons on. */
|
||||
const addonsOn = (...ids: string[]) =>
|
||||
seedStore(useAddonStore, { loaded: true, addons: ids.map((id) => ({ id, enabled: true })) });
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useAssistantStore.setState(INITIAL, true);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('useAssistantStatus', () => {
|
||||
it('FE-AI-STATUS-001: asks nothing before the addon list or with both addons off, and asks once one comes on', async () => {
|
||||
const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS);
|
||||
renderHook(() => useAssistantStatus());
|
||||
act(() => addonsOn('mcp'));
|
||||
expect(status).not.toHaveBeenCalled();
|
||||
expect(useAssistantStore.getState().status).toBeNull();
|
||||
|
||||
act(() => addonsOn('ai_assistant'));
|
||||
await waitFor(() => expect(useAssistantStore.getState().status).toEqual(STATUS));
|
||||
expect(status).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The list read again with the assistant still on asks nothing more.
|
||||
act(() => addonsOn('ai_assistant', 'mcp'));
|
||||
expect(status).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-AI-STATUS-002: with AI parsing on it asks at once, and the last addon going off hides the assistant', async () => {
|
||||
const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS);
|
||||
addonsOn('llm_parsing');
|
||||
renderHook(() => useAssistantStatus());
|
||||
await waitFor(() => expect(useAssistantStore.getState().status).toEqual(STATUS));
|
||||
expect(status).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => addonsOn());
|
||||
expect(useAssistantStore.getState().status).toBeNull();
|
||||
expect(status).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-AI-STATUS-003: with AI parsing on, the assistant switched on or off asks again', async () => {
|
||||
const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS);
|
||||
addonsOn('llm_parsing');
|
||||
renderHook(() => useAssistantStatus());
|
||||
await waitFor(() => expect(status).toHaveBeenCalledTimes(1));
|
||||
|
||||
act(() => addonsOn('llm_parsing', 'ai_assistant'));
|
||||
await waitFor(() => expect(status).toHaveBeenCalledTimes(2));
|
||||
|
||||
act(() => addonsOn('llm_parsing'));
|
||||
await waitFor(() => expect(status).toHaveBeenCalledTimes(3));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { aiRoutesOpen, useAssistantStore } from '../../store/assistantStore';
|
||||
|
||||
/**
|
||||
* Asks for the assistant's status when a view of it mounts, and again when
|
||||
* either addon that opens /api/ai is switched: the addon list can land after
|
||||
* the view, and an admin can switch either addon while it is open. The
|
||||
* assistant's own addon counts on its own, since the status says it runs only
|
||||
* while that one is on, even with AI parsing keeping /api/ai open. The store
|
||||
* asks the server only while one is on (aiRoutesOpen).
|
||||
*/
|
||||
export function useAssistantStatus(): void {
|
||||
const loadStatus = useAssistantStore((s) => s.loadStatus);
|
||||
const routesOpen = useAddonStore(aiRoutesOpen);
|
||||
const assistantOn = useAddonStore((s) => s.isEnabled('ai_assistant'));
|
||||
|
||||
useEffect(() => {
|
||||
void loadStatus();
|
||||
}, [routesOpen, assistantOn, loadStatus]);
|
||||
}
|
||||
@@ -25,13 +25,25 @@ interface CollabLink {
|
||||
pinned: boolean | number
|
||||
}
|
||||
|
||||
// The glyph holds the place until a favicon has really arrived: a host that never
|
||||
// answers, or answers with a blank pixel, left the chip with an empty square.
|
||||
function LinkIcon({ url }: { url: string }) {
|
||||
const [failed, setFailed] = useState(false)
|
||||
const [state, setState] = useState<'loading' | 'loaded' | 'failed'>('loading')
|
||||
let favicon = ''
|
||||
try { favicon = new URL('/favicon.ico', url).href } catch { /* not a parseable url, fall through to the glyph */ }
|
||||
const loaded = state === 'loaded'
|
||||
return (
|
||||
<span className="collab-link-chip__icon" aria-hidden="true">
|
||||
{!favicon || failed ? <Link2 size={13} /> : <img src={favicon} alt="" onError={() => setFailed(true)} />}
|
||||
{!loaded && <Link2 size={13} />}
|
||||
{favicon && state !== 'failed' && (
|
||||
<img
|
||||
src={favicon}
|
||||
alt=""
|
||||
hidden={!loaded}
|
||||
onLoad={(e) => setState(e.currentTarget.naturalWidth > 2 ? 'loaded' : 'failed')}
|
||||
onError={() => setState('failed')}
|
||||
/>
|
||||
)}
|
||||
<span className="collab-link-chip__go"><ExternalLink size={13} /></span>
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -38,6 +38,20 @@ function VoterChip({ name, src, offset }: { name: string; src: string | null; of
|
||||
);
|
||||
}
|
||||
|
||||
/** The voters past the third, as a count on the end of the stack; their names on hover. */
|
||||
function MoreVoters({ names }: { names: string[] }) {
|
||||
return (
|
||||
<Tooltip label={names.map((name) => name || '?').join(', ')}>
|
||||
<span
|
||||
className="grid h-[18px] min-w-[18px] flex-none place-items-center rounded-full border-[1.5px] border-surface-card bg-surface-tertiary px-[3px] font-bold tabular-nums text-content-muted"
|
||||
style={{ ...fs(7), marginInlineStart: -5 }}
|
||||
>
|
||||
+{names.length}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** A fact about the poll on its head band, set like the type chip on a booking card. */
|
||||
export function PollChip({
|
||||
icon,
|
||||
@@ -138,6 +152,7 @@ export function PollOptionRow({
|
||||
{voters.slice(0, 3).map((voter, index) => (
|
||||
<VoterChip key={voter.key} name={voter.name} src={voter.src} offset={index > 0} />
|
||||
))}
|
||||
{voters.length > 3 && <MoreVoters names={voters.slice(3).map((voter) => voter.name)} />}
|
||||
</span>
|
||||
)}
|
||||
{pending && <span className="sr-only">{pending.label}</span>}
|
||||
|
||||
@@ -191,6 +191,18 @@ describe('CollabPolls', () => {
|
||||
expect(await screen.findByText(/closed/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-025: past three voters the stack ends in a count of the rest', async () => {
|
||||
const voters = ['anna', 'ben', 'cleo', 'dana', 'emil'].map((username, i) => ({ user_id: 10 + i, username, avatar_url: null }));
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll({ is_closed: true, options: [{ id: 1, text: 'Paris', label: 'Paris', voters }, { id: 2, text: 'Rome', label: 'Rome', voters: [] }] })] }),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
expect(await screen.findByText('+2')).toBeInTheDocument();
|
||||
expect(screen.queryByText('D')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-011: closed poll options are disabled (cannot vote)', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { buildTrip } from '../../../tests/helpers/factories'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -171,6 +174,20 @@ describe('WhatsNextWidget', () => {
|
||||
expect(screen.getByText('TBD')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-018: a hotel without a time of its own shows the check-in of the stay it begins', async () => {
|
||||
server.use(http.get('/api/trips/1/accommodations', () => HttpResponse.json({
|
||||
accommodations: [{ id: 7, trip_id: 1, place_id: 40, start_day_id: 1, end_day_id: 2, check_in: '15:00', check_out: '11:00' }],
|
||||
})))
|
||||
seedStore(useTripStore, {
|
||||
trip: buildTrip({ id: 1 }),
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
assignments: { '1': [makeAssignment(40, { name: 'Hotel Kea', place_time: null })] },
|
||||
})
|
||||
render(<WhatsNextWidget />)
|
||||
expect(await screen.findByText('15:00')).toBeInTheDocument()
|
||||
expect(screen.queryByText('TBD')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-WHATSNEXT-009: renders address when provided', () => {
|
||||
seedStore(useTripStore, {
|
||||
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, day_number: 0, assignments: [], notes_items: [], notes: null }],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import { avatarSrc } from '../../utils/avatarSrc'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -8,6 +8,8 @@ import { fs } from '../shared/DialogShell'
|
||||
import CollabPanelHead from './CollabPanelHead'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { localToday } from '../Planner/today'
|
||||
import { useTripStays } from '../Planner/useTripStays'
|
||||
import { stayStartingOn } from '../../utils/dayMerge'
|
||||
|
||||
function formatTime(timeStr, is12h) {
|
||||
if (!timeStr) return ''
|
||||
@@ -48,6 +50,10 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
const { days, assignments } = useTripStore()
|
||||
const { t, locale } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
// A hotel on the day its stay begins is timed by the check-in when the stop has no time of its own.
|
||||
const tripId = useTripStore(s => s.trip?.id ?? 0)
|
||||
const { tripAccommodations, loadAccommodations } = useTripStays(tripId)
|
||||
useEffect(() => { loadAccommodations() }, [loadAccommodations])
|
||||
|
||||
const upcoming = useMemo(() => {
|
||||
const now = new Date()
|
||||
@@ -60,14 +66,15 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
const dayAssignments = assignments[String(day.id)] || []
|
||||
for (const a of dayAssignments) {
|
||||
if (!a.place) continue
|
||||
const time = a.place.place_time || stayStartingOn(tripAccommodations, a.place.id, day.id)?.check_in || null
|
||||
// Include: today (not started yet, or still running) + all future days
|
||||
const isFutureDay = day.date > nowDate
|
||||
const isTodayFuture = day.date === nowDate && (!a.place.place_time || a.place.place_time >= nowTime || (!!a.place.end_time && a.place.end_time >= nowTime))
|
||||
const isTodayFuture = day.date === nowDate && (!time || time >= nowTime || (!!a.place.end_time && a.place.end_time >= nowTime))
|
||||
if (isFutureDay || isTodayFuture) {
|
||||
items.push({
|
||||
id: a.id,
|
||||
name: a.place.name,
|
||||
time: a.place.place_time,
|
||||
time,
|
||||
endTime: a.place.end_time,
|
||||
date: day.date,
|
||||
dayTitle: day.title,
|
||||
@@ -88,7 +95,7 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
})
|
||||
|
||||
return items.slice(0, 8)
|
||||
}, [days, assignments, tripMembers])
|
||||
}, [days, assignments, tripMembers, tripAccommodations])
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
|
||||
@@ -7,7 +7,7 @@ import CustomSelect from '../../shared/CustomSelect'
|
||||
import ToggleSwitch from '../../Settings/ToggleSwitch'
|
||||
import Tooltip from '../../shared/Tooltip'
|
||||
import { useTranslation } from '../../../i18n/TranslationContext'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/documentProviderIconMap'
|
||||
import DocSyncFlow, { type SyncDirection } from './DocSyncFlow'
|
||||
import { Badge, CONFLICT_POLICIES, conflictPolicyKey, LastRun, StateBadge } from './DocSyncBits'
|
||||
import { bindingNotice, needsReauth, type DocSyncLink, type useDocSync } from './useDocSync'
|
||||
|
||||
@@ -6,7 +6,7 @@ import ToggleSwitch from '../../Settings/ToggleSwitch'
|
||||
import { useTranslation } from '../../../i18n/TranslationContext'
|
||||
import { useConnectForm } from './useConnectForm'
|
||||
import { Badge } from './DocSyncBits'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/documentProviderIconMap'
|
||||
import type { DocSyncProvider, DocSyncVerdict, useDocSync } from './useDocSync'
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ArrowLeft, ArrowRight } from 'lucide-react'
|
||||
import { useTranslation } from '../../../i18n/TranslationContext'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/documentProviderIconMap'
|
||||
import TrekIcon from '../../shared/TrekIcon'
|
||||
import { Badge } from './DocSyncBits'
|
||||
import { toggledDirection, type SyncDirection } from './docSyncModel'
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import { AlertCircle, ArrowRight, Check, FolderSync, Loader2, Plus } from 'lucide-react'
|
||||
import { DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT } from '../../shared/DialogShell'
|
||||
import { useTranslation } from '../../../i18n/TranslationContext'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/documentProviderIconMap'
|
||||
import { StateBadge } from './DocSyncBits'
|
||||
import { slugFor } from './docSyncModel'
|
||||
import { useConflicts } from './useConflicts'
|
||||
|
||||
@@ -118,9 +118,10 @@ export function DayPlanPlaceSubline({
|
||||
const description = place.description || place.address || cat?.name;
|
||||
if (!(dayRowTour || place.place_time || hasPlannedTimes(tourTimes) || description)) return null;
|
||||
return (
|
||||
// One line like the row's name: when the hover controls come out, the description
|
||||
// gives way and the pills keep their place instead of wrapping onto a second line.
|
||||
<div className="trek-fade-end mt-0.5 flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden">
|
||||
// The description stays on one line and gives way first. The pills wrap rather
|
||||
// than run out of the row: a tour carries a time, its duration and its distance,
|
||||
// and in the narrow day column the last of them used to be cut off unseen.
|
||||
<div className="trek-fade-end mt-0.5 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 overflow-hidden">
|
||||
{place.place_time && dayRowTour && (
|
||||
// TIME-02: a tour's end, typed or worked out from its start and its time.
|
||||
<TimePill quiet>
|
||||
|
||||
@@ -201,9 +201,9 @@ describe('DayPlanSidebar', () => {
|
||||
const view = render(<DayPlanSidebar {...props} />);
|
||||
const tourPreview = view.container.querySelector('[data-testid="trip-plan-tour-description"]') as HTMLElement;
|
||||
expect(tourPreview).toHaveTextContent(description);
|
||||
// One line like the row's name, so hover controls make the description give way instead of wrapping.
|
||||
// The description keeps to one line; the pills beside it wrap rather than run out of the row.
|
||||
expect(tourPreview).toHaveStyle({ whiteSpace: 'nowrap', maxHeight: '1.3em' });
|
||||
expect(tourPreview.parentElement).toHaveClass('flex-nowrap', 'overflow-hidden');
|
||||
expect(tourPreview.parentElement).toHaveClass('flex-wrap', 'overflow-hidden');
|
||||
expect(screen.getByTestId('trip-plan-tour-walking-time')).toHaveTextContent('Walk: 1 h');
|
||||
expect(screen.getByTestId('trip-plan-tour-breaks')).toHaveTextContent('Breaks: 35 min');
|
||||
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('Planned: 1 h 35 min');
|
||||
|
||||
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildUser } from '../../../../tests/helpers/factories';
|
||||
import { render, screen } from '../../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
@@ -33,6 +34,8 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAssistantStore.setState({ status: null });
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
// The card shows while the assistant is on, and only then is its status asked for.
|
||||
seedStore(useAddonStore, { loaded: true, addons: [{ id: 'ai_assistant', enabled: true }] });
|
||||
repo.models.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildAdmin, buildUser } from '../../../../tests/helpers/factories';
|
||||
import { render, screen, waitFor } from '../../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
|
||||
@@ -51,6 +52,8 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAssistantStore.setState({ status: null });
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
// The card shows while the assistant is on, and only then is its status asked for.
|
||||
seedStore(useAddonStore, { loaded: true, addons: [{ id: 'ai_assistant', enabled: true }] });
|
||||
repo.models.mockResolvedValue([{ id: 'claude-sonnet', label: 'Claude Sonnet', supportsTools: true }]);
|
||||
repo.disconnect.mockResolvedValue(undefined);
|
||||
repo.chatgptStart.mockResolvedValue({
|
||||
|
||||
@@ -4,7 +4,9 @@ import type { AiConnectionView, AiStatusResponse } from '@trek/shared';
|
||||
import { createElement, type ReactNode } from 'react';
|
||||
import { MemoryRouter, useLocation } from 'react-router';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { seedStore } from '../../../../tests/helpers/store';
|
||||
import { TranslationProvider } from '../../../i18n/TranslationContext';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
|
||||
const repo = vi.hoisted(() => ({
|
||||
@@ -61,6 +63,8 @@ function renderConnections(phone = false) {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAssistantStore.setState({ status: null, window: { open: false, minimized: false } });
|
||||
// The card shows while the assistant is on, and only then is its status asked for.
|
||||
seedStore(useAddonStore, { loaded: true, addons: [{ id: 'ai_assistant', enabled: true }] });
|
||||
repo.status.mockResolvedValue(status());
|
||||
repo.models.mockResolvedValue([
|
||||
{ id: 'claude-sonnet', label: 'Claude Sonnet', supportsTools: true },
|
||||
|
||||
@@ -24,9 +24,9 @@ function releaseNotice(overrides: Partial<SystemNoticeDTO> = {}): SystemNoticeDT
|
||||
introKey: 'rel.intro',
|
||||
featuresLabelKey: 'rel.features.label',
|
||||
features: [
|
||||
{ iconName: 'Database', visual: 'places-api', titleKey: 'rel.f1.title', bodyKey: 'rel.f1.body' },
|
||||
{ iconName: 'Route', visual: 'roadtrip', titleKey: 'rel.f2.title', bodyKey: 'rel.f2.body' },
|
||||
{ iconName: 'MapPin', visual: 'dawarich', titleKey: 'rel.f3.title', bodyKey: 'rel.f3.body' },
|
||||
{ iconName: 'Mountain', visual: 'tours', titleKey: 'rel.f1.title', bodyKey: 'rel.f1.body' },
|
||||
{ iconName: 'BookOpen', visual: 'studio', titleKey: 'rel.f2.title', bodyKey: 'rel.f2.body' },
|
||||
{ iconName: 'Sparkles', visual: 'assistant', titleKey: 'rel.f3.title', bodyKey: 'rel.f3.body' },
|
||||
],
|
||||
note: {
|
||||
eyebrowKey: 'rel.note.eyebrow',
|
||||
@@ -78,9 +78,9 @@ describe('ReleaseNoticeModal', () => {
|
||||
renderModal()
|
||||
expect(screen.getByText('rel.f1.title')).toBeInTheDocument()
|
||||
expect(document.querySelectorAll('.rn-feature')).toHaveLength(3)
|
||||
expect(document.querySelector('.rn-vis-places')).not.toBeNull()
|
||||
expect(document.querySelector('.rn-vis-road')).not.toBeNull()
|
||||
expect(document.querySelector('.rn-vis-trail')).not.toBeNull()
|
||||
expect(document.querySelector('.rn-vis-tours')).not.toBeNull()
|
||||
expect(document.querySelector('.rn-vis-studio')).not.toBeNull()
|
||||
expect(document.querySelector('.rn-vis-assist')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-RN-004: splits the note body on the blank line into separate paragraphs', () => {
|
||||
@@ -194,20 +194,20 @@ describe('ReleaseNoticeModal', () => {
|
||||
const n = releaseNotice()
|
||||
n.release!.features = [
|
||||
...n.release!.features,
|
||||
{ iconName: 'FolderSync', visual: 'docsync', layout: 'wide', titleKey: 'rel.f4.title', bodyKey: 'rel.f4.body' },
|
||||
{ iconName: 'LifeBuoy', visual: 'help', layout: 'wide', titleKey: 'rel.f4.title', bodyKey: 'rel.f4.body' },
|
||||
]
|
||||
renderModal(n)
|
||||
const cards = document.querySelectorAll('.rn-feature')
|
||||
expect(cards).toHaveLength(4)
|
||||
expect(cards[3].classList.contains('rn-feature-wide')).toBe(true)
|
||||
expect([...cards].slice(0, 3).some(c => c.classList.contains('rn-feature-wide'))).toBe(false)
|
||||
// The document sync drawing: the wordmark without its API pill, and the five stores.
|
||||
// The help drawing: the help button and two rows of greetings, each written twice to loop.
|
||||
const wide = cards[3]
|
||||
expect(wide.querySelector('.rn-vis-docs')).not.toBeNull()
|
||||
expect(wide.querySelectorAll('.rn-vis-docs-store')).toHaveLength(5)
|
||||
expect(wide.querySelector('.trek-mark .pill-bg')).toBeNull()
|
||||
// The places card keeps the pill: that one is about the index.
|
||||
expect(cards[0].querySelector('.trek-mark .pill-bg')).not.toBeNull()
|
||||
expect(wide.querySelector('.rn-vis-help-q')).not.toBeNull()
|
||||
expect(wide.querySelectorAll('.rn-vis-hello-row')).toHaveLength(2)
|
||||
expect(wide.querySelectorAll('.rn-vis-hello').length % 2).toBe(0)
|
||||
// The tours card lists the kinds of tour along its foot.
|
||||
expect(cards[0].querySelectorAll('.rn-vis-tours-kind')).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('FE-RN-016: the close button belongs to the panel, not the note', () => {
|
||||
|
||||
@@ -1,94 +1,166 @@
|
||||
import React from 'react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { ArrowLeftRight, Car, Landmark, Search, Sparkles } from 'lucide-react';
|
||||
import DawarichIcon from '../shared/DawarichIcon';
|
||||
import { NextcloudIcon, OpenCloudIcon, PaperlessIcon, PapraIcon, SynologyDriveIcon } from '../shared/DocumentProviderIcons';
|
||||
import TrekMark from '../shared/TrekMark';
|
||||
import { Bike, Check, Footprints, Landmark, Plane, Sailboat, Snowflake, Sparkles, Star, TrendingUp } from 'lucide-react';
|
||||
import './releaseNoticeVisuals.css';
|
||||
|
||||
/*
|
||||
* The small pictures on the release cards: pieces of TREK itself, drawn at card
|
||||
* size rather than screenshotted, because a screenshot this small is a blur. They
|
||||
* carry no words, only place names and figures that read the same in every
|
||||
* language, so nothing here needs translating.
|
||||
* carry no words, only place names, greetings and figures that read the same in
|
||||
* every language, so nothing here needs translating. Every colour lives in
|
||||
* releaseNoticeVisuals.css, and every motion stops under reduced motion.
|
||||
*/
|
||||
|
||||
function PlacesApiVisual() {
|
||||
return (
|
||||
<div className="rn-vis rn-vis-places">
|
||||
<span className="rn-vis-trekbadge"><TrekMark className="rn-vis-trekmark" /></span>
|
||||
<div className="rn-vis-search">
|
||||
<Search size={11} strokeWidth={2.4} />
|
||||
<span className="rn-vis-typed">Fushimi Inari</span>
|
||||
<span className="rn-vis-caret" />
|
||||
</div>
|
||||
<div className="rn-vis-result">
|
||||
<span className="rn-vis-cat" style={{ background: '#ef4444' }}><Landmark size={10} strokeWidth={2.2} /></span>
|
||||
<span className="rn-vis-place">
|
||||
<span>Fushimi Inari Taisha</span>
|
||||
<small>Kyoto</small>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// ── Tours: a trail climbing to the summit at dusk, and the kinds of tour under it ──
|
||||
|
||||
// The route and its stops, in the planner's own route blue and marker style.
|
||||
const ROUTE = 'M26 84 C 58 82, 60 44, 96 46 S 136 74, 160 52 S 176 30, 182 28';
|
||||
const FAR_RIDGE = 'M0 66 L20 50 L38 60 L60 38 L84 56 L104 44 L128 58 L152 40 L176 54 L200 46 V112 H0 Z';
|
||||
const MOUNTAIN = 'M0 112 V86 L44 62 L84 28 L98 38 L112 32 L152 68 L200 80 V112 Z';
|
||||
const SNOW = 'M84 28 L73 38 L79 37 L84 42 L89 37.5 L95 37 Z';
|
||||
const MEADOW = 'M0 112 V96 C 30 88, 60 90, 92 94 S 160 82, 200 90 V112 Z';
|
||||
// From the valley up the mountain's flank to the summit, in switchbacks.
|
||||
const TRAIL = 'M24 100 C 40 96, 50 90, 54 84 S 66 78, 70 72 S 64 62, 72 56 S 82 48, 78 42 S 82 34, 84 29';
|
||||
const KINDS = [Footprints, Bike, Sailboat, Snowflake, Landmark];
|
||||
|
||||
function RoadtripVisual() {
|
||||
function ToursVisual() {
|
||||
return (
|
||||
<div className="rn-vis rn-vis-road">
|
||||
<svg viewBox="0 0 200 112" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
|
||||
<path className="rn-vis-road-casing" d={ROUTE} />
|
||||
<path className="rn-vis-road-line" d={ROUTE} />
|
||||
<div className="rn-vis rn-vis-tours">
|
||||
<svg className="rn-vis-tours-scene" viewBox="0 0 200 112" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
|
||||
<circle className="rn-vis-tours-sun" cx="160" cy="24" r="9" />
|
||||
<path className="rn-vis-tours-far" d={FAR_RIDGE} />
|
||||
<path className="rn-vis-tours-mountain" d={MOUNTAIN} />
|
||||
<path className="rn-vis-tours-snow" d={SNOW} />
|
||||
<path className="rn-vis-tours-meadow" d={MEADOW} />
|
||||
<path className="rn-vis-tours-trail" d={TRAIL} pathLength={1} />
|
||||
<circle className="rn-vis-tours-walker" r="2.6">
|
||||
<animateMotion dur="5s" begin="1.6s" repeatCount="indefinite" path={TRAIL} />
|
||||
</circle>
|
||||
<g className="rn-vis-tours-flag">
|
||||
<line x1="84" y1="29" x2="84" y2="17" />
|
||||
<path d="M84 17 L93 20 L84 23 Z" />
|
||||
</g>
|
||||
</svg>
|
||||
<span className="rn-vis-marker" style={{ left: '13%', top: '75%', background: '#6366f1' }}>1</span>
|
||||
<span className="rn-vis-marker" style={{ left: '48%', top: '41%', background: '#f59e0b' }}>2</span>
|
||||
<span className="rn-vis-marker" style={{ left: '91%', top: '25%', background: '#ec4899' }}>3</span>
|
||||
<span className="rn-vis-distance"><Car size={10} strokeWidth={2.2} />386 km</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A recorded day, with the two places the traveller stayed.
|
||||
const TRAIL = '84,94 96,88 106,80 118,76 130,74 142,66 152,58 162,62 172,66 180,56 186,46';
|
||||
const STAYS: Array<[number, number]> = [[130, 74], [186, 46]];
|
||||
|
||||
function DawarichVisual() {
|
||||
return (
|
||||
<div className="rn-vis rn-vis-trail">
|
||||
<svg viewBox="0 0 200 112" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
|
||||
<polyline className="rn-vis-trail-line" points={TRAIL} />
|
||||
{STAYS.map(([x, y]) => <circle key={x} className="rn-vis-trail-stay" cx={x} cy={y} r="3.4" />)}
|
||||
</svg>
|
||||
<span className="rn-vis-brand"><DawarichIcon size={58} /></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A trip's documents travelling both ways between TREK and the five stores. The
|
||||
// wordmark without the API pill: this is TREK itself, not its index.
|
||||
const STORES = [PaperlessIcon, PapraIcon, NextcloudIcon, OpenCloudIcon, SynologyDriveIcon];
|
||||
|
||||
function DocSyncVisual() {
|
||||
return (
|
||||
<div className="rn-vis rn-vis-docs">
|
||||
<span className="rn-vis-trekbadge"><TrekMark pill={false} className="rn-vis-trekmark" /></span>
|
||||
<span className="rn-vis-docs-flow"><ArrowLeftRight size={14} strokeWidth={2.4} /></span>
|
||||
<div className="rn-vis-docs-stores">
|
||||
{STORES.map((Store, i) => (
|
||||
<span key={i} className="rn-vis-docs-store"><Store size={22} /></span>
|
||||
<span className="rn-vis-tours-climb">
|
||||
<TrendingUp size={9} strokeWidth={2.6} />
|
||||
860 m
|
||||
</span>
|
||||
<div className="rn-vis-tours-kinds">
|
||||
{KINDS.map((Kind, i) => (
|
||||
<span key={i} className="rn-vis-tours-kind" style={{ animationDelay: `${1.2 + i * 0.08}s` }}>
|
||||
<Kind size={10} strokeWidth={2.2} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── TREK Studio: an open photo book with a polaroid, a stamp and a sticker ──
|
||||
|
||||
function StudioVisual() {
|
||||
return (
|
||||
<div className="rn-vis rn-vis-studio">
|
||||
<div className="rn-vis-book">
|
||||
<div className="rn-vis-page rn-vis-page-start">
|
||||
<div className="rn-vis-polaroid">
|
||||
<svg viewBox="0 0 60 44" aria-hidden="true">
|
||||
<rect className="rn-vis-sky" width="60" height="44" />
|
||||
<circle className="rn-vis-sun" cx="42" cy="15" r="6" />
|
||||
<path className="rn-vis-peak-far" d="M0 32 L12 20 L20 26 L32 12 L44 24 L52 18 L60 26 V44 H0 Z" />
|
||||
<path className="rn-vis-peak-near" d="M0 38 L14 28 L26 34 L38 26 L60 36 V44 H0 Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="rn-vis-tape" />
|
||||
</div>
|
||||
<div className="rn-vis-page rn-vis-page-end">
|
||||
<span className="rn-vis-book-title">Dolomiti</span>
|
||||
<span className="rn-vis-book-line" />
|
||||
<span className="rn-vis-book-line rn-vis-book-line-short" />
|
||||
<span className="rn-vis-stamp">
|
||||
<Plane size={10} strokeWidth={2.2} />
|
||||
</span>
|
||||
</div>
|
||||
<span className="rn-vis-gutter" />
|
||||
</div>
|
||||
<span className="rn-vis-sticker">
|
||||
<Star size={15} strokeWidth={1.8} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── The assistant: a day in Porto proposed stop by stop, ready to apply ──
|
||||
|
||||
const PORTO: Array<{ time: string; name: string; tone: string }> = [
|
||||
{ time: '09:30', name: 'Ribeira', tone: 'sight' },
|
||||
{ time: '12:30', name: 'Livraria Lello', tone: 'culture' },
|
||||
{ time: '16:00', name: 'Torre dos Clérigos', tone: 'view' },
|
||||
];
|
||||
|
||||
function AssistantVisual() {
|
||||
return (
|
||||
<div className="rn-vis rn-vis-assist">
|
||||
<div className="rn-vis-assist-head">
|
||||
<span className="rn-vis-assist-avatar">
|
||||
<Sparkles size={10} strokeWidth={2.4} />
|
||||
</span>
|
||||
<span className="rn-vis-assist-typing">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
</div>
|
||||
<div className="rn-vis-assist-card">
|
||||
{PORTO.map((stop, i) => (
|
||||
<div key={stop.name} className="rn-vis-assist-row" style={{ animationDelay: `${0.5 + i * 0.35}s` }}>
|
||||
<span className="rn-vis-assist-time">{stop.time}</span>
|
||||
<span className={`rn-vis-assist-dot rn-vis-assist-dot-${stop.tone}`} />
|
||||
<span className="rn-vis-assist-name">{stop.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span className="rn-vis-assist-apply">
|
||||
<Check size={11} strokeWidth={3} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Help on every screen, in 27 languages: hello in each of them, drifting by ──
|
||||
|
||||
const HELLO_A = ['Hallo', 'Bonjour', 'Hola', 'Ciao', 'Olá', 'Hej', 'Ahoj', 'Cześć', 'Szia', 'Tere', 'Salam', 'Bon dia', 'Halo'];
|
||||
const HELLO_B = ['Привіт', 'Привет', 'Γεια σου', 'مرحبا', 'こんにちは', '안녕하세요', '你好', '哈囉', 'สวัสดี', 'Xin chào', 'Merhaba', 'Dobrý deň'];
|
||||
|
||||
function HelloRow({ words, reverse }: { words: string[]; reverse?: boolean }) {
|
||||
// Written twice, so the row can slide by half its width and start over without a seam.
|
||||
return (
|
||||
<div className={reverse ? 'rn-vis-hello-row rn-vis-hello-row-reverse' : 'rn-vis-hello-row'}>
|
||||
{[...words, ...words].map((word, i) => (
|
||||
<span key={i} className="rn-vis-hello" lang="und">
|
||||
{word}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpVisual() {
|
||||
return (
|
||||
<div className="rn-vis rn-vis-help">
|
||||
<span className="rn-vis-help-q">?</span>
|
||||
<div className="rn-vis-help-langs">
|
||||
<HelloRow words={HELLO_A} />
|
||||
<HelloRow words={HELLO_B} reverse />
|
||||
</div>
|
||||
<span className="rn-vis-help-count">27</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const VISUALS: Record<string, () => React.ReactElement> = {
|
||||
'places-api': PlacesApiVisual,
|
||||
roadtrip: RoadtripVisual,
|
||||
dawarich: DawarichVisual,
|
||||
docsync: DocSyncVisual,
|
||||
tours: ToursVisual,
|
||||
studio: StudioVisual,
|
||||
assistant: AssistantVisual,
|
||||
help: HelpVisual,
|
||||
};
|
||||
|
||||
/** The card's picture, or its icon when the release names no drawing this client knows. */
|
||||
|
||||
@@ -380,223 +380,6 @@
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* ── The card pictures ─────────────────────────────────────────────────── */
|
||||
|
||||
.rn-vis {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
color: #f5f5f7;
|
||||
font-family: var(--font-system);
|
||||
}
|
||||
|
||||
.rn-vis-road > svg,
|
||||
.rn-vis-trail > svg { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
|
||||
.rn-vis-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: oklch(1 0 0 / 0.55);
|
||||
}
|
||||
|
||||
/* Places API: the index answering a search while it is typed. */
|
||||
.rn-vis-places {
|
||||
padding: 9px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* The same wordmark the API keys card leads with, centred; its pill text inverts against the tile. */
|
||||
.rn-vis-trekbadge { align-self: center; display: flex; align-items: center; height: 24px; }
|
||||
|
||||
.rn-vis-trekmark { display: block; height: 20px; width: auto; color: #f5f5f7; }
|
||||
|
||||
.rn-vis-places .trek-mark .pill-fg { fill: #101013; }
|
||||
|
||||
.rn-vis-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 22px;
|
||||
padding: 0 9px;
|
||||
border-radius: 8px;
|
||||
background: oklch(1 0 0 / 0.08);
|
||||
border: 1px solid oklch(1 0 0 / 0.16);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rn-vis-search svg { width: 11px; height: 11px; flex: none; color: oklch(1 0 0 / 0.55); }
|
||||
|
||||
/* The query types itself out once, then the answer arrives under it. */
|
||||
.rn-vis-typed {
|
||||
display: inline-block;
|
||||
max-width: 90px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
animation: rn-type 1.3s steps(13, end) 0.7s both;
|
||||
}
|
||||
|
||||
@keyframes rn-type { from { max-width: 0; } to { max-width: 90px; } }
|
||||
|
||||
.rn-vis-caret {
|
||||
width: 1px;
|
||||
height: 12px;
|
||||
margin-inline-start: -4px;
|
||||
background: #f5f5f7;
|
||||
animation: rn-caret 1s steps(1) infinite;
|
||||
}
|
||||
|
||||
@keyframes rn-caret { 50% { opacity: 0; } }
|
||||
|
||||
.rn-vis-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 8px;
|
||||
background: oklch(1 0 0 / 0.07);
|
||||
animation: rn-answer 0.3s ease 2.1s both;
|
||||
}
|
||||
|
||||
@keyframes rn-answer { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
.rn-vis-cat {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid #ffffff;
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.rn-vis-place { display: flex; flex-direction: column; min-width: 0; line-height: 1.2; }
|
||||
|
||||
.rn-vis-place span {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rn-vis-place small { font-size: 8.5px; color: oklch(1 0 0 / 0.5); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rn-vis-typed, .rn-vis-caret, .rn-vis-result { animation: none; }
|
||||
}
|
||||
|
||||
[data-reduce-motion='true'] .rn-vis-typed,
|
||||
[data-reduce-motion='true'] .rn-vis-caret,
|
||||
[data-reduce-motion='true'] .rn-vis-result { animation: none; }
|
||||
|
||||
/* Road trip: the drive on the map, in the planner's route blue and marker style. */
|
||||
.rn-vis-road-casing { fill: none; stroke: #0a5cc2; stroke-width: 5; stroke-linecap: round; stroke-linejoin: round; }
|
||||
|
||||
.rn-vis-road-line { fill: none; stroke: #0a84ff; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
|
||||
|
||||
.rn-vis-marker {
|
||||
position: absolute;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: -9px 0 0 -9px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #ffffff;
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 1px 4px oklch(0 0 0 / 0.35);
|
||||
color: #ffffff;
|
||||
font-size: 8.5px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rn-vis-distance {
|
||||
position: absolute;
|
||||
right: 9px;
|
||||
bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 99px;
|
||||
background: #f5f5f7;
|
||||
color: #101013;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Dawarich: a recorded day, in the trail colour the map draws it with. */
|
||||
.rn-vis-trail-line { fill: none; stroke: #2563eb; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
|
||||
|
||||
.rn-vis-trail-stay { fill: #ffffff; stroke: #2563eb; stroke-width: 2; }
|
||||
|
||||
/* Document sync: the wordmark, the two-way arrow under it, and the five stores in a row. */
|
||||
.rn-vis-docs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.rn-vis-docs .rn-vis-trekbadge { height: 22px; }
|
||||
.rn-vis-docs .rn-vis-trekmark { height: 19px; }
|
||||
|
||||
.rn-vis-docs-flow { display: flex; color: oklch(1 0 0 / 0.55); transform: rotate(90deg); }
|
||||
|
||||
.rn-vis-docs-stores {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rn-vis-docs-store {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
background: oklch(1 0 0 / 0.1);
|
||||
box-shadow: inset 0 1px 0 oklch(1 0 0 / 0.18), inset 0 0 0 1px oklch(1 0 0 / 0.1);
|
||||
}
|
||||
|
||||
/* The mark itself, unframed, with its corners rounded like an app icon. */
|
||||
.rn-vis-brand {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border-radius: 15px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 14px oklch(0 0 0 / 0.45), 0 0 0 1px oklch(1 0 0 / 0.14);
|
||||
}
|
||||
|
||||
/* A glass sheen over the mark: a soft light from the top and a bright inner edge. */
|
||||
.rn-vis-brand::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(160deg, oklch(1 0 0 / 0.42) 0%, oklch(1 0 0 / 0.08) 38%, transparent 55%, oklch(0 0 0 / 0.12) 100%);
|
||||
box-shadow: inset 0 1px 0 oklch(1 0 0 / 0.55), inset 0 0 0 1px oklch(1 0 0 / 0.22);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rn-vis-brand img { display: block; width: 100%; height: 100%; }
|
||||
|
||||
/* ── Right: the note ───────────────────────────────────────────────────── */
|
||||
|
||||
.rn-note {
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
/*
|
||||
* The pictures on the release cards (ReleaseNoticeVisuals.tsx). They sit on the
|
||||
* release half, which is dark in both themes, so they draw in their own colours
|
||||
* rather than the surface tokens, like the panel around them.
|
||||
*/
|
||||
|
||||
/* ── The card pictures ─────────────────────────────────────────────────── */
|
||||
|
||||
.rn-vis {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
color: #f5f5f7;
|
||||
font-family: var(--font-system);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rn-vis-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: oklch(1 0 0 / 0.55);
|
||||
}
|
||||
|
||||
@keyframes rn-draw {
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
@keyframes rn-pop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.2);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@keyframes rn-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@keyframes rn-typing {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
opacity: 0.35;
|
||||
transform: none;
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
@keyframes rn-ring {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 oklch(0.72 0.19 150 / 0.55);
|
||||
}
|
||||
70%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 9px oklch(0.72 0.19 150 / 0);
|
||||
}
|
||||
}
|
||||
@keyframes rn-marquee {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
@keyframes rn-sticker {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.2) rotate(-50deg);
|
||||
}
|
||||
70% {
|
||||
opacity: 1;
|
||||
transform: scale(1.12) rotate(12deg);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: rotate(8deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Tours: a trail climbing to the summit at dusk, and the kinds of tour along the foot. */
|
||||
.rn-vis-tours {
|
||||
background: linear-gradient(180deg, #24304f 0%, #3c4a6b 46%, #c9876a 100%);
|
||||
}
|
||||
|
||||
.rn-vis-tours-scene {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.rn-vis-tours-sun {
|
||||
fill: #fde68a;
|
||||
filter: drop-shadow(0 0 6px oklch(0.92 0.12 90 / 0.7));
|
||||
}
|
||||
|
||||
.rn-vis-tours-far {
|
||||
fill: #56668a;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.rn-vis-tours-mountain {
|
||||
fill: #23344a;
|
||||
}
|
||||
|
||||
.rn-vis-tours-snow {
|
||||
fill: #e8f0f8;
|
||||
}
|
||||
|
||||
.rn-vis-tours-meadow {
|
||||
fill: #14202d;
|
||||
}
|
||||
|
||||
.rn-vis-tours-trail {
|
||||
fill: none;
|
||||
stroke: #fbbf24;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-dasharray: 1;
|
||||
stroke-dashoffset: 1;
|
||||
filter: drop-shadow(0 0 2px oklch(0.85 0.16 80 / 0.6));
|
||||
animation: rn-draw 1.3s ease-in-out 0.3s both;
|
||||
}
|
||||
|
||||
.rn-vis-tours-walker {
|
||||
fill: #ffffff;
|
||||
filter: drop-shadow(0 0 3px #ffffff);
|
||||
opacity: 0;
|
||||
animation: rn-show 0.2s linear 1.6s both;
|
||||
}
|
||||
|
||||
@keyframes rn-show {
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.rn-vis-tours-flag {
|
||||
transform-box: fill-box;
|
||||
transform-origin: 0% 100%;
|
||||
animation: rn-pop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) 1.5s both;
|
||||
}
|
||||
|
||||
.rn-vis-tours-flag line {
|
||||
stroke: #f5f5f7;
|
||||
stroke-width: 1.2;
|
||||
}
|
||||
|
||||
.rn-vis-tours-flag path {
|
||||
fill: #ef4444;
|
||||
}
|
||||
|
||||
.rn-vis-tours-climb {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
inset-inline-start: 9px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 99px;
|
||||
background: oklch(1 0 0 / 0.14);
|
||||
border: 1px solid oklch(1 0 0 / 0.18);
|
||||
backdrop-filter: blur(6px);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rn-vis-tours-kinds {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
bottom: 7px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.rn-vis-tours-kind {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: oklch(1 0 0 / 0.12);
|
||||
border: 1px solid oklch(1 0 0 / 0.2);
|
||||
backdrop-filter: blur(6px);
|
||||
animation: rn-rise 0.3s ease both;
|
||||
}
|
||||
|
||||
/* TREK Studio: an open book, a polaroid taped in, a title, a stamp and a sticker. */
|
||||
.rn-vis-studio {
|
||||
background:
|
||||
radial-gradient(120% 90% at 50% 115%, oklch(0.62 0.14 45 / 0.38) 0%, transparent 60%),
|
||||
linear-gradient(165deg, #23201c 0%, #171513 100%);
|
||||
}
|
||||
|
||||
.rn-vis-book {
|
||||
position: absolute;
|
||||
inset: 13px 20px 13px;
|
||||
display: flex;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
transform: perspective(520px) rotateX(16deg);
|
||||
box-shadow:
|
||||
0 16px 22px -10px oklch(0 0 0 / 0.75),
|
||||
0 0 0 1px oklch(1 0 0 / 0.08);
|
||||
}
|
||||
|
||||
.rn-vis-page {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.rn-vis-page-start {
|
||||
background: #ede2c9;
|
||||
}
|
||||
|
||||
.rn-vis-page-end {
|
||||
background: #f6eedc;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 11px 9px 0;
|
||||
}
|
||||
|
||||
.rn-vis-gutter {
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline-start: 50%;
|
||||
width: 16px;
|
||||
margin-inline-start: -8px;
|
||||
background: linear-gradient(90deg, transparent, oklch(0 0 0 / 0.2), transparent);
|
||||
}
|
||||
|
||||
.rn-vis-polaroid {
|
||||
position: absolute;
|
||||
top: 11px;
|
||||
inset-inline-start: 10px;
|
||||
width: 58%;
|
||||
padding: 3px 3px 9px;
|
||||
background: #ffffff;
|
||||
transform: rotate(-6deg);
|
||||
box-shadow: 0 3px 6px oklch(0 0 0 / 0.25);
|
||||
}
|
||||
|
||||
.rn-vis-polaroid svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.rn-vis-sky {
|
||||
fill: #fcd5b0;
|
||||
}
|
||||
.rn-vis-sun {
|
||||
fill: #fb923c;
|
||||
}
|
||||
.rn-vis-peak-far {
|
||||
fill: #7c8aa0;
|
||||
}
|
||||
.rn-vis-peak-near {
|
||||
fill: #334155;
|
||||
}
|
||||
|
||||
.rn-vis-tape {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
inset-inline-start: 30%;
|
||||
width: 24px;
|
||||
height: 7px;
|
||||
transform: rotate(-24deg);
|
||||
background: repeating-linear-gradient(45deg, #f43f5e 0 3px, #fecdd3 3px 6px);
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.rn-vis-book-title {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
color: #1f3d2b;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rn-vis-book-line {
|
||||
height: 3px;
|
||||
width: 88%;
|
||||
border-radius: 2px;
|
||||
background: oklch(0.4 0.03 60 / 0.3);
|
||||
}
|
||||
.rn-vis-book-line-short {
|
||||
width: 58%;
|
||||
}
|
||||
|
||||
.rn-vis-stamp {
|
||||
position: absolute;
|
||||
bottom: 9px;
|
||||
inset-inline-end: 9px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px dashed #b45309;
|
||||
color: #b45309;
|
||||
transform: rotate(-14deg);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.rn-vis-sticker {
|
||||
position: absolute;
|
||||
bottom: 7px;
|
||||
inset-inline-start: 14px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: #facc15;
|
||||
color: #fff8db;
|
||||
box-shadow:
|
||||
0 0 0 2px #ffffff,
|
||||
0 4px 10px oklch(0 0 0 / 0.4);
|
||||
animation: rn-sticker 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) 1s both;
|
||||
}
|
||||
|
||||
.rn-vis-sticker svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
/* The assistant: a day in Porto proposed stop by stop, and the button that applies it. */
|
||||
.rn-vis-assist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 9px 10px;
|
||||
background:
|
||||
radial-gradient(110% 80% at 0% 0%, oklch(0.55 0.2 290 / 0.36) 0%, transparent 62%),
|
||||
linear-gradient(165deg, #1d1b29 0%, #141320 100%);
|
||||
}
|
||||
|
||||
.rn-vis-assist-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.rn-vis-assist-avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #a78bfa 0%, #3b82f6 100%);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 2px 8px oklch(0.55 0.2 290 / 0.6);
|
||||
}
|
||||
|
||||
.rn-vis-assist-typing {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
padding: 5px 7px;
|
||||
border-radius: 99px;
|
||||
background: oklch(1 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.rn-vis-assist-typing i {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: #f5f5f7;
|
||||
animation: rn-typing 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.rn-vis-assist-typing i:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
.rn-vis-assist-typing i:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
.rn-vis-assist-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 9px;
|
||||
background: oklch(1 0 0 / 0.08);
|
||||
border: 1px solid oklch(1 0 0 / 0.14);
|
||||
box-shadow: inset 0 1px 0 oklch(1 0 0 / 0.12);
|
||||
}
|
||||
|
||||
.rn-vis-assist-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
font-size: 9.5px;
|
||||
line-height: 1.35;
|
||||
animation: rn-rise 0.34s ease both;
|
||||
}
|
||||
|
||||
.rn-vis-assist-time {
|
||||
flex: none;
|
||||
width: 27px;
|
||||
color: oklch(1 0 0 / 0.5);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rn-vis-assist-dot {
|
||||
flex: none;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.rn-vis-assist-dot-sight {
|
||||
background: #f59e0b;
|
||||
}
|
||||
.rn-vis-assist-dot-culture {
|
||||
background: #a78bfa;
|
||||
}
|
||||
.rn-vis-assist-dot-view {
|
||||
background: #34d399;
|
||||
}
|
||||
.rn-vis-assist-name {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rn-vis-assist-apply {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
inset-inline-end: 9px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
color: #ffffff;
|
||||
animation:
|
||||
rn-pop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) 1.7s both,
|
||||
rn-ring 2.2s ease-out 2s infinite;
|
||||
}
|
||||
|
||||
/* Help on every screen: the help button, and hello in TREK's languages drifting past it. */
|
||||
.rn-vis-help {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 12px;
|
||||
background:
|
||||
radial-gradient(70% 120% at 10% 50%, oklch(0.6 0.16 250 / 0.34) 0%, transparent 70%),
|
||||
linear-gradient(165deg, #1a1f2b 0%, #12151d 100%);
|
||||
}
|
||||
|
||||
.rn-vis-help-q {
|
||||
position: relative;
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
background: #f5f5f7;
|
||||
color: #101013;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
box-shadow:
|
||||
0 0 0 5px oklch(1 0 0 / 0.08),
|
||||
0 6px 16px oklch(0 0 0 / 0.5);
|
||||
}
|
||||
|
||||
.rn-vis-help-count {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
inset-inline-start: 42px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 99px;
|
||||
background: #3b82f6;
|
||||
color: #ffffff;
|
||||
font-size: 9.5px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
box-shadow: 0 0 0 2px #12151d;
|
||||
}
|
||||
|
||||
/* The rows slide the same way in every language: they are a picture, not text. */
|
||||
.rn-vis-help-langs {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
direction: ltr;
|
||||
-webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 14%, #000 86%, transparent 100%);
|
||||
mask-image: linear-gradient(90deg, transparent 0%, #000 14%, #000 86%, transparent 100%);
|
||||
}
|
||||
|
||||
.rn-vis-hello-row {
|
||||
display: flex;
|
||||
width: max-content;
|
||||
animation: rn-marquee 34s linear infinite;
|
||||
}
|
||||
.rn-vis-hello-row-reverse {
|
||||
animation-direction: reverse;
|
||||
animation-duration: 40s;
|
||||
}
|
||||
|
||||
.rn-vis-hello {
|
||||
flex: none;
|
||||
margin-inline-end: 5px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 99px;
|
||||
background: oklch(1 0 0 / 0.09);
|
||||
border: 1px solid oklch(1 0 0 / 0.13);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rn-vis-tours-trail,
|
||||
.rn-vis-tours-flag,
|
||||
.rn-vis-tours-kind,
|
||||
.rn-vis-sticker,
|
||||
.rn-vis-assist-typing i,
|
||||
.rn-vis-assist-row,
|
||||
.rn-vis-assist-apply,
|
||||
.rn-vis-hello-row {
|
||||
animation: none;
|
||||
}
|
||||
.rn-vis-tours-trail {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
.rn-vis-tours-walker {
|
||||
display: none;
|
||||
}
|
||||
.rn-vis-sticker {
|
||||
transform: rotate(8deg);
|
||||
}
|
||||
}
|
||||
|
||||
[data-reduce-motion='true'] .rn-vis-tours-trail,
|
||||
[data-reduce-motion='true'] .rn-vis-tours-flag,
|
||||
[data-reduce-motion='true'] .rn-vis-tours-kind,
|
||||
[data-reduce-motion='true'] .rn-vis-sticker,
|
||||
[data-reduce-motion='true'] .rn-vis-assist-typing i,
|
||||
[data-reduce-motion='true'] .rn-vis-assist-row,
|
||||
[data-reduce-motion='true'] .rn-vis-assist-apply,
|
||||
[data-reduce-motion='true'] .rn-vis-hello-row {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
[data-reduce-motion='true'] .rn-vis-tours-trail {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
|
||||
[data-reduce-motion='true'] .rn-vis-tours-walker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-reduce-motion='true'] .rn-vis-sticker {
|
||||
transform: rotate(8deg);
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { DOCUMENT_PROVIDER_IDS } from '@trek/shared'
|
||||
import { render } from '../../../tests/helpers/render'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from './DocumentProviderIcons'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from './documentProviderIconMap'
|
||||
import TrekIcon from './TrekIcon'
|
||||
|
||||
const marks = Object.entries(DOCUMENT_PROVIDER_ICONS)
|
||||
|
||||
@@ -15,7 +15,7 @@ import React from 'react'
|
||||
* for legibility in a monochrome row.
|
||||
*/
|
||||
|
||||
interface GlyphProps {
|
||||
export interface GlyphProps {
|
||||
size?: number
|
||||
className?: string
|
||||
}
|
||||
@@ -126,12 +126,3 @@ export function SynologyDriveIcon({ size = 20, className }: GlyphProps): React.R
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Keyed by the provider ids in `document_providers`. */
|
||||
export const DOCUMENT_PROVIDER_ICONS: Record<string, React.ComponentType<GlyphProps>> = {
|
||||
paperless: PaperlessIcon,
|
||||
papra: PapraIcon,
|
||||
nextcloud: NextcloudIcon,
|
||||
opencloud: OpenCloudIcon,
|
||||
synologydrive: SynologyDriveIcon,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type React from 'react';
|
||||
import {
|
||||
type GlyphProps,
|
||||
NextcloudIcon,
|
||||
OpenCloudIcon,
|
||||
PaperlessIcon,
|
||||
PapraIcon,
|
||||
SynologyDriveIcon,
|
||||
} from './DocumentProviderIcons';
|
||||
|
||||
/**
|
||||
* The document providers' marks keyed by the provider ids in `document_providers`.
|
||||
* Kept apart from the drawings so that module exports components only.
|
||||
*/
|
||||
export const DOCUMENT_PROVIDER_ICONS: Record<string, React.ComponentType<GlyphProps>> = {
|
||||
paperless: PaperlessIcon,
|
||||
papra: PapraIcon,
|
||||
nextcloud: NextcloudIcon,
|
||||
opencloud: OpenCloudIcon,
|
||||
synologydrive: SynologyDriveIcon,
|
||||
};
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import DawarichIcon from '../../../components/shared/DawarichIcon'
|
||||
import AirTrailIcon from '../../../components/shared/AirTrailIcon'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../../components/shared/DocumentProviderIcons'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../../components/shared/documentProviderIconMap'
|
||||
import MToggle from '../../components/MToggle'
|
||||
import {
|
||||
type Addon,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useMatch, useNavigate } from 'react-router';
|
||||
|
||||
import type { AssistantChatTrip } from '../../../components/Assistant/assistantViewContext';
|
||||
import type { EntityTarget } from '../../../components/Assistant/cards/cardTypes';
|
||||
import { chatPagePath, useAssistantHome, useRouteJournalChat } from '../../../components/Assistant/useAssistantHome';
|
||||
import { useAssistantStatus } from '../../../components/Assistant/useAssistantStatus';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
import { AssistantScreen, type MapHops } from '../trip/assistant/MAssistantScreen';
|
||||
|
||||
@@ -26,7 +27,6 @@ export default function MAssistantHomeScreen() {
|
||||
const available = useAssistantStore((s) => s.status?.available === true);
|
||||
const open = useAssistantStore((s) => s.window.open);
|
||||
const minimized = useAssistantStore((s) => s.window.minimized);
|
||||
const loadStatus = useAssistantStore((s) => s.loadStatus);
|
||||
const close = useAssistantStore((s) => s.close);
|
||||
|
||||
// The trip's screen (or the journal's) takes over with the window still open, so its chat comes up there.
|
||||
@@ -46,10 +46,8 @@ export default function MAssistantHomeScreen() {
|
||||
[close, navigate]
|
||||
);
|
||||
|
||||
// The desktop window loads this itself; on the phone the pages outside a trip ask once.
|
||||
useEffect(() => {
|
||||
void loadStatus();
|
||||
}, [loadStatus]);
|
||||
// The desktop window loads this itself; on the phone the pages outside a trip ask here.
|
||||
useAssistantStatus();
|
||||
|
||||
if (inTrip || !available || !open || minimized) return null;
|
||||
return (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildUser } from '../../../../tests/helpers/factories';
|
||||
import { render, screen, waitFor } from '../../../../tests/helpers/render';
|
||||
import { seedStore } from '../../../../tests/helpers/store';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
|
||||
@@ -47,6 +48,8 @@ function status(over: Partial<AiStatusResponse> = {}): AiStatusResponse {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAssistantStore.setState({ status: null });
|
||||
// The card shows while the assistant is on, and only then is its status asked for.
|
||||
seedStore(useAddonStore, { loaded: true, addons: [{ id: 'ai_assistant', enabled: true }] });
|
||||
repo.models.mockResolvedValue([
|
||||
{ id: 'gemini-flash', label: 'Gemini Flash', supportsTools: true, free: true },
|
||||
{ id: 'gemini-pro', label: 'Gemini Pro', supportsTools: true },
|
||||
|
||||
@@ -23,6 +23,7 @@ import RoutePeekLazy from '../../../../components/Assistant/map/RoutePeekLazy';
|
||||
import { moveRoutePeek } from '../../../../components/Assistant/map/routePeekControl';
|
||||
import { useAssistantHintsSetting } from '../../../../components/Assistant/useAssistantHints';
|
||||
import { useAssistantLiveStep } from '../../../../components/Assistant/useAssistantLiveStep';
|
||||
import { useAssistantStatus } from '../../../../components/Assistant/useAssistantStatus';
|
||||
import { useAssistantThread } from '../../../../components/Assistant/useAssistantThread';
|
||||
import { useTripMemory } from '../../../../components/Assistant/useTripMemory';
|
||||
import { useKeyboardInset } from '../../../../hooks/useKeyboardInset';
|
||||
@@ -395,13 +396,10 @@ export default function MAssistantScreen({ planner, shell }: MTripSheetsProps) {
|
||||
const minimized = useAssistantStore((s) => s.window.minimized);
|
||||
const peeking = useAssistantStore((s) => s.routePeek?.tripId === planner.tripId);
|
||||
const running = useAssistantStore((s) => s.byTrip[planner.tripId]?.isRunning === true);
|
||||
const loadStatus = useAssistantStore((s) => s.loadStatus);
|
||||
const hops = useMapHops({ planner, shell });
|
||||
|
||||
// The desktop window loads this itself; on the phone the trip screen asks once.
|
||||
useEffect(() => {
|
||||
void loadStatus();
|
||||
}, [loadStatus]);
|
||||
// The desktop window loads this itself; on the phone the trip screen asks here.
|
||||
useAssistantStatus();
|
||||
|
||||
if (!available || !open || !planner.trip) return null;
|
||||
return (
|
||||
|
||||
@@ -9,7 +9,7 @@ import MToggle from '../../../components/MToggle'
|
||||
import MConfirmSheet from '../../settings/MConfirmSheet'
|
||||
import { TileHeader } from '../sheets/MTripSheetUi'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../../../components/shared/DocumentProviderIcons'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../../../components/shared/documentProviderIconMap'
|
||||
import TrekIcon from '../../../../components/shared/TrekIcon'
|
||||
import {
|
||||
bindingNotice, needsReauth, storeName, useDocSync, type DocSyncLink, type DocSyncProvider, type DocSyncVerdict,
|
||||
|
||||
@@ -281,7 +281,8 @@ export function TourPlannerMapStage({
|
||||
) : (
|
||||
<MapView
|
||||
tripId={tripId}
|
||||
places={[]}
|
||||
places={tourMap.overviewPlaces}
|
||||
onMarkerClick={tourMap.openOverviewTour}
|
||||
route={lines.route}
|
||||
routeWalking={lines.routeWalking}
|
||||
routeColors={lines.routeColors}
|
||||
|
||||
@@ -31,6 +31,25 @@ describe('usePlannerFocusPoints', () => {
|
||||
expect(result.current.focusPoints).toEqual([[48, 11]]);
|
||||
});
|
||||
|
||||
it('FE-TP-FOCUSPOINTS-005: with nothing open the trip tours on show are framed, an open tour frames its own points', () => {
|
||||
const tours: [number, number][] = [
|
||||
[46.5, 12.0],
|
||||
[46.7, 12.2],
|
||||
];
|
||||
const neutral = renderHook(() => usePlannerFocusPoints('tour-planner', PLACES, planner(), closed, tours));
|
||||
expect(neutral.result.current.focusPoints).toEqual(tours);
|
||||
const editing = renderHook(() =>
|
||||
usePlannerFocusPoints(
|
||||
'tour-planner',
|
||||
PLACES,
|
||||
planner({ mode: { type: 'new-draft' }, waypoints: [{ lat: 1, lng: 2 }] }),
|
||||
closed,
|
||||
tours
|
||||
)
|
||||
);
|
||||
expect(editing.result.current.focusPoints).toEqual([[1, 2]]);
|
||||
});
|
||||
|
||||
it('FE-TP-FOCUSPOINTS-002: the area the plan map last showed wins once the planner opens again', () => {
|
||||
const { result, rerender } = renderHook(({ tab }) => usePlannerFocusPoints(tab, PLACES, planner(), closed), {
|
||||
initialProps: { tab: 'plan' },
|
||||
|
||||
@@ -7,14 +7,16 @@ import type { Place } from '../../types';
|
||||
/**
|
||||
* What the TOUR-PLANNER map frames: a browsed wanderer trail, the recorded
|
||||
* track or the control points of the open tour, and with nothing open the
|
||||
* area the plan map last showed (or the trip's places). The plan map reports
|
||||
* its viewport into the returned ref.
|
||||
* trip's tours on show (`overviewPoints`), else the area the plan map last
|
||||
* showed (or the trip's places). The plan map reports its viewport into the
|
||||
* returned ref.
|
||||
*/
|
||||
export function usePlannerFocusPoints(
|
||||
activeTab: string,
|
||||
places: readonly Place[],
|
||||
tourPlanner: TourPlannerController,
|
||||
wandererBrowser: Pick<WandererBrowser, 'open' | 'line'>
|
||||
wandererBrowser: Pick<WandererBrowser, 'open' | 'line'>,
|
||||
overviewPoints: readonly [number, number][] = []
|
||||
) {
|
||||
const lastPlanMapViewportRef = useRef<Bbox | null>(null);
|
||||
// Nothing reported yet when the planner first opens; the effect takes the area the plan map last showed.
|
||||
@@ -49,6 +51,7 @@ export function usePlannerFocusPoints(
|
||||
if (tourPlanner.mode.type === 'edit-saved' && tourPlanner.waypoints.length) {
|
||||
return tourPlanner.waypoints.map((point) => [point.lat, point.lng]);
|
||||
}
|
||||
if (tourPlanner.mode.type === 'neutral' && overviewPoints.length) return [...overviewPoints];
|
||||
return plannerFallbackFocusPoints;
|
||||
}, [
|
||||
wandererBrowser.open,
|
||||
@@ -56,6 +59,7 @@ export function usePlannerFocusPoints(
|
||||
tourPlanner.mode.type,
|
||||
tourPlanner.readOnlyGpxAnalysis?.routeCoordinates,
|
||||
tourPlanner.waypoints,
|
||||
overviewPoints,
|
||||
plannerFallbackFocusPoints,
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// FE-TP-TOUROVERVIEW-001 to 003: the trip's tours on the Tours map while none is open.
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import type { Place } from '../../types';
|
||||
import { useTourOverview } from './useTourOverview';
|
||||
|
||||
const OPEN = {
|
||||
activeTab: 'tour-planner',
|
||||
tourPlanner: { mode: { type: 'neutral' } },
|
||||
wandererBrowser: { open: false },
|
||||
} as const;
|
||||
const EDITING = { ...OPEN, tourPlanner: { mode: { type: 'new-draft' } } } as const;
|
||||
|
||||
const PLACES = [
|
||||
{ id: 1, lat: 46.6, lng: 12.1, route_geometry: '[[46.5,12.0],[46.7,12.2],[46.6,12.3]]' },
|
||||
{ id: 2, lat: 45.4, lng: 12.3, route_geometry: '[[45.43,12.32],[45.44,12.34]]' },
|
||||
{ id: 3, lat: 45.4, lng: 12.3, route_geometry: null },
|
||||
{ id: 4, lat: 48, lng: 11, route_geometry: '[[48,11],[48.1,11.1]]' },
|
||||
] as unknown as Place[];
|
||||
const TOURS: ReadonlySet<number> = new Set([1, 2, 3]);
|
||||
|
||||
describe('useTourOverview', () => {
|
||||
beforeEach(() => resetAllStores());
|
||||
|
||||
it('FE-TP-TOUROVERVIEW-001: shows the tours with a line, not the other places', () => {
|
||||
const { result } = renderHook(() => useTourOverview(5, PLACES, TOURS, OPEN));
|
||||
expect(result.current.tourPlaces.map((place) => place.id)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('FE-TP-TOUROVERVIEW-002: frames each line by its corners', () => {
|
||||
const { result } = renderHook(() => useTourOverview(5, PLACES, TOURS, OPEN));
|
||||
expect(result.current.framePoints).toEqual([
|
||||
[46.5, 12.0],
|
||||
[46.7, 12.3],
|
||||
[45.43, 12.32],
|
||||
[45.44, 12.34],
|
||||
]);
|
||||
expect(result.current.focusSuffix).toBe(':tours:1,2');
|
||||
});
|
||||
|
||||
it('FE-TP-TOUROVERVIEW-003: shows nothing while a tour is open', () => {
|
||||
const { result } = renderHook(() => useTourOverview(5, PLACES, TOURS, EDITING));
|
||||
expect(result.current).toEqual({ tourPlaces: [], framePoints: [], focusSuffix: '' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { TourPlannerController } from '../../components/Tours/planner/useTourPlanner';
|
||||
import { useTourMapVisibility } from '../../components/Tours/useTourMapVisibility';
|
||||
import type { Place } from '../../types';
|
||||
import { parseRenderableRouteGeometry } from '../../utils/routeGeometry';
|
||||
|
||||
/**
|
||||
* The trip's tours on the Tours map while none is open, as the plan map shows them:
|
||||
* each tour's place, whose line and marker the map draws in the tour's colour, and the
|
||||
* corners of those lines for the camera to frame. A tour hidden from the map in the
|
||||
* list stays off it here too. Empty off the Tours tab and while a tour is open, an
|
||||
* import viewed or the wanderer browser up, so the map then shows that alone.
|
||||
*/
|
||||
export function useTourOverview(
|
||||
tripId: number,
|
||||
places: readonly Place[],
|
||||
tourPlaceIds: ReadonlySet<number>,
|
||||
view: {
|
||||
activeTab: string;
|
||||
tourPlanner: Pick<TourPlannerController, 'mode'>;
|
||||
wandererBrowser: { open: boolean };
|
||||
}
|
||||
) {
|
||||
const showing =
|
||||
view.activeTab === 'tour-planner' && view.tourPlanner.mode.type === 'neutral' && !view.wandererBrowser.open;
|
||||
const { hiddenIds } = useTourMapVisibility(tripId, tourPlaceIds);
|
||||
const tourPlaces = useMemo(
|
||||
() =>
|
||||
showing
|
||||
? places.filter((place) => tourPlaceIds.has(place.id) && !hiddenIds.has(place.id) && !!place.route_geometry)
|
||||
: [],
|
||||
[showing, places, tourPlaceIds, hiddenIds]
|
||||
);
|
||||
const framePoints = useMemo(() => tourPlaces.flatMap((place) => cornersOf(place.route_geometry)), [tourPlaces]);
|
||||
return {
|
||||
tourPlaces,
|
||||
framePoints,
|
||||
// Part of the camera's key, so the map frames the tours again once they are loaded or one is shown or hidden.
|
||||
focusSuffix: tourPlaces.length ? `:tours:${tourPlaces.map((place) => place.id).join(',')}` : '',
|
||||
};
|
||||
}
|
||||
|
||||
/** The south-west and north-east corners of a stored line, or nothing for a line that does not parse. */
|
||||
function cornersOf(routeGeometry: string | null | undefined): [number, number][] {
|
||||
const points = parseRenderableRouteGeometry(routeGeometry);
|
||||
if (!points?.length) return [];
|
||||
const lats = points.map(([lat]) => lat);
|
||||
const lngs = points.map(([, lng]) => lng);
|
||||
return [
|
||||
[Math.min(...lats), Math.min(...lngs)],
|
||||
[Math.max(...lats), Math.max(...lngs)],
|
||||
];
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { useMapLocationSearch } from './useMapLocationSearch'
|
||||
import { usePlannerFocusPoints } from './usePlannerFocusPoints'
|
||||
import { useTripPlanner } from './useTripPlanner'
|
||||
import { useTourWorkspace } from './useTourWorkspace'
|
||||
import { useTourOverview } from './useTourOverview'
|
||||
import { mapFocusKeyOf, offeredWaysOf, paddleLineOf } from './tourMapFocus'
|
||||
import { placeFormContext } from './placeFormContext'
|
||||
|
||||
@@ -41,7 +42,8 @@ export function useTripPlannerPage() {
|
||||
useAssistantEntityLinks({ planner, workspace })
|
||||
|
||||
const tourDetailOpenerRef = useRef<{ placeId: number; element: HTMLElement } | null>(null)
|
||||
const { focusPoints, lastPlanMapViewportRef } = usePlannerFocusPoints(activeTab, places, tourPlanner, wandererBrowser)
|
||||
const tourOverview = useTourOverview(tripId, places, planner.tourPlaceIds, { activeTab, tourPlanner, wandererBrowser })
|
||||
const { focusPoints, lastPlanMapViewportRef } = usePlannerFocusPoints(activeTab, places, tourPlanner, wandererBrowser, tourOverview.framePoints)
|
||||
|
||||
// PAD-06: an obstacle or area the paddle checklist asks for moves the camera as a picked search result does.
|
||||
const paddleFocus = usePaddleObstacleStore(state => state.focus)
|
||||
@@ -152,7 +154,9 @@ export function useTripPlannerPage() {
|
||||
variantLines: drawn.overlays,
|
||||
previewPoints: tourConversion.status === 'idle' ? null : tourConversion.points,
|
||||
focusPoints: offered.frame?.points ?? mapFocusPoints,
|
||||
focusKey: mapFocusKeyOf(wandererBrowser, offered.frame, mapFocusKey),
|
||||
focusKey: mapFocusKeyOf(wandererBrowser, offered.frame, mapFocusKey) + tourOverview.focusSuffix,
|
||||
overviewPlaces: tourOverview.tourPlaces,
|
||||
openOverviewTour: workspace.openTourInPlanner,
|
||||
captureViewport,
|
||||
},
|
||||
locationSearch,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* FE-AI-STORE-001 to FE-AI-STORE-012
|
||||
* FE-AI-STORE-001 to FE-AI-STORE-014
|
||||
*
|
||||
* The store runs a turn end to end against a faked repo: the request it
|
||||
* sends, how the stream lands in the chat, and what each way of failing
|
||||
@@ -9,11 +9,14 @@
|
||||
*/
|
||||
import type { AiStatusResponse, AssistantMessage, AssistantThreadResponse, UiStreamChunk } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { seedStore } from '../../tests/helpers/store';
|
||||
import { AssistantRequestError } from '../api/assistant';
|
||||
import { aiConnectionsRepo } from '../repo/aiConnectionsRepo';
|
||||
import { assistantRepo } from '../repo/assistantRepo';
|
||||
import { OnlineOnlyError } from '../repo/onlineOnly';
|
||||
import { useAddonStore } from './addonStore';
|
||||
import { chatOf, useAssistantStore } from './assistantStore';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { useSettingsStore } from './settingsStore';
|
||||
|
||||
const TRIP = 4;
|
||||
@@ -60,6 +63,10 @@ async function* hangingStream(first: UiStreamChunk[], signal: AbortSignal): Asyn
|
||||
});
|
||||
}
|
||||
|
||||
/** The addon list as it lands from the server, with these addons on. */
|
||||
const addonsOn = (...ids: string[]) =>
|
||||
seedStore(useAddonStore, { loaded: true, addons: ids.map((id) => ({ id, enabled: true })) });
|
||||
|
||||
const chat = () => chatOf(useAssistantStore.getState(), TRIP);
|
||||
const store = () => useAssistantStore.getState();
|
||||
|
||||
@@ -76,6 +83,7 @@ beforeEach(() => {
|
||||
lastApplied: null,
|
||||
});
|
||||
useSettingsStore.setState((s) => ({ settings: { ...s.settings, language: 'de' } }));
|
||||
useAuthStore.setState({ managed: false });
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
@@ -311,6 +319,7 @@ describe('assistantStore: threads, window and status', () => {
|
||||
});
|
||||
|
||||
it('FE-AI-STORE-012: status fails closed, offline keeps the last answer, and the privacy notice is acknowledged', async () => {
|
||||
addonsOn('ai_assistant');
|
||||
vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValueOnce(STATUS);
|
||||
await store().loadStatus();
|
||||
expect(store().status).toEqual(STATUS);
|
||||
@@ -328,4 +337,39 @@ describe('assistantStore: threads, window and status', () => {
|
||||
await store().loadStatus();
|
||||
expect(store().status).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-STORE-013: the status is asked for only while the assistant or AI parsing is on', async () => {
|
||||
const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS);
|
||||
useAddonStore.setState({ addons: [], loaded: false });
|
||||
await store().loadStatus();
|
||||
addonsOn('mcp');
|
||||
await store().loadStatus();
|
||||
expect(status).not.toHaveBeenCalled();
|
||||
expect(store().status).toBeNull();
|
||||
|
||||
addonsOn('llm_parsing');
|
||||
await store().loadStatus();
|
||||
expect(status).toHaveBeenCalledTimes(1);
|
||||
expect(store().status).toEqual(STATUS);
|
||||
|
||||
addonsOn('ai_assistant');
|
||||
await store().loadStatus();
|
||||
expect(status).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Switched off again, the assistant goes away without a request.
|
||||
addonsOn();
|
||||
await store().loadStatus();
|
||||
expect(status).toHaveBeenCalledTimes(2);
|
||||
expect(store().status).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-STORE-014: a managed install, which refuses /api/ai, is not asked whatever its addons say', async () => {
|
||||
const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS);
|
||||
useAssistantStore.setState({ status: STATUS });
|
||||
useAuthStore.setState({ managed: true });
|
||||
addonsOn('ai_assistant', 'llm_parsing');
|
||||
await store().loadStatus();
|
||||
expect(status).not.toHaveBeenCalled();
|
||||
expect(store().status).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { Assignment } from '../types';
|
||||
import { browserTimeZone } from '../utils/localDate';
|
||||
import { randomId } from '../utils/randomId';
|
||||
import type { RouteProfileFocus } from '../utils/routeGeometry';
|
||||
import { useAddonStore } from './addonStore';
|
||||
import { assistantScopeOf, assistantTargetOf, type AssistantChatKey } from './assistantChatKey';
|
||||
import {
|
||||
blockDays,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
type WindowRect,
|
||||
} from './assistantMapModel';
|
||||
import { applyChunk, closeUndo, markDecisions } from './assistantMessages';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { useSettingsStore } from './settingsStore';
|
||||
import { useTripStore } from './tripStore';
|
||||
|
||||
@@ -120,7 +122,10 @@ interface UndoBlockResult {
|
||||
}
|
||||
|
||||
interface AssistantState {
|
||||
/** What the assistant runs on for this user; null until loaded, and the assistant stays hidden until then. */
|
||||
/**
|
||||
* What the assistant runs on for this user; null until loaded, while no addon
|
||||
* opens /api/ai (aiRoutesOpen) and on a managed install, and the assistant stays hidden then.
|
||||
*/
|
||||
status: AiStatusResponse | null;
|
||||
window: { open: boolean; minimized: boolean };
|
||||
/** The chats by `AssistantChatKey`: a trip's id, or a journey's key. */
|
||||
@@ -316,6 +321,19 @@ export function chatOf(state: Pick<AssistantState, 'byTrip'>, key: AssistantChat
|
||||
return state.byTrip[key] ?? EMPTY_CHAT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the addons open /api/ai: the server opens it while the assistant or
|
||||
* AI parsing is on, the two sharing one connection, and answers 404 while
|
||||
* both are off. Closed until the addon list is in. A selector on the addon
|
||||
* store; a managed install refuses /api/ai whatever it says (loadStatus).
|
||||
*/
|
||||
export function aiRoutesOpen({
|
||||
loaded,
|
||||
isEnabled,
|
||||
}: Pick<ReturnType<typeof useAddonStore.getState>, 'loaded' | 'isEnabled'>): boolean {
|
||||
return loaded && (isEnabled('ai_assistant') || isEnabled('llm_parsing'));
|
||||
}
|
||||
|
||||
function httpStatus(err: unknown): number | undefined {
|
||||
return (err as { response?: { status?: number } } | null)?.response?.status;
|
||||
}
|
||||
@@ -568,6 +586,12 @@ function backToAfter(byTrip: Record<number, AssistantTripChat>): Record<number,
|
||||
function shellActions(ctx: Ctx) {
|
||||
return {
|
||||
loadStatus: async () => {
|
||||
// While /api/ai is closed the request could only get a 404, or the 403 of a managed install: nothing is asked,
|
||||
// and the assistant stays hidden. The managed flag comes with the app config, which can land after the addons.
|
||||
if (useAuthStore.getState().managed || !aiRoutesOpen(useAddonStore.getState())) {
|
||||
ctx.set({ status: null });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ctx.set({ status: await aiConnectionsRepo.status() });
|
||||
} catch (err) {
|
||||
|
||||
@@ -27,6 +27,15 @@ describe('analyzeRouteGeometry', () => {
|
||||
expect(analysis!.distanceIndexedProfileSamples[2].distanceMeters).toBeCloseTo(3335.85, 1)
|
||||
})
|
||||
|
||||
it('leaves the elevation model jitter of a flat walk out of its ascent', () => {
|
||||
const analysis = analyzeRouteGeometry(JSON.stringify([
|
||||
[45.44, 12.32, 2], [45.441, 12.32, 9], [45.442, 12.32, 1], [45.443, 12.32, 4], [45.444, 12.32, 3],
|
||||
]))
|
||||
expect(analysis?.maxEle).toBe(9)
|
||||
expect(analysis?.gain).toBe(7)
|
||||
expect(analysis?.loss).toBe(8)
|
||||
})
|
||||
|
||||
it('maps route endpoints and interpolates focus between prepared samples', () => {
|
||||
const samples = [
|
||||
{ distanceMeters: 0, elevationMeters: 100, lat: 10, lng: 20 },
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { elevationGainLoss } from '@trek/shared'
|
||||
|
||||
export interface DistanceIndexedProfileSample {
|
||||
distanceMeters: number
|
||||
elevationMeters: number
|
||||
@@ -109,8 +111,6 @@ export function analyzeRouteGeometry(routeGeometry: string | null | undefined):
|
||||
let distanceMeters = 0
|
||||
let minEle = hasElevation ? points[0][2]! : null
|
||||
let maxEle = minEle
|
||||
let gain = hasElevation ? 0 : null
|
||||
let loss = hasElevation ? 0 : null
|
||||
const distanceIndexedProfileSamples: DistanceIndexedProfileSample[] = hasElevation
|
||||
? [{ distanceMeters: 0, elevationMeters: points[0][2]!, lat: points[0][0], lng: points[0][1] }]
|
||||
: []
|
||||
@@ -122,19 +122,18 @@ export function analyzeRouteGeometry(routeGeometry: string | null | undefined):
|
||||
const elevation = points[index][2]!
|
||||
minEle = Math.min(minEle!, elevation)
|
||||
maxEle = Math.max(maxEle!, elevation)
|
||||
const delta = elevation - points[index - 1][2]!
|
||||
if (delta > 0) gain! += delta
|
||||
else loss! += Math.abs(delta)
|
||||
distanceIndexedProfileSamples.push({ distanceMeters, elevationMeters: elevation, lat: points[index][0], lng: points[index][1] })
|
||||
}
|
||||
|
||||
// Ascent and descent with the elevation model's jitter left out, as the server counts them.
|
||||
const climb = hasElevation ? elevationGainLoss(points.map(point => point[2]!)) : null
|
||||
return {
|
||||
routeCoordinates: points.map(([lat, lng]) => [lat, lng]),
|
||||
distanceKm: distanceMeters / 1000,
|
||||
minEle,
|
||||
maxEle,
|
||||
gain,
|
||||
loss,
|
||||
gain: climb ? climb.gain : null,
|
||||
loss: climb ? climb.loss : null,
|
||||
distanceIndexedProfileSamples,
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+51
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@trek/root",
|
||||
"version": "4.3.4",
|
||||
"version": "5.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@trek/root",
|
||||
"version": "4.3.4",
|
||||
"version": "5.0.0",
|
||||
"workspaces": [
|
||||
"client",
|
||||
"server",
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"client": {
|
||||
"name": "@trek/client",
|
||||
"version": "4.3.4",
|
||||
"version": "5.0.0",
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "0.15.25",
|
||||
"@fontsource/abril-fatface": "^5.3.0",
|
||||
@@ -9370,7 +9370,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.62.0",
|
||||
@@ -9384,7 +9385,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.62.0",
|
||||
@@ -9398,7 +9400,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.62.0",
|
||||
@@ -9412,7 +9415,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.62.0",
|
||||
@@ -9426,7 +9430,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.62.0",
|
||||
@@ -9440,7 +9445,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.62.0",
|
||||
@@ -9454,7 +9460,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.62.0",
|
||||
@@ -9468,7 +9475,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.62.0",
|
||||
@@ -9482,7 +9490,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.62.0",
|
||||
@@ -9509,7 +9518,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.62.0",
|
||||
@@ -9523,7 +9533,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.62.0",
|
||||
@@ -9537,7 +9548,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.62.0",
|
||||
@@ -9551,7 +9563,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.62.0",
|
||||
@@ -9565,7 +9578,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.62.0",
|
||||
@@ -9579,7 +9593,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.62.0",
|
||||
@@ -9593,7 +9608,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.62.0",
|
||||
@@ -9607,7 +9623,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.62.0",
|
||||
@@ -9634,7 +9651,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.62.0",
|
||||
@@ -9648,7 +9666,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.62.0",
|
||||
@@ -9662,7 +9681,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.62.0",
|
||||
@@ -9676,7 +9696,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.62.0",
|
||||
@@ -9690,7 +9711,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.62.0",
|
||||
@@ -9704,7 +9726,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@scarf/scarf": {
|
||||
"version": "1.4.0",
|
||||
@@ -24982,7 +25005,7 @@
|
||||
},
|
||||
"server": {
|
||||
"name": "@trek/server",
|
||||
"version": "4.3.4",
|
||||
"version": "5.0.0",
|
||||
"dependencies": {
|
||||
"@aws-lite/client": "^0.23.7",
|
||||
"@aws-lite/s3": "^0.2.7",
|
||||
@@ -25361,7 +25384,7 @@
|
||||
},
|
||||
"shared": {
|
||||
"name": "@trek/shared",
|
||||
"version": "4.3.4",
|
||||
"version": "5.0.0",
|
||||
"dependencies": {
|
||||
"isomorphic-dompurify": "^3.23.0",
|
||||
"zod": "^4.6.7"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@trek/root",
|
||||
"private": true,
|
||||
"version": "4.3.4",
|
||||
"version": "5.0.0",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/server",
|
||||
"version": "4.3.4",
|
||||
"version": "5.0.0",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"src/db/repositories/Users.repository.ts": 1445,
|
||||
"src/nest/atlas/atlas.service.ts": 1182,
|
||||
"src/nest/auth/auth.service.ts": 1143,
|
||||
"src/nest/budget/budget.service.ts": 1622,
|
||||
"src/nest/budget/budget.service.ts": 1617,
|
||||
"src/nest/collections/collections.service.ts": 1478,
|
||||
"src/nest/doc-sync/doc-sync.service.ts": 1060,
|
||||
"src/nest/doc-sync/providers/synology-drive.client.ts": 1160,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { sumMinor } from '@trek/shared';
|
||||
import { splitEqualShares, sumMinor, toMinor } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* Re-denominate whole trip-currency cents into whole display-currency cents
|
||||
@@ -54,6 +54,65 @@ export function allocateDisplayCents(cents: number[], factor: number, total?: nu
|
||||
return unit === 1 ? out : out.map((v) => v * unit);
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom split's member amounts as trip cents that add up to what the payers were
|
||||
* credited. Converting each amount on its own rounds each one to a cent, so four
|
||||
* quarters of a 136,000 ISK bill came to 4 x 249.38 = 997.52 EUR against the 997.51
|
||||
* the payer was credited: every such bill left the balances a cent off zero, a cent
|
||||
* the settle-up list then had no payment for. When the amounts add up to the payers'
|
||||
* total in the bill's own currency, as the write path insists, they are allocated
|
||||
* against `creditCents` by largest remainder, ties to the lower user id. A split that
|
||||
* does not add up (rows from before that check) still converts member by member.
|
||||
*/
|
||||
export function customSplitCents(
|
||||
members: readonly { user_id: number; amount?: number | null }[],
|
||||
convert: (amount: number) => number,
|
||||
creditCents: number,
|
||||
paidMinor: number,
|
||||
): Record<number, number> {
|
||||
const shares: Record<number, number> = {};
|
||||
const own = members.map((m) => m.amount ?? 0);
|
||||
if (members.length === 0) return shares;
|
||||
if (sumMinor(own) !== paidMinor) {
|
||||
members.forEach((m, i) => (shares[m.user_id] = toMinor(convert(own[i]))));
|
||||
return shares;
|
||||
}
|
||||
const exact = own.map((amount) => convert(amount) * 100);
|
||||
const cents = exact.map((v) => Math.floor(v));
|
||||
let drift = creditCents - cents.reduce((a, c) => a + c, 0);
|
||||
const rows = members.map((m, i) => ({ i, userId: m.user_id, frac: exact[i] - cents[i] }));
|
||||
const up = [...rows].sort((a, b) => b.frac - a.frac || a.userId - b.userId);
|
||||
const down = [...rows].sort((a, b) => a.frac - b.frac || a.userId - b.userId);
|
||||
for (let k = 0; drift > 0; k++, drift--) cents[up[k % up.length].i] += 1;
|
||||
for (let k = 0; drift < 0; k++, drift++) cents[down[k % down.length].i] -= 1;
|
||||
members.forEach((m, i) => (shares[m.user_id] = cents[i]));
|
||||
return shares;
|
||||
}
|
||||
|
||||
/**
|
||||
* Each member's part of one expense for the per-person summary, in trip cents. A
|
||||
* member with an amount of their own owes exactly that, the rest split the total
|
||||
* evenly, as the query this replaced did. When every member has an amount, the
|
||||
* amounts are converted as one set (customSplitCents) the way the settlement
|
||||
* converts them, so the parts add up to the bill.
|
||||
*/
|
||||
export function summaryShareCents(
|
||||
members: readonly { user_id: number; amount?: number | null }[],
|
||||
convert: (amount: number) => number,
|
||||
totalPrice: number,
|
||||
itemId: number,
|
||||
): Record<number, number> {
|
||||
const totalCents = toMinor(convert(totalPrice));
|
||||
const hasAmount = (m: { amount?: number | null }) => m.amount !== null && m.amount !== undefined;
|
||||
if (members.length > 0 && members.every(hasAmount)) {
|
||||
return customSplitCents(members, convert, totalCents, toMinor(totalPrice));
|
||||
}
|
||||
const equal = splitEqualShares(totalCents, members, itemId);
|
||||
return Object.fromEntries(
|
||||
members.map((m) => [m.user_id, hasAmount(m) ? toMinor(convert(m.amount as number)) : equal[m.user_id] || 0]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What `exchange_rate` holds on a row whose rate was never frozen. The column is
|
||||
* `REAL NOT NULL DEFAULT 1`, so without a migration 1 is the only way to store "no
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import type { BudgetFallbackFx, BudgetParticipantFinal, BudgetUnconverted, TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
|
||||
import { currencyDecimals, splitEqualShares, toMinor } from '@trek/shared';
|
||||
import { currencyDecimals, splitEqualShares, sumMinor, toMinor } from '@trek/shared';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { avatarUrl } from '../common/avatarUrl';
|
||||
@@ -31,7 +31,7 @@ import { BUDGET_ITEM_TICKET, RESERVATION_METADATA } from '../../db/json-columns'
|
||||
import { decodeJson, decodeJsonResult, encodeJson } from '../../utils/json-column';
|
||||
import { DomainError } from '../common/domain-error';
|
||||
import { GUEST_HAS_PAYMENTS, shareAfterLeaving } from './budget-departure.helpers';
|
||||
import { allocateDisplayCents, isFrozenRate, NOT_FROZEN_RATE, quoteRatio, sumMoney, tripConverter } from './budget-money.helpers';
|
||||
import { allocateDisplayCents, customSplitCents, isFrozenRate, NOT_FROZEN_RATE, quoteRatio, summaryShareCents, sumMoney, tripConverter } from './budget-money.helpers';
|
||||
|
||||
type Trip = TripAccess;
|
||||
|
||||
@@ -992,12 +992,9 @@ export class BudgetService {
|
||||
// A foreign row nothing can convert counts for nobody, as in the settlement.
|
||||
const convert = tripConverter(item.currency, item.exchange_rate, tripCurrency, rates);
|
||||
if (!convert) continue;
|
||||
const toTripCents = (amount: number) => toMinor(convert(amount));
|
||||
// A member with an amount of their own owes exactly that; the rest split the
|
||||
// total evenly, as the query this replaces did.
|
||||
const equal = splitEqualShares(toTripCents(item.total_price || 0), own, item.id);
|
||||
const shares = summaryShareCents(own, convert, item.total_price || 0, item.id);
|
||||
for (const m of own) {
|
||||
const share = m.amount !== null && m.amount !== undefined ? toTripCents(m.amount) : (equal[m.user_id] || 0);
|
||||
const share = shares[m.user_id] || 0;
|
||||
let p = people.get(m.user_id);
|
||||
if (!p) {
|
||||
p = { user_id: m.user_id, username: m.username, avatar: m.avatar, assigned: 0, paid: 0, items_count: 0 };
|
||||
@@ -1184,7 +1181,6 @@ export class BudgetService {
|
||||
unconvertedCurrencies.add((item.currency || '').toUpperCase());
|
||||
continue;
|
||||
}
|
||||
const toTripCents = (amount: number): number => toMinor(convert(amount));
|
||||
const members = allMembers.filter(m => m.budget_item_id === item.id);
|
||||
const payers = allPayers.filter(p => p.budget_item_id === item.id);
|
||||
if (members.length === 0) continue; // planning-only entry → doesn't affect balances
|
||||
@@ -1231,12 +1227,11 @@ export class BudgetService {
|
||||
// here since #2225. An item whose payers net to exactly zero still does, and
|
||||
// divides zero, which is what it is worth.
|
||||
const hasCustomSplit = members.some(m => m.amount !== null && m.amount !== undefined);
|
||||
const equalShares = !hasCustomSplit ? splitEqualShares(creditCents, members, item.id) : {};
|
||||
const shares = hasCustomSplit
|
||||
? customSplitCents(members, convert, creditCents, sumMinor(payers.map(p => p.amount)))
|
||||
: splitEqualShares(creditCents, members, item.id);
|
||||
for (const m of members) {
|
||||
const memberShare = hasCustomSplit && m.amount !== null && m.amount !== undefined
|
||||
? toTripCents(m.amount)
|
||||
: (equalShares[m.user_id] || 0);
|
||||
ensure(m.user_id, m).cents -= memberShare;
|
||||
ensure(m.user_id, m).cents -= shares[m.user_id] || 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -143,14 +143,18 @@ export class SystemNoticesService {
|
||||
managed: this.env.isManaged(),
|
||||
settingFlag: (key: string) => settingFlags.get(key) ?? false,
|
||||
};
|
||||
const appVer = semver.coerce(currentAppVersion)?.version ?? '0.0.0';
|
||||
// Full versions, the prerelease part included: a dismissal on 5.0.0-pre.2 is older
|
||||
// than 5.0.0, so whoever tried a prerelease still gets the notes of the release.
|
||||
const fullVersion = (v: string | null | undefined) =>
|
||||
semver.valid(v ?? '') ?? semver.coerce(v ?? '')?.version ?? '0.0.0';
|
||||
const appVer = fullVersion(currentAppVersion);
|
||||
|
||||
const isStillDismissed = (n: SystemNotice): boolean => {
|
||||
if (!dismissals.has(n.id)) return false;
|
||||
if (n.recurring === 'per-version') {
|
||||
// Re-show once the running app version moves past the version it was last dismissed at,
|
||||
// so a per-version notice surfaces again on each install/upgrade.
|
||||
const dismissedVer = semver.coerce(dismissals.get(n.id) ?? '0.0.0')?.version ?? '0.0.0';
|
||||
const dismissedVer = fullVersion(dismissals.get(n.id));
|
||||
return semver.gte(dismissedVer, appVer);
|
||||
}
|
||||
return true; // default: permanent one-time dismissal
|
||||
|
||||
@@ -59,7 +59,7 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
|
||||
titleKey: 'system_notice.release_notes.headline',
|
||||
bodyKey: 'system_notice.release_notes.intro',
|
||||
release: {
|
||||
version: '4.3.3',
|
||||
version: '5.0.0',
|
||||
eyebrowKey: 'system_notice.release_notes.eyebrow',
|
||||
headlineKey: 'system_notice.release_notes.headline',
|
||||
introKey: 'system_notice.release_notes.intro',
|
||||
@@ -67,35 +67,35 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
|
||||
featuresAsideKey: 'system_notice.release_notes.features_aside',
|
||||
features: [
|
||||
{
|
||||
iconName: 'Database',
|
||||
visual: 'places-api',
|
||||
titleKey: 'system_notice.release_notes.feature_places_title',
|
||||
bodyKey: 'system_notice.release_notes.feature_places_body',
|
||||
iconName: 'Mountain',
|
||||
visual: 'tours',
|
||||
titleKey: 'system_notice.release_notes.feature_tours_title',
|
||||
bodyKey: 'system_notice.release_notes.feature_tours_body',
|
||||
},
|
||||
{
|
||||
iconName: 'Route',
|
||||
visual: 'roadtrip',
|
||||
titleKey: 'system_notice.release_notes.feature_roadtrip_title',
|
||||
bodyKey: 'system_notice.release_notes.feature_roadtrip_body',
|
||||
iconName: 'BookOpen',
|
||||
visual: 'studio',
|
||||
titleKey: 'system_notice.release_notes.feature_studio_title',
|
||||
bodyKey: 'system_notice.release_notes.feature_studio_body',
|
||||
},
|
||||
{
|
||||
iconName: 'MapPin',
|
||||
visual: 'dawarich',
|
||||
titleKey: 'system_notice.release_notes.feature_dawarich_title',
|
||||
bodyKey: 'system_notice.release_notes.feature_dawarich_body',
|
||||
iconName: 'Sparkles',
|
||||
visual: 'assistant',
|
||||
titleKey: 'system_notice.release_notes.feature_assistant_title',
|
||||
bodyKey: 'system_notice.release_notes.feature_assistant_body',
|
||||
},
|
||||
{
|
||||
iconName: 'FolderSync',
|
||||
visual: 'docsync',
|
||||
iconName: 'LifeBuoy',
|
||||
visual: 'help',
|
||||
layout: 'wide',
|
||||
titleKey: 'system_notice.release_notes.feature_docsync_title',
|
||||
bodyKey: 'system_notice.release_notes.feature_docsync_body',
|
||||
titleKey: 'system_notice.release_notes.feature_help_title',
|
||||
bodyKey: 'system_notice.release_notes.feature_help_body',
|
||||
},
|
||||
],
|
||||
footnoteKey: 'system_notice.release_notes.footnote',
|
||||
notes: {
|
||||
labelKey: 'system_notice.release_notes.notes_label',
|
||||
href: 'https://github.com/liketrek/TREK/releases/tag/v4.3.3',
|
||||
href: 'https://github.com/liketrek/TREK/releases/tag/v5.0.0',
|
||||
},
|
||||
note: {
|
||||
eyebrowKey: 'system_notice.release_notes.note_eyebrow',
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { customSplitCents } from '../../../src/nest/budget/budget-money.helpers';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const isk = (amount: number) => amount / 136.34;
|
||||
|
||||
describe('customSplitCents', () => {
|
||||
it('allocates a split that adds up against the credited cents, ties to the lower user id', () => {
|
||||
const members = [4, 1, 3, 2].map((user_id) => ({ user_id, amount: 34000 }));
|
||||
expect(customSplitCents(members, isk, 99751, 13600000)).toEqual({ 1: 24938, 2: 24938, 3: 24938, 4: 24937 });
|
||||
});
|
||||
|
||||
it('takes cents back from the smallest fractions when the credit rounded down', () => {
|
||||
const members = [
|
||||
{ user_id: 1, amount: 0.5 },
|
||||
{ user_id: 2, amount: 0.5 },
|
||||
];
|
||||
expect(customSplitCents(members, (a) => a * 1.005, 99, 100)).toEqual({ 1: 49, 2: 50 });
|
||||
});
|
||||
|
||||
it('keeps the member-by-member rounding for a split that does not add up to the payers', () => {
|
||||
const members = [
|
||||
{ user_id: 1, amount: 34000 },
|
||||
{ user_id: 2, amount: null },
|
||||
];
|
||||
expect(customSplitCents(members, isk, 99751, 13600000)).toEqual({ 1: 24938, 2: 0 });
|
||||
});
|
||||
|
||||
it('splits nothing among nobody', () => {
|
||||
expect(customSplitCents([], isk, 100, 100)).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* A custom split in a foreign currency adds up to what its payer was credited.
|
||||
* BUDGET-SVC-DB-095 and 096.
|
||||
*
|
||||
* 136,000 ISK at 136.34 ISK per euro is 997.51 EUR, but each 34,000 ISK quarter
|
||||
* is 249.376 and rounded up on its own, so four of them came to 997.52: the
|
||||
* balances ended a cent off zero, a cent the settle-up list had no payment for.
|
||||
*/
|
||||
import { BudgetService } from '../../../src/nest/budget/budget.service';
|
||||
import { TripMembersModule } from '../../../src/nest/trip-members/trip-members.module';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { addTripMember, makeTrip } from '../../helpers/factories/trips';
|
||||
import { makeUser } from '../../helpers/factories/users';
|
||||
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
let t: TestModule;
|
||||
let budget: BudgetService;
|
||||
const orm = () => t.get(MikroORM);
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({ db, imports: [TripMembersModule] });
|
||||
budget = t.get(BudgetService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
async function seedQuarters() {
|
||||
const users = [
|
||||
(await makeUser(orm())).user,
|
||||
(await makeUser(orm())).user,
|
||||
(await makeUser(orm())).user,
|
||||
(await makeUser(orm())).user,
|
||||
];
|
||||
const trip = await makeTrip(orm(), users[0].id);
|
||||
await addTripMember(orm(), trip.id, users[1].id);
|
||||
await addTripMember(orm(), trip.id, users[2].id);
|
||||
await addTripMember(orm(), trip.id, users[3].id);
|
||||
await budget.createBudgetItem(trip.id, {
|
||||
name: 'Sky Lagoon',
|
||||
currency: 'ISK',
|
||||
exchange_rate: 136.34,
|
||||
payers: [{ user_id: users[0].id, amount: 136000 }],
|
||||
members: users.map((u) => ({ user_id: u.id, amount: 34000 })),
|
||||
});
|
||||
return { trip, users };
|
||||
}
|
||||
|
||||
describe('a custom split in a foreign currency', () => {
|
||||
it('BUDGET-SVC-DB-095: the balances add up to zero and settle-up clears every one of them', async () => {
|
||||
const { trip, users } = await seedQuarters();
|
||||
const result = await budget.calculateSettlement(trip.id, { tripCurrency: 'EUR' });
|
||||
|
||||
const cents = result.balances.map((b) => Math.round(b.balance * 100));
|
||||
expect(cents.reduce((a, c) => a + c, 0)).toBe(0);
|
||||
// The payer has the lowest id, so the tie hands them one of the three rounded-up quarters.
|
||||
expect(result.balances.find((b) => b.user_id === users[0].id)?.balance).toBe(748.13);
|
||||
const owed = result.flows
|
||||
.filter((f) => f.to.user_id === users[0].id)
|
||||
.reduce((a, f) => a + Math.round(f.amount * 100), 0);
|
||||
expect(owed).toBe(74813);
|
||||
});
|
||||
|
||||
it('BUDGET-SVC-DB-096: the per-person shares add up to the bill', async () => {
|
||||
const { trip } = await seedQuarters();
|
||||
const people = await budget.getPerPersonSummary(trip.id);
|
||||
expect(people.reduce((a, p) => a + Math.round(p.total_assigned * 100), 0)).toBe(99751);
|
||||
});
|
||||
});
|
||||
@@ -162,6 +162,18 @@ describe('SystemNoticesService (Plan 3f Task 6)', () => {
|
||||
).not.toContain('sn-test-release');
|
||||
});
|
||||
|
||||
it('SN4b — a dismissal on a prerelease of the running version is older, so the notice of the release shows', async () => {
|
||||
// Tried 5.0.0-pre.2, now on 5.0.0: the prerelease part must not be dropped.
|
||||
const prerelease = makeService({
|
||||
listForUser: [{ notice_id: 'sn-test-release', dismissed_app_version: `${getCurrentAppVersion()}-pre.2` }],
|
||||
});
|
||||
expect(
|
||||
(await prerelease.svc.getActiveFor(BASE_USER.id, new Set(['release']), getCurrentAppVersion())).map(
|
||||
(n) => n.id,
|
||||
),
|
||||
).toContain('sn-test-release');
|
||||
});
|
||||
|
||||
it('a permanently-dismissed (non-recurring) notice never returns', async () => {
|
||||
const { svc } = makeService({ listForUser: [{ notice_id: 'sn-test-generic', dismissed_app_version: null }] });
|
||||
expect((await svc.getActiveFor(BASE_USER.id)).map((n) => n.id)).not.toContain('sn-test-generic');
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/shared",
|
||||
"version": "4.3.4",
|
||||
"version": "5.0.0",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
|
||||
@@ -35,12 +35,6 @@ export const ALLOWED = [
|
||||
'settings.about.descriptionManaged instead, so neither reader is told the ' +
|
||||
'wrong thing and self-hosters lose nothing.',
|
||||
},
|
||||
{
|
||||
key: 'system_notice.release_notes.feature_dawarich_body',
|
||||
because:
|
||||
'it calls Dawarich self-hosted, not TREK, which is what Dawarich is, and the ' +
|
||||
'release notice carrying it is shown only where the managed condition is false',
|
||||
},
|
||||
{
|
||||
key: 'system_notice.v3_thankyou.body',
|
||||
because:
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"ar": {
|
||||
"admin.ts": { "marked": 0, "identical": 2 },
|
||||
"settings.ts": { "marked": 1, "identical": 1 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 1 }
|
||||
"settings.ts": { "marked": 1, "identical": 1 }
|
||||
},
|
||||
"az": {
|
||||
"admin.ts": { "marked": 0, "identical": 1 },
|
||||
@@ -57,7 +56,7 @@
|
||||
"roadtrip.ts": { "marked": 0, "identical": 3 },
|
||||
"settings.ts": { "marked": 0, "identical": 8 },
|
||||
"storage.ts": { "marked": 0, "identical": 2 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 3 }
|
||||
"system_notice.ts": { "marked": 0, "identical": 2 }
|
||||
},
|
||||
"hu": {
|
||||
"admin.ts": { "marked": 0, "identical": 1 }
|
||||
@@ -74,12 +73,12 @@
|
||||
"ja": {
|
||||
"admin.ts": { "marked": 0, "identical": 1 },
|
||||
"settings.ts": { "marked": 0, "identical": 1 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 2 }
|
||||
"system_notice.ts": { "marked": 0, "identical": 1 }
|
||||
},
|
||||
"ko": {
|
||||
"admin.ts": { "marked": 0, "identical": 1 },
|
||||
"settings.ts": { "marked": 0, "identical": 1 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 3 }
|
||||
"system_notice.ts": { "marked": 0, "identical": 2 }
|
||||
},
|
||||
"nl": {
|
||||
"journey.ts": { "marked": 0, "identical": 2 },
|
||||
@@ -94,7 +93,7 @@
|
||||
"ru": {
|
||||
"admin.ts": { "marked": 0, "identical": 4 },
|
||||
"settings.ts": { "marked": 0, "identical": 3 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 3 }
|
||||
"system_notice.ts": { "marked": 0, "identical": 2 }
|
||||
},
|
||||
"sk": {
|
||||
"admin.ts": { "marked": 0, "identical": 1 }
|
||||
@@ -110,16 +109,14 @@
|
||||
"system_notice.ts": { "marked": 0, "identical": 2 }
|
||||
},
|
||||
"th": {
|
||||
"admin.ts": { "marked": 0, "identical": 1 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 1 }
|
||||
"admin.ts": { "marked": 0, "identical": 1 }
|
||||
},
|
||||
"tr": {
|
||||
"system_notice.ts": { "marked": 0, "identical": 1 }
|
||||
},
|
||||
"uk": {
|
||||
"admin.ts": { "marked": 0, "identical": 4 },
|
||||
"settings.ts": { "marked": 0, "identical": 3 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 1 }
|
||||
"settings.ts": { "marked": 0, "identical": 3 }
|
||||
},
|
||||
"vi": {
|
||||
"admin.ts": { "marked": 0, "identical": 1 },
|
||||
@@ -127,12 +124,11 @@
|
||||
},
|
||||
"zh": {
|
||||
"admin.ts": { "marked": 0, "identical": 1 },
|
||||
"settings.ts": { "marked": 0, "identical": 1 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 1 }
|
||||
"settings.ts": { "marked": 0, "identical": 1 }
|
||||
},
|
||||
"zh-TW": {
|
||||
"admin.ts": { "marked": 0, "identical": 1 },
|
||||
"settings.ts": { "marked": 0, "identical": 1 },
|
||||
"system_notice.ts": { "marked": 0, "identical": 3 }
|
||||
"system_notice.ts": { "marked": 0, "identical": 2 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,20 +54,23 @@ const system_notice: TranslationStrings = {
|
||||
'system_notice.thank_you_support.cta_kofi': 'ادعمني على Ko-fi',
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.release_notes.eyebrow': 'تم تثبيت التحديث',
|
||||
'system_notice.release_notes.headline': 'أربعة أشياء يقوم بها TREK الآن بنفسه.',
|
||||
'system_notice.release_notes.headline': 'أكبر إصدار من TREK حتى الآن.',
|
||||
'system_notice.release_notes.intro':
|
||||
'واجهة أماكن خاصة به، ورحلات برية مخططة من البداية إلى النهاية، وسجل مواقعك في يديك، ومستنداتك متزامنة.',
|
||||
'جولات لكل يوم في الهواء الطلق، واستوديو لكتب صورك، ومساعد يخطط معك، ومساعدة في كل شاشة.',
|
||||
'system_notice.release_notes.features_label': 'أبرز الجديد',
|
||||
'system_notice.release_notes.features_aside': 'وهذا ليس كل شيء',
|
||||
'system_notice.release_notes.feature_places_title': 'TREK Places API',
|
||||
'system_notice.release_notes.feature_places_body':
|
||||
'أول مخطط رحلات مفتوح المصدر بواجهة أماكن خاصة به. 73.6 مليون مكان، بلا مفتاح، بلا حصة.',
|
||||
'system_notice.release_notes.feature_roadtrip_title': 'إضافة الرحلة البرية',
|
||||
'system_notice.release_notes.feature_roadtrip_body':
|
||||
'يخطط القيادة بنفسه: المسار والمسافة والساعات والمحطات. متوقف حتى يفعّله المشرف.',
|
||||
'system_notice.release_notes.feature_dawarich_title': 'تكامل Dawarich',
|
||||
'system_notice.release_notes.feature_dawarich_body':
|
||||
'البديل المستضاف ذاتيًا لـ Google Timeline، وصار بالإمكان الآن قراءته داخل TREK. يقرأ TREK، ويقرأ فقط.',
|
||||
'system_notice.release_notes.feature_tours_title': 'الجولات',
|
||||
'system_notice.release_notes.feature_tours_body':
|
||||
'المشي لمسافات طويلة وركوب الدراجات والجري وركوب الخيل والتجديف والتنزه في المدن وأيام التزلج، مع مخطط الارتفاعات ودرجات الصعوبة وتحليق ثلاثي الأبعاد.',
|
||||
'system_notice.release_notes.feature_studio_title': 'TREK Studio', // same-as-en
|
||||
'system_notice.release_notes.feature_studio_body':
|
||||
'يحوّل رحلتك إلى كتاب صور مصمَّم: 17 سمة و34 خطًا و160 ملصقًا وتنسيق تلقائي بنقرة واحدة.',
|
||||
'system_notice.release_notes.feature_assistant_title': 'المساعد الذكي',
|
||||
'system_notice.release_notes.feature_assistant_body':
|
||||
'يخطط معك لرحلاتك الحقيقية باستخدام حساب الذكاء الاصطناعي الذي لديك بالفعل. ترى كل تغيير أولًا ويمكنك التراجع عنه.',
|
||||
'system_notice.release_notes.feature_help_title': 'مساعدة في كل شاشة، بـ27 لغة',
|
||||
'system_notice.release_notes.feature_help_body':
|
||||
'نحو 370 دليلًا خطوة بخطوة مع 1600 صورة، في المكان الذي تحتاجها فيه بالضبط. جديد: الأذربيجانية والإستونية والسلوفاكية والتايلاندية.',
|
||||
'system_notice.release_notes.footnote': 'إلى جانب قائمة طويلة من التغييرات الأصغر في بقية أرجاء TREK.',
|
||||
'system_notice.release_notes.notes_label': 'ملاحظات الإصدار',
|
||||
'system_notice.release_notes.note_eyebrow': 'كلمة من صاحب المشروع',
|
||||
@@ -86,8 +89,5 @@ const system_notice: TranslationStrings = {
|
||||
'إن كان TREK قد استحق مكانًا في رحلاتك، فاشترِ لي فنجان قهوة وساعد في أن يرى الإصدار القادم النور.',
|
||||
'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':
|
||||
'Paperless-ngx وPapra وNextcloud وOpenCloud وSynology Drive. تتدفق مستندات الرحلة في الاتجاهين مع المخزن الذي تشغّله بالفعل.',
|
||||
};
|
||||
export default system_notice;
|
||||
|
||||
@@ -54,20 +54,23 @@ const system_notice: TranslationStrings = {
|
||||
|
||||
// The release modal. One stable set of keys: each big release swaps the copy in place.
|
||||
'system_notice.release_notes.eyebrow': 'Yeniləmə quraşdırıldı',
|
||||
'system_notice.release_notes.headline': 'TREK-in indi özü gördüyü dörd iş.',
|
||||
'system_notice.release_notes.headline': 'İndiyədək ən böyük TREK.',
|
||||
'system_notice.release_notes.intro':
|
||||
'Ö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ı.',
|
||||
'Açıq havada hər gün üçün turlar, foto kitablarınız üçün studiya, sizinlə birlikdə planlayan köməkçi və hər ekranda kömək.',
|
||||
'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', // 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',
|
||||
'system_notice.release_notes.feature_roadtrip_body':
|
||||
'Sürüşü özü planlaşdırır: marşrut, məsafə, müddət və dayanacaqlar. Administrator aktivləşdirənə qədər deaktivdir.',
|
||||
'system_notice.release_notes.feature_dawarich_title': 'Dawarich inteqrasiyası',
|
||||
'system_notice.release_notes.feature_dawarich_body':
|
||||
'Google Timeline xidmətinin şəxsi serverdə işləyən alternativini indi TREK daxilində oxumaq mümkündür. TREK məlumatları yalnız oxuyur.',
|
||||
'system_notice.release_notes.feature_tours_title': 'Turlar',
|
||||
'system_notice.release_notes.feature_tours_body':
|
||||
'Piyada yürüşlər, velosiped turları, qaçışlar, at gəzintiləri, avarçəkmə, şəhər gəzintiləri və xizək günləri: hündürlük profili, çətinlik dərəcələri və 3D uçuşla.',
|
||||
'system_notice.release_notes.feature_studio_title': 'TREK Studio', // same-as-en
|
||||
'system_notice.release_notes.feature_studio_body':
|
||||
'Səyahəti dizayn edilmiş foto kitaba çevirir: 17 mövzu, 34 şrift, 160 stiker və bir kliklə avtomatik tərtibat.',
|
||||
'system_notice.release_notes.feature_assistant_title': 'Süni intellekt köməkçisi',
|
||||
'system_notice.release_notes.feature_assistant_body':
|
||||
'Artıq sahib olduğunuz süni intellekt hesabı ilə real səyahətlərinizi sizinlə birlikdə planlayır. Hər dəyişikliyi əvvəlcə görür və geri qaytara bilərsiniz.',
|
||||
'system_notice.release_notes.feature_help_title': 'Hər ekranda kömək, 27 dildə',
|
||||
'system_notice.release_notes.feature_help_body':
|
||||
'Təxminən 370 addım-addım bələdçi və 1600 şəkil, məhz lazım olduğu yerdə. Yeni dillər: Azərbaycan, eston, slovak və tay dilləri.',
|
||||
'system_notice.release_notes.footnote': 'Bundan əlavə, TREK-in digər bölmələrində çoxsaylı kiçik dəyişikliklər var.',
|
||||
'system_notice.release_notes.notes_label': 'Buraxılış qeydləri',
|
||||
'system_notice.release_notes.note_eyebrow': 'Layihə rəhbərindən qeyd',
|
||||
@@ -92,9 +95,6 @@ const system_notice: TranslationStrings = {
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': '{n}-ci bildirişə keç',
|
||||
'system_notice.pager.position': '{total} bildirişdən {current}-cisi',
|
||||
'system_notice.release_notes.feature_docsync_title': 'Sənəd sinxronizasiyası',
|
||||
'system_notice.release_notes.feature_docsync_body':
|
||||
'Paperless-ngx, Papra, Nextcloud, OpenCloud və Synology Drive. Səyahət sənədləri artıq istifadə etdiyiniz saxlama xidməti ilə hər iki istiqamətdə ötürülür.',
|
||||
};
|
||||
|
||||
export default system_notice;
|
||||
|
||||
@@ -56,20 +56,23 @@ const system_notice: TranslationStrings = {
|
||||
'A atualização 3.0.14 detectou um ou mais conflitos de nome de usuário ou e-mail causados por espaços em branco no início ou fim dos valores armazenados. As contas afetadas foram renomeadas automaticamente. Verifique os logs do servidor por linhas começando com **[migration] WHITESPACE COLLISION** para identificar quais contas precisam de revisão.',
|
||||
// The release modal. One stable set of keys: each big release swaps the copy in place.
|
||||
'system_notice.release_notes.eyebrow': 'Atualização instalada',
|
||||
'system_notice.release_notes.headline': 'Quatro coisas que o TREK agora faz sozinho.',
|
||||
'system_notice.release_notes.headline': 'O maior TREK até hoje.',
|
||||
'system_notice.release_notes.intro':
|
||||
'A sua própria API de locais, road trips planeadas do início ao fim, o seu histórico de localização nas suas mãos, e os seus documentos sincronizados.',
|
||||
'Passeios para cada dia ao ar livre, um estúdio para os seus fotolivros, um assistente que planeja com você e ajuda em cada tela.',
|
||||
'system_notice.release_notes.features_label': 'Os destaques',
|
||||
'system_notice.release_notes.features_aside': 'E não é só isso',
|
||||
'system_notice.release_notes.feature_places_title': 'TREK Places API', // same-as-en
|
||||
'system_notice.release_notes.feature_places_body':
|
||||
'O primeiro planeador de viagens open source com API de locais própria. 73,6 milhões de locais, sem chave, sem quota.',
|
||||
'system_notice.release_notes.feature_roadtrip_title': 'Addon Viagem de carro',
|
||||
'system_notice.release_notes.feature_roadtrip_body':
|
||||
'Planeia a viagem sozinho: rota, distância, horas e paragens. Desligado até um admin o ativar.',
|
||||
'system_notice.release_notes.feature_dawarich_title': 'Integração Dawarich',
|
||||
'system_notice.release_notes.feature_dawarich_body':
|
||||
'A alternativa self-hosted ao Google Timeline, agora visível dentro do TREK. O TREK lê, e apenas lê.',
|
||||
'system_notice.release_notes.feature_tours_title': 'Passeios',
|
||||
'system_notice.release_notes.feature_tours_body':
|
||||
'Trilhas, pedaladas, corridas, cavalgadas, canoagem, caminhadas urbanas e dias de esqui, com perfil de altitude, graus de dificuldade e sobrevoo em 3D.',
|
||||
'system_notice.release_notes.feature_studio_title': 'TREK Studio', // same-as-en
|
||||
'system_notice.release_notes.feature_studio_body':
|
||||
'Transforma uma viagem em um fotolivro diagramado: 17 temas, 34 fontes, 160 adesivos e diagramação automática em um clique.',
|
||||
'system_notice.release_notes.feature_assistant_title': 'Assistente de IA',
|
||||
'system_notice.release_notes.feature_assistant_body':
|
||||
'Planeja com você nas suas viagens de verdade, usando a conta de IA que você já tem. Você vê cada mudança antes e pode desfazê-la.',
|
||||
'system_notice.release_notes.feature_help_title': 'Ajuda em cada tela, em 27 idiomas',
|
||||
'system_notice.release_notes.feature_help_body':
|
||||
'Cerca de 370 guias passo a passo com 1.600 imagens, bem onde você precisa. Novos: azerbaijano, estoniano, eslovaco e tailandês.',
|
||||
'system_notice.release_notes.footnote': 'E ainda uma longa lista de mudanças menores em todo o resto do TREK.',
|
||||
'system_notice.release_notes.notes_label': 'Notas da versão',
|
||||
'system_notice.release_notes.note_eyebrow': 'Uma nota do mantenedor',
|
||||
@@ -89,8 +92,5 @@ const system_notice: TranslationStrings = {
|
||||
'Se o TREK ganhou um lugar nas suas viagens, me pague um café e ajude a fazer a próxima versão acontecer.',
|
||||
'system_notice.release_notes.cta_bmc': 'Buy me a coffee',
|
||||
'system_notice.release_notes.cta_kofi': 'Apoiar no Ko-fi',
|
||||
'system_notice.release_notes.feature_docsync_title': 'Sincronização de documentos',
|
||||
'system_notice.release_notes.feature_docsync_body':
|
||||
'Paperless-ngx, Papra, Nextcloud, OpenCloud e Synology Drive. Os documentos de uma viagem fluem nos dois sentidos com o serviço que já usa.',
|
||||
};
|
||||
export default system_notice;
|
||||
|
||||
@@ -56,18 +56,21 @@ const system_notice: TranslationStrings = {
|
||||
'system_notice.thank_you_support.cta_bmc': 'Buy Me a Coffee',
|
||||
'system_notice.thank_you_support.cta_kofi': 'Dona suport a Ko-fi',
|
||||
'system_notice.release_notes.eyebrow': 'Actualització instal·lada',
|
||||
'system_notice.release_notes.headline': 'Quatre coses que TREK ara fa tot sol.',
|
||||
'system_notice.release_notes.headline': 'El TREK més gran fins ara.',
|
||||
'system_notice.release_notes.features_label': 'Els protagonistes',
|
||||
'system_notice.release_notes.features_aside': 'I això no és tot',
|
||||
'system_notice.release_notes.feature_places_title': 'TREK Places API', // same-as-en
|
||||
'system_notice.release_notes.feature_places_body':
|
||||
'El primer planificador de viatges de codi obert amb API de llocs pròpia. 73,6 milions de llocs, sense clau, sense quota.',
|
||||
'system_notice.release_notes.feature_roadtrip_title': 'Complement Roadtrip',
|
||||
'system_notice.release_notes.feature_roadtrip_body':
|
||||
'Planifica el trajecte tot sol: ruta, distància, hores i parades. Desactivat fins que un admin l’activi.',
|
||||
'system_notice.release_notes.feature_dawarich_title': 'Integració amb Dawarich',
|
||||
'system_notice.release_notes.feature_dawarich_body':
|
||||
'L’alternativa autoallotjada a Google Timeline, ara llegible des de TREK. TREK llegeix, i només llegeix.',
|
||||
'system_notice.release_notes.feature_tours_title': 'Excursions',
|
||||
'system_notice.release_notes.feature_tours_body':
|
||||
'Excursions a peu, en bici, curses, sortides a cavall, rem, passejades per la ciutat i dies d’esquí, amb perfil d’altitud, graus de dificultat i sobrevol en 3D.',
|
||||
'system_notice.release_notes.feature_studio_title': 'TREK Studio', // same-as-en
|
||||
'system_notice.release_notes.feature_studio_body':
|
||||
'Converteix un viatge en un fotollibre dissenyat: 17 temes, 34 tipografies, 160 adhesius i maquetació automàtica amb un clic.',
|
||||
'system_notice.release_notes.feature_assistant_title': 'Assistent d’IA',
|
||||
'system_notice.release_notes.feature_assistant_body':
|
||||
'Planifica amb tu sobre els teus viatges reals, amb el compte d’IA que ja tens. Cada canvi es mostra primer i es pot desfer.',
|
||||
'system_notice.release_notes.feature_help_title': 'Ajuda a cada pantalla, en 27 idiomes',
|
||||
'system_notice.release_notes.feature_help_body':
|
||||
'Unes 370 guies pas a pas amb 1.600 imatges, just on les necessites. Novetat: azerbaidjanès, estonià, eslovac i tai.',
|
||||
'system_notice.release_notes.footnote': 'I una llarga llista de canvis més petits a la resta de TREK.',
|
||||
'system_notice.release_notes.notes_label': 'Notes de la versió',
|
||||
'system_notice.release_notes.note_eyebrow': 'Una nota del mantenidor',
|
||||
@@ -88,9 +91,6 @@ const system_notice: TranslationStrings = {
|
||||
'system_notice.release_notes.cta_bmc': 'Buy me a coffee',
|
||||
'system_notice.release_notes.cta_kofi': 'Dona suport a Ko-fi',
|
||||
'system_notice.release_notes.intro':
|
||||
'Una API de llocs pròpia, viatges per carretera planificats de cap a peus, el teu historial d’ubicacions a les teves mans, i els teus documents sincronitzats.',
|
||||
'system_notice.release_notes.feature_docsync_title': 'Sincronització de documents',
|
||||
'system_notice.release_notes.feature_docsync_body':
|
||||
'Paperless-ngx, Papra, Nextcloud, OpenCloud i Synology Drive. Els documents d’un viatge flueixen en tots dos sentits amb el magatzem que ja fas servir.',
|
||||
'Excursions per a cada dia a l’aire lliure, un estudi per als teus fotollibres, un assistent que planifica amb tu i ajuda a cada pantalla.',
|
||||
};
|
||||
export default system_notice;
|
||||
|
||||
@@ -56,20 +56,23 @@ const system_notice: TranslationStrings = {
|
||||
'Aktualizace 3.0.14 zjistila jeden nebo více konfliktů uživatelského jména nebo e-mailu způsobených mezerami na začátku nebo konci uložených hodnot. Dotčené účty byly automaticky přejmenovány. Zkontrolujte protokoly serveru na řádky začínající **[migration] WHITESPACE COLLISION** a zjistěte, které účty vyžadují kontrolu.',
|
||||
// The release modal. One stable set of keys: each big release swaps the copy in place.
|
||||
'system_notice.release_notes.eyebrow': 'Aktualizace nainstalována',
|
||||
'system_notice.release_notes.headline': 'Čtyři věci, které TREK nyní zvládne sám.',
|
||||
'system_notice.release_notes.headline': 'Největší TREK, jaký kdy byl.',
|
||||
'system_notice.release_notes.intro':
|
||||
'Vlastní API míst, road tripy naplánované od začátku do konce, historie polohy ve vašich rukou, a dokumenty v synchronizaci.',
|
||||
'Výlety na každý den venku, studio pro vaše fotoknihy, asistent, který plánuje s vámi, a nápověda na každé obrazovce.',
|
||||
'system_notice.release_notes.features_label': 'Hlavní novinky',
|
||||
'system_notice.release_notes.features_aside': 'Zdaleka ne všechno',
|
||||
'system_notice.release_notes.feature_places_title': 'TREK Places API', // same-as-en
|
||||
'system_notice.release_notes.feature_places_body':
|
||||
'První open source plánovač cest s vlastním API míst. 73,6 milionu míst, bez klíče, bez limitu.',
|
||||
'system_notice.release_notes.feature_roadtrip_title': 'Doplněk Cesta autem',
|
||||
'system_notice.release_notes.feature_roadtrip_body':
|
||||
'Naplánuje jízdu sám: trasu, vzdálenost, hodiny a zastávky. Vypnuto, dokud to admin nezapne.',
|
||||
'system_notice.release_notes.feature_dawarich_title': 'Integrace Dawarich',
|
||||
'system_notice.release_notes.feature_dawarich_body':
|
||||
'Self-hosted odpověď na Google Timeline, teď čitelná přímo v TREK. TREK čte, a jen čte.',
|
||||
'system_notice.release_notes.feature_tours_title': 'Výlety',
|
||||
'system_notice.release_notes.feature_tours_body':
|
||||
'Túry, cyklovýlety, běhy, vyjížďky na koni, pádlování, procházky městem a dny na lyžích, s výškovým profilem, obtížností a 3D přeletem.',
|
||||
'system_notice.release_notes.feature_studio_title': 'TREK Studio', // same-as-en
|
||||
'system_notice.release_notes.feature_studio_body':
|
||||
'Promění cestu v navrženou fotoknihu: 17 motivů, 34 písem, 160 samolepek a automatické rozvržení jedním kliknutím.',
|
||||
'system_notice.release_notes.feature_assistant_title': 'AI asistent',
|
||||
'system_notice.release_notes.feature_assistant_body':
|
||||
'Plánuje s vámi vaše skutečné cesty, přes účet AI, který už máte. Každou změnu nejdřív uvidíte a můžete ji vrátit.',
|
||||
'system_notice.release_notes.feature_help_title': 'Nápověda na každé obrazovce, ve 27 jazycích',
|
||||
'system_notice.release_notes.feature_help_body':
|
||||
'Asi 370 návodů krok za krokem s 1 600 obrázky, přesně tam, kde je potřebujete. Nově: ázerbájdžánština, estonština, slovenština a thajština.',
|
||||
'system_notice.release_notes.footnote': 'A k tomu dlouhý seznam menších změn napříč zbytkem TREK.',
|
||||
'system_notice.release_notes.notes_label': 'Poznámky k vydání',
|
||||
'system_notice.release_notes.note_eyebrow': 'Slovo od autora',
|
||||
@@ -89,8 +92,5 @@ const system_notice: TranslationStrings = {
|
||||
'Pokud si TREK získal místo na vašich cestách, kupte mi kávu a pomozte, aby přišlo i další vydání.',
|
||||
'system_notice.release_notes.cta_bmc': 'Kup mi kávu',
|
||||
'system_notice.release_notes.cta_kofi': 'Podpořit na Ko-fi',
|
||||
'system_notice.release_notes.feature_docsync_title': 'Synchronizace dokumentů',
|
||||
'system_notice.release_notes.feature_docsync_body':
|
||||
'Paperless-ngx, Papra, Nextcloud, OpenCloud a Synology Drive. Dokumenty cesty proudí oběma směry s úložištěm, které už provozujete.',
|
||||
};
|
||||
export default system_notice;
|
||||
|
||||
@@ -57,20 +57,23 @@ const system_notice: TranslationStrings = {
|
||||
'Das 3.0.14-Upgrade hat einen oder mehrere Konflikte bei Benutzernamen oder E-Mail-Adressen festgestellt, die durch führende oder nachgestellte Leerzeichen in gespeicherten Konten verursacht wurden. Betroffene Konten wurden automatisch umbenannt. Prüfe die Serverprotokolle auf Zeilen, die mit **[migration] WHITESPACE COLLISION** beginnen, um die betroffenen Konten zu identifizieren.',
|
||||
// The release modal. One stable set of keys: each big release swaps the copy in place.
|
||||
'system_notice.release_notes.eyebrow': 'Update installiert',
|
||||
'system_notice.release_notes.headline': 'Vier Dinge, die TREK jetzt selbst kann.',
|
||||
'system_notice.release_notes.headline': 'Das größte TREK bisher.',
|
||||
'system_notice.release_notes.intro':
|
||||
'Eine eigene Orts-API, Roadtrips komplett geplant, dein Standortverlauf wieder in deiner Hand, und deine Dokumente im Abgleich.',
|
||||
'Touren für jeden Tag draußen, ein Studio für deine Fotobücher, ein Assistent, der mit dir plant, und Hilfe auf jedem Bildschirm.',
|
||||
'system_notice.release_notes.features_label': 'Die Highlights',
|
||||
'system_notice.release_notes.features_aside': 'Längst nicht alles',
|
||||
'system_notice.release_notes.feature_places_title': 'TREK Places API', // same-as-en
|
||||
'system_notice.release_notes.feature_places_body':
|
||||
'Der erste Open-Source-Reiseplaner mit eigener Orts-API. 73,6 Millionen Orte, kein Key, kein Kontingent.',
|
||||
'system_notice.release_notes.feature_roadtrip_title': 'Roadtrip-Addon',
|
||||
'system_notice.release_notes.feature_roadtrip_body':
|
||||
'Plant die Fahrt selbst: Route, Strecke, Stunden und Stopps. Aus, bis ein Admin es einschaltet.',
|
||||
'system_notice.release_notes.feature_dawarich_title': 'Dawarich-Integration',
|
||||
'system_notice.release_notes.feature_dawarich_body':
|
||||
'Die selbst gehostete Antwort auf Google Timeline, jetzt direkt in TREK lesbar. TREK liest, und nur das.',
|
||||
'system_notice.release_notes.feature_tours_title': 'Touren',
|
||||
'system_notice.release_notes.feature_tours_body':
|
||||
'Wanderungen, Radtouren, Läufe, Ausritte, Paddeltouren, Stadtspaziergänge und Skitage, mit Höhenprofil, Schwierigkeitsgraden und 3D-Überflug.',
|
||||
'system_notice.release_notes.feature_studio_title': 'TREK Studio', // same-as-en
|
||||
'system_notice.release_notes.feature_studio_body':
|
||||
'Macht aus einer Reise ein gestaltetes Fotobuch: 17 Themes, 34 Schriften, 160 Sticker und Auto-Layout mit einem Klick.',
|
||||
'system_notice.release_notes.feature_assistant_title': 'KI-Assistent',
|
||||
'system_notice.release_notes.feature_assistant_body':
|
||||
'Plant mit dir an deinen echten Reisen, über das KI-Konto, das du schon hast. Jede Änderung siehst du vorher und kannst sie rückgängig machen.',
|
||||
'system_notice.release_notes.feature_help_title': 'Hilfe auf jedem Bildschirm, in 27 Sprachen',
|
||||
'system_notice.release_notes.feature_help_body':
|
||||
'Rund 370 Schritt-für-Schritt-Anleitungen mit 1.600 Bildern, genau dort, wo du sie brauchst. Neu: Aserbaidschanisch, Estnisch, Slowakisch und Thai.',
|
||||
'system_notice.release_notes.footnote': 'Dazu kommt eine lange Liste kleinerer Änderungen überall sonst in TREK.',
|
||||
'system_notice.release_notes.notes_label': 'Release Notes',
|
||||
'system_notice.release_notes.note_eyebrow': 'Ein Wort vom Maintainer',
|
||||
@@ -90,8 +93,5 @@ const system_notice: TranslationStrings = {
|
||||
'Wenn TREK sich einen Platz auf deinen Reisen verdient hat, spendier mir einen Kaffee und hilf mit, dass auch das nächste Release kommt.',
|
||||
'system_notice.release_notes.cta_bmc': 'Buy me a coffee',
|
||||
'system_notice.release_notes.cta_kofi': 'Ko-fi unterstützen',
|
||||
'system_notice.release_notes.feature_docsync_title': 'Dokumenten-Sync',
|
||||
'system_notice.release_notes.feature_docsync_body':
|
||||
'Paperless-ngx, Papra, Nextcloud, OpenCloud und Synology Drive. Die Dokumente einer Reise fließen in beide Richtungen mit dem Speicher, den du ohnehin betreibst.',
|
||||
};
|
||||
export default system_notice;
|
||||
|
||||
@@ -52,20 +52,23 @@ const system_notice: TranslationStrings = {
|
||||
'system_notice.thank_you_support.cta_kofi': 'Support on Ko-fi',
|
||||
// The release modal. One stable set of keys: each big release swaps the copy in place.
|
||||
'system_notice.release_notes.eyebrow': 'Update installed',
|
||||
'system_notice.release_notes.headline': 'Four things TREK now does on its own.',
|
||||
'system_notice.release_notes.headline': 'The biggest TREK yet.',
|
||||
'system_notice.release_notes.intro':
|
||||
'Its own place API, road trips planned end to end, your location history back in your hands, and your documents in sync.',
|
||||
'Tours for every kind of day outdoors, a studio for your photo books, an assistant that plans with you, and help on every screen.',
|
||||
'system_notice.release_notes.features_label': 'The headliners',
|
||||
'system_notice.release_notes.features_aside': 'Far from everything',
|
||||
'system_notice.release_notes.feature_places_title': 'TREK Places API',
|
||||
'system_notice.release_notes.feature_places_body':
|
||||
'The first open source travel planner with its own place API. 73.6 million places, no key, no quota.',
|
||||
'system_notice.release_notes.feature_roadtrip_title': 'Roadtrip Addon',
|
||||
'system_notice.release_notes.feature_roadtrip_body':
|
||||
'Plans the drive itself: route, distance, hours and stops. Off until an admin turns it on.',
|
||||
'system_notice.release_notes.feature_dawarich_title': 'Dawarich Integration',
|
||||
'system_notice.release_notes.feature_dawarich_body':
|
||||
'The self-hosted answer to Google Timeline, now readable inside TREK. TREK reads, and only reads.',
|
||||
'system_notice.release_notes.feature_tours_title': 'Tours',
|
||||
'system_notice.release_notes.feature_tours_body':
|
||||
'Hikes, bike tours, runs, rides, paddling, city walks and ski days, with an elevation profile, grades and a 3D flyover.',
|
||||
'system_notice.release_notes.feature_studio_title': 'TREK Studio',
|
||||
'system_notice.release_notes.feature_studio_body':
|
||||
'Turns a journey into a designed photo book: 17 themes, 34 typefaces, 160 stickers and auto layout in one click.',
|
||||
'system_notice.release_notes.feature_assistant_title': 'AI Assistant',
|
||||
'system_notice.release_notes.feature_assistant_body':
|
||||
'Plans with you on your real trips, using the AI account you already have. You see every change first and can undo it.',
|
||||
'system_notice.release_notes.feature_help_title': 'Help on every screen, in 27 languages',
|
||||
'system_notice.release_notes.feature_help_body':
|
||||
'About 370 step-by-step guides with 1,600 pictures, right where you need them. New: Azerbaijani, Estonian, Slovak and Thai.',
|
||||
'system_notice.release_notes.footnote': 'Plus a long list of smaller changes across the rest of TREK.',
|
||||
'system_notice.release_notes.notes_label': 'Release notes',
|
||||
'system_notice.release_notes.note_eyebrow': 'A note from the maintainer',
|
||||
@@ -90,8 +93,5 @@ const system_notice: TranslationStrings = {
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': 'Go to notice {n}',
|
||||
'system_notice.pager.position': 'Notice {current} of {total}',
|
||||
'system_notice.release_notes.feature_docsync_title': 'Document Sync',
|
||||
'system_notice.release_notes.feature_docsync_body':
|
||||
'Paperless-ngx, Papra, Nextcloud, OpenCloud and Synology Drive. A trip’s documents flow both ways with the store you already run.',
|
||||
};
|
||||
export default system_notice;
|
||||
|
||||
@@ -56,20 +56,23 @@ const system_notice: TranslationStrings = {
|
||||
'La actualización 3.0.14 detectó uno o más conflictos de nombre de usuario o correo electrónico causados por espacios en blanco al inicio o al final de los valores almacenados. Las cuentas afectadas se renombraron automáticamente. Revisa los registros del servidor en busca de líneas que empiecen por **[migration] WHITESPACE COLLISION** para identificar qué cuentas necesitan revisión.',
|
||||
// The release modal. One stable set of keys: each big release swaps the copy in place.
|
||||
'system_notice.release_notes.eyebrow': 'Actualización instalada',
|
||||
'system_notice.release_notes.headline': 'Cuatro cosas que TREK ahora hace por sí mismo.',
|
||||
'system_notice.release_notes.headline': 'El TREK más grande hasta ahora.',
|
||||
'system_notice.release_notes.intro':
|
||||
'Su propia API de lugares, viajes por carretera planificados de principio a fin, tu historial de ubicaciones en tus manos, y tus documentos sincronizados.',
|
||||
'Excursiones para cada día al aire libre, un estudio para tus fotolibros, un asistente que planifica contigo y ayuda en cada pantalla.',
|
||||
'system_notice.release_notes.features_label': 'Los protagonistas',
|
||||
'system_notice.release_notes.features_aside': 'Y eso no es todo',
|
||||
'system_notice.release_notes.feature_places_title': 'API de lugares de TREK',
|
||||
'system_notice.release_notes.feature_places_body':
|
||||
'El primer planificador de viajes de código abierto con su propia API de lugares. 73,6 millones de lugares, sin clave, sin cuota.',
|
||||
'system_notice.release_notes.feature_roadtrip_title': 'Addon Roadtrip',
|
||||
'system_notice.release_notes.feature_roadtrip_body':
|
||||
'Planifica el trayecto por sí solo: ruta, distancia, horas y paradas. Apagado hasta que un admin lo active.',
|
||||
'system_notice.release_notes.feature_dawarich_title': 'Integración con Dawarich',
|
||||
'system_notice.release_notes.feature_dawarich_body':
|
||||
'La alternativa autoalojada a Google Timeline, ahora legible desde TREK. TREK lee, y solo lee.',
|
||||
'system_notice.release_notes.feature_tours_title': 'Excursiones',
|
||||
'system_notice.release_notes.feature_tours_body':
|
||||
'Senderismo, bici, carreras, rutas a caballo, piragüismo, paseos urbanos y días de esquí, con perfil de altitud, grados de dificultad y sobrevuelo en 3D.',
|
||||
'system_notice.release_notes.feature_studio_title': 'TREK Studio', // same-as-en
|
||||
'system_notice.release_notes.feature_studio_body':
|
||||
'Convierte un viaje en un fotolibro diseñado: 17 temas, 34 tipografías, 160 pegatinas y maquetación automática con un clic.',
|
||||
'system_notice.release_notes.feature_assistant_title': 'Asistente de IA',
|
||||
'system_notice.release_notes.feature_assistant_body':
|
||||
'Planifica contigo sobre tus viajes reales, con la cuenta de IA que ya tienes. Cada cambio se muestra primero y se puede deshacer.',
|
||||
'system_notice.release_notes.feature_help_title': 'Ayuda en cada pantalla, en 27 idiomas',
|
||||
'system_notice.release_notes.feature_help_body':
|
||||
'Unas 370 guías paso a paso con 1600 imágenes, justo donde las necesitas. Novedad: azerí, estonio, eslovaco y tailandés.',
|
||||
'system_notice.release_notes.footnote': 'Y una larga lista de cambios más pequeños en el resto de TREK.',
|
||||
'system_notice.release_notes.notes_label': 'Notas de versión',
|
||||
'system_notice.release_notes.note_eyebrow': 'Una nota del desarrollador',
|
||||
@@ -89,8 +92,5 @@ const system_notice: TranslationStrings = {
|
||||
'Si se ha ganado un sitio en tus viajes, invítame a un café y ayuda a que llegue la próxima versión.',
|
||||
'system_notice.release_notes.cta_bmc': 'Invítame a un café',
|
||||
'system_notice.release_notes.cta_kofi': 'Apóyame en Ko-fi',
|
||||
'system_notice.release_notes.feature_docsync_title': 'Sincronización de documentos',
|
||||
'system_notice.release_notes.feature_docsync_body':
|
||||
'Paperless-ngx, Papra, Nextcloud, OpenCloud y Synology Drive. Los documentos de un viaje fluyen en ambos sentidos con el almacén que ya usas.',
|
||||
};
|
||||
export default system_notice;
|
||||
|
||||
@@ -52,20 +52,23 @@ const system_notice: TranslationStrings = {
|
||||
'system_notice.thank_you_support.cta_kofi': 'Toeta Ko-fi kaudu',
|
||||
// The release modal. One stable set of keys: each big release swaps the copy in place.
|
||||
'system_notice.release_notes.eyebrow': 'Uuendus paigaldatud',
|
||||
'system_notice.release_notes.headline': 'Neli asja, mida TREK nüüd ise teeb.',
|
||||
'system_notice.release_notes.headline': 'Kõigi aegade suurim TREK.',
|
||||
'system_notice.release_notes.intro':
|
||||
'Oma kohaandmete API, algusest lõpuni planeeritud autoreisid, sinu asukohaajalugu taas sinu kätes ja sünkroonitud dokumendid.',
|
||||
'Retked igaks päevaks looduses, stuudio sinu fotoraamatutele, assistent, kes planeerib koos sinuga, ja abi igal ekraanil.',
|
||||
'system_notice.release_notes.features_label': 'Peamised uuendused',
|
||||
'system_notice.release_notes.features_aside': 'See pole kaugeltki kõik',
|
||||
'system_notice.release_notes.feature_places_title': 'TREK Places API', // same-as-en
|
||||
'system_notice.release_notes.feature_places_body':
|
||||
'Esimene avatud lähtekoodiga reisiplaneerija oma kohaandmete API-ga. 73,6 miljonit kohta, ilma võtme ja kvoodita.',
|
||||
'system_notice.release_notes.feature_roadtrip_title': 'Autoreisi lisamoodul',
|
||||
'system_notice.release_notes.feature_roadtrip_body':
|
||||
'Planeerib sõidu: marsruudi, vahemaad, ajakulu ja peatused. Vaikimisi välja lülitatud, kuni administraator selle lubab.',
|
||||
'system_notice.release_notes.feature_dawarich_title': 'Dawarichi liidestus',
|
||||
'system_notice.release_notes.feature_dawarich_body':
|
||||
"Ise majutatav alternatiiv Google Timeline'ile, nüüd loetav TREKis. TREK ainult loeb andmeid.",
|
||||
'system_notice.release_notes.feature_tours_title': 'Retked',
|
||||
'system_notice.release_notes.feature_tours_body':
|
||||
'Matkad, rattaretked, jooksud, ratsaretked, aerutamine, linnajalutuskäigud ja suusapäevad, kõrgusprofiili, raskusastmete ja 3D-ülelennuga.',
|
||||
'system_notice.release_notes.feature_studio_title': 'TREK Studio', // same-as-en
|
||||
'system_notice.release_notes.feature_studio_body':
|
||||
'Teeb reisist kujundatud fotoraamatu: 17 teemat, 34 kirjatüüpi, 160 kleebist ja automaatne paigutus ühe klõpsuga.',
|
||||
'system_notice.release_notes.feature_assistant_title': 'Tehisintellekti assistent',
|
||||
'system_notice.release_notes.feature_assistant_body':
|
||||
'Planeerib koos sinuga sinu päris reise, kasutades sinu olemasolevat tehisintellekti kontot. Iga muudatust näed enne ja saad selle tagasi võtta.',
|
||||
'system_notice.release_notes.feature_help_title': 'Abi igal ekraanil, 27 keeles',
|
||||
'system_notice.release_notes.feature_help_body':
|
||||
'Umbes 370 samm-sammult juhendit 1600 pildiga, just seal, kus neid vajad. Uued keeled: aserbaidžaani, eesti, slovaki ja tai.',
|
||||
'system_notice.release_notes.footnote': 'Lisaks pikk loend väiksemaid muudatusi mujal TREKis.',
|
||||
'system_notice.release_notes.notes_label': 'Väljalaskemärkmed',
|
||||
'system_notice.release_notes.note_eyebrow': 'Sõnum hooldajalt',
|
||||
@@ -90,8 +93,5 @@ const system_notice: TranslationStrings = {
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': 'Ava teade {n}',
|
||||
'system_notice.pager.position': 'Teade {current} / {total}',
|
||||
'system_notice.release_notes.feature_docsync_title': 'Dokumentide sünkroonimine',
|
||||
'system_notice.release_notes.feature_docsync_body':
|
||||
'Paperless-ngx, Papra, Nextcloud, OpenCloud ja Synology Drive. Reisi dokumendid liiguvad mõlemas suunas sinu juba kasutatava salvestusteenusega.',
|
||||
};
|
||||
export default system_notice;
|
||||
|
||||
@@ -55,20 +55,23 @@ const system_notice: TranslationStrings = {
|
||||
'system_notice.v3014_whitespace_collision.body':
|
||||
"La mise à niveau 3.0.14 a détecté un ou plusieurs conflits de nom d'utilisateur ou d'adresse e-mail causés par des espaces en début ou en fin de valeur dans les comptes enregistrés. Les comptes concernés ont été renommés automatiquement. Consultez les journaux du serveur pour les lignes commençant par **[migration] WHITESPACE COLLISION** afin d'identifier les comptes nécessitant une vérification.",
|
||||
'system_notice.release_notes.eyebrow': 'Mise à jour installée',
|
||||
'system_notice.release_notes.headline': 'Quatre choses que TREK fait désormais tout seul.',
|
||||
'system_notice.release_notes.headline': 'Le plus grand TREK à ce jour.',
|
||||
'system_notice.release_notes.intro':
|
||||
'Sa propre API de lieux, des road trips planifiés de bout en bout, votre historique de position entre vos mains, et vos documents synchronisés.',
|
||||
'Des sorties pour chaque journée en plein air, un studio pour vos livres photo, un assistant qui planifie avec vous et de l’aide sur chaque écran.',
|
||||
'system_notice.release_notes.features_label': "Les têtes d'affiche",
|
||||
'system_notice.release_notes.features_aside': "Et ce n'est pas tout",
|
||||
'system_notice.release_notes.feature_places_title': 'API TREK Places',
|
||||
'system_notice.release_notes.feature_places_body':
|
||||
'Le premier planificateur de voyage open source avec sa propre API de lieux. 73,6 millions de lieux, sans clé, sans quota.',
|
||||
'system_notice.release_notes.feature_roadtrip_title': 'Addon Roadtrip',
|
||||
'system_notice.release_notes.feature_roadtrip_body':
|
||||
'Planifie le trajet lui-même : itinéraire, distance, heures et arrêts. Désactivé tant qu’un admin ne l’active pas.',
|
||||
'system_notice.release_notes.feature_dawarich_title': 'Intégration Dawarich',
|
||||
'system_notice.release_notes.feature_dawarich_body':
|
||||
'L’alternative auto-hébergée à Google Timeline, désormais lisible depuis TREK. TREK lit, et ne fait que lire.',
|
||||
'system_notice.release_notes.feature_tours_title': 'Sorties',
|
||||
'system_notice.release_notes.feature_tours_body':
|
||||
'Randonnées, vélo, course à pied, équitation, pagaie, balades en ville et journées de ski, avec profil altimétrique, cotations et survol en 3D.',
|
||||
'system_notice.release_notes.feature_studio_title': 'TREK Studio', // same-as-en
|
||||
'system_notice.release_notes.feature_studio_body':
|
||||
'Transforme un voyage en livre photo mis en page : 17 thèmes, 34 polices, 160 autocollants et une mise en page automatique en un clic.',
|
||||
'system_notice.release_notes.feature_assistant_title': 'Assistant IA',
|
||||
'system_notice.release_notes.feature_assistant_body':
|
||||
'Planifie avec vous sur vos vrais voyages, avec le compte IA que vous avez déjà. Chaque modification s’affiche d’abord et peut être annulée.',
|
||||
'system_notice.release_notes.feature_help_title': 'De l’aide sur chaque écran, en 27 langues',
|
||||
'system_notice.release_notes.feature_help_body':
|
||||
'Environ 370 guides pas à pas et 1 600 images, là où vous en avez besoin. Nouveau : azerbaïdjanais, estonien, slovaque et thaï.',
|
||||
'system_notice.release_notes.footnote': 'Et une longue liste de changements plus discrets dans le reste de TREK.',
|
||||
'system_notice.release_notes.notes_label': 'Notes de version',
|
||||
'system_notice.release_notes.note_eyebrow': 'Un mot du mainteneur',
|
||||
@@ -88,8 +91,5 @@ const system_notice: TranslationStrings = {
|
||||
"Si TREK s'est fait une place dans tes voyages, offre-moi un café et aide la prochaine version à voir le jour.",
|
||||
'system_notice.release_notes.cta_bmc': 'Buy me a coffee',
|
||||
'system_notice.release_notes.cta_kofi': 'Soutenir sur Ko-fi',
|
||||
'system_notice.release_notes.feature_docsync_title': 'Synchronisation de documents',
|
||||
'system_notice.release_notes.feature_docsync_body':
|
||||
'Paperless-ngx, Papra, Nextcloud, OpenCloud et Synology Drive. Les documents d’un voyage circulent dans les deux sens avec le service que vous hébergez déjà.',
|
||||
};
|
||||
export default system_notice;
|
||||
|
||||
@@ -57,20 +57,23 @@ const system_notice: TranslationStrings = {
|
||||
'system_notice.pager.goto': 'Μετάβαση στην ειδοποίηση {n}',
|
||||
'system_notice.pager.position': 'Ειδοποίηση {current} από {total}',
|
||||
'system_notice.release_notes.eyebrow': 'Ενημερώθηκε',
|
||||
'system_notice.release_notes.headline': 'Τέσσερα πράγματα που το TREK κάνει πλέον μόνο του.',
|
||||
'system_notice.release_notes.headline': 'Το μεγαλύτερο TREK μέχρι σήμερα.',
|
||||
'system_notice.release_notes.intro':
|
||||
'Δικό του API τοποθεσιών, οδικά ταξίδια σχεδιασμένα από την αρχή ως το τέλος, το ιστορικό τοποθεσίας σας στα χέρια σας, και τα έγγραφά σας σε συγχρονισμό.',
|
||||
'Εκδρομές για κάθε μέρα στη φύση, ένα στούντιο για τα φωτοβιβλία σας, ένας βοηθός που σχεδιάζει μαζί σας και βοήθεια σε κάθε οθόνη.',
|
||||
'system_notice.release_notes.features_label': 'Τα σημαντικότερα',
|
||||
'system_notice.release_notes.features_aside': 'Και δεν είναι μόνο αυτά',
|
||||
'system_notice.release_notes.feature_places_title': 'TREK Places API',
|
||||
'system_notice.release_notes.feature_places_body':
|
||||
'Ο πρώτος ανοιχτού κώδικα σχεδιαστής ταξιδιών με δικό του API τοποθεσιών. 73,6 εκατ. τοποθεσίες, χωρίς κλειδί, χωρίς όριο.',
|
||||
'system_notice.release_notes.feature_roadtrip_title': 'Πρόσθετο «Οδικό ταξίδι»',
|
||||
'system_notice.release_notes.feature_roadtrip_body':
|
||||
'Σχεδιάζει τη διαδρομή μόνο του: δρομολόγιο, απόσταση, ώρες και στάσεις. Ανενεργό μέχρι να το ενεργοποιήσει ένας διαχειριστής.',
|
||||
'system_notice.release_notes.feature_dawarich_title': 'Ενσωμάτωση Dawarich',
|
||||
'system_notice.release_notes.feature_dawarich_body':
|
||||
'Η εναλλακτική του Google Timeline που φιλοξενείτε μόνοι σας, τώρα ορατή μέσα από το TREK. Το TREK διαβάζει, και μόνο διαβάζει.',
|
||||
'system_notice.release_notes.feature_tours_title': 'Εκδρομές',
|
||||
'system_notice.release_notes.feature_tours_body':
|
||||
'Πεζοπορίες, ποδηλατάδες, τρέξιμο, ιππασία, κουπί, βόλτες στην πόλη και μέρες σκι, με υψομετρικό προφίλ, βαθμούς δυσκολίας και τρισδιάστατη πτήση.',
|
||||
'system_notice.release_notes.feature_studio_title': 'TREK Studio', // same-as-en
|
||||
'system_notice.release_notes.feature_studio_body':
|
||||
'Μετατρέπει ένα ταξίδι σε σχεδιασμένο φωτοβιβλίο: 17 θέματα, 34 γραμματοσειρές, 160 αυτοκόλλητα και αυτόματη σελιδοποίηση με ένα κλικ.',
|
||||
'system_notice.release_notes.feature_assistant_title': 'Βοηθός τεχνητής νοημοσύνης',
|
||||
'system_notice.release_notes.feature_assistant_body':
|
||||
'Σχεδιάζει μαζί σας τα πραγματικά σας ταξίδια, με τον λογαριασμό τεχνητής νοημοσύνης που ήδη έχετε. Κάθε αλλαγή τη βλέπετε πρώτα και μπορείτε να την αναιρέσετε.',
|
||||
'system_notice.release_notes.feature_help_title': 'Βοήθεια σε κάθε οθόνη, σε 27 γλώσσες',
|
||||
'system_notice.release_notes.feature_help_body':
|
||||
'Περίπου 370 οδηγοί βήμα προς βήμα με 1.600 εικόνες, εκεί που τις χρειάζεστε. Νέες γλώσσες: αζερικά, εσθονικά, σλοβακικά και ταϊλανδικά.',
|
||||
'system_notice.release_notes.footnote': 'Και ακόμη μια μεγάλη λίστα με μικρότερες αλλαγές σε όλο το υπόλοιπο TREK.',
|
||||
'system_notice.release_notes.notes_label': 'Σημειώσεις έκδοσης',
|
||||
'system_notice.release_notes.note_eyebrow': 'Μια σημείωση από τον δημιουργό',
|
||||
@@ -90,8 +93,5 @@ const system_notice: TranslationStrings = {
|
||||
'Αν έχει κερδίσει μια θέση στα ταξίδια σας, κεράστε με έναν καφέ και βοηθήστε να έρθει η επόμενη έκδοση.',
|
||||
'system_notice.release_notes.cta_bmc': 'Buy me a coffee',
|
||||
'system_notice.release_notes.cta_kofi': 'Στηρίξτε στο Ko-fi',
|
||||
'system_notice.release_notes.feature_docsync_title': 'Συγχρονισμός εγγράφων',
|
||||
'system_notice.release_notes.feature_docsync_body':
|
||||
'Paperless-ngx, Papra, Nextcloud, OpenCloud και Synology Drive. Τα έγγραφα ενός ταξιδιού ρέουν και προς τις δύο κατευθύνσεις με τον χώρο που ήδη χρησιμοποιείτε.',
|
||||
};
|
||||
export default system_notice;
|
||||
|
||||
@@ -55,20 +55,23 @@ const system_notice: TranslationStrings = {
|
||||
'system_notice.v3014_whitespace_collision.body':
|
||||
'A 3.0.14-es frissítés egy vagy több felhasználónév- vagy e-mail-ütközést észlelt, amelyeket a tárolt értékek elején vagy végén lévő szóközök okoztak. Az érintett fiókok automatikusan át lettek nevezve. Ellenőrizze a szervernaplókat a **[migration] WHITESPACE COLLISION** kezdetű soroknál a felülvizsgálatot igénylő fiókok azonosításához.',
|
||||
'system_notice.release_notes.eyebrow': 'Frissítés telepítve',
|
||||
'system_notice.release_notes.headline': 'Négy dolog, amit a TREK mostantól magától csinál.',
|
||||
'system_notice.release_notes.headline': 'Az eddigi legnagyobb TREK.',
|
||||
'system_notice.release_notes.intro':
|
||||
'Saját hely-API, teljesen megtervezett autós túrák, a helyelőzményeid a te kezedben, és a dokumentumaid szinkronban.',
|
||||
'Túrák minden szabadban töltött napra, stúdió a fotókönyveidhez, asszisztens, amely veled együtt tervez, és súgó minden képernyőn.',
|
||||
'system_notice.release_notes.features_label': 'A fő újdonságok',
|
||||
'system_notice.release_notes.features_aside': 'És ez még nem minden',
|
||||
'system_notice.release_notes.feature_places_title': 'TREK Places API', // same-as-en
|
||||
'system_notice.release_notes.feature_places_body':
|
||||
'Az első nyílt forráskódú útitervező saját hely-API-val. 73,6 millió hely, kulcs és kvóta nélkül.',
|
||||
'system_notice.release_notes.feature_roadtrip_title': 'Autós út bővítmény',
|
||||
'system_notice.release_notes.feature_roadtrip_body':
|
||||
'Maga tervezi meg az utat: útvonal, távolság, órák és megállók. Kikapcsolva, amíg egy admin be nem kapcsolja.',
|
||||
'system_notice.release_notes.feature_dawarich_title': 'Dawarich-integráció',
|
||||
'system_notice.release_notes.feature_dawarich_body':
|
||||
'A Google Timeline self-hosted megfelelője, mostantól a TREK-ben is olvasható. A TREK olvas, és csak olvas.',
|
||||
'system_notice.release_notes.feature_tours_title': 'Túrák',
|
||||
'system_notice.release_notes.feature_tours_body':
|
||||
'Gyalogtúrák, kerékpártúrák, futások, lovaglások, evezés, városi séták és síelős napok, magassági profillal, nehézségi fokozatokkal és 3D-s átrepüléssel.',
|
||||
'system_notice.release_notes.feature_studio_title': 'TREK Studio', // same-as-en
|
||||
'system_notice.release_notes.feature_studio_body':
|
||||
'Egy utazásból megtervezett fotókönyvet készít: 17 téma, 34 betűtípus, 160 matrica és automatikus elrendezés egy kattintással.',
|
||||
'system_notice.release_notes.feature_assistant_title': 'MI-asszisztens',
|
||||
'system_notice.release_notes.feature_assistant_body':
|
||||
'Veled együtt tervez a valódi utazásaidon, a már meglévő MI-fiókoddal. Minden módosítást előbb látsz, és vissza is vonhatod.',
|
||||
'system_notice.release_notes.feature_help_title': 'Súgó minden képernyőn, 27 nyelven',
|
||||
'system_notice.release_notes.feature_help_body':
|
||||
'Körülbelül 370 lépésről lépésre útmutató 1600 képpel, pont ott, ahol szükséged van rájuk. Új: azerbajdzsáni, észt, szlovák és thai.',
|
||||
'system_notice.release_notes.footnote': 'Plusz rengeteg kisebb változás a TREK többi részében.',
|
||||
'system_notice.release_notes.notes_label': 'Kiadási jegyzetek',
|
||||
'system_notice.release_notes.note_eyebrow': 'Egy szó a fejlesztőtől',
|
||||
@@ -88,8 +91,5 @@ const system_notice: TranslationStrings = {
|
||||
'Ha kiérdemelt egy helyet az utazásaid között, hívj meg egy kávéra, és segíts, hogy jöhessen a következő kiadás.',
|
||||
'system_notice.release_notes.cta_bmc': 'Vegyél nekem egy kávét',
|
||||
'system_notice.release_notes.cta_kofi': 'Támogass a Ko-fi-n',
|
||||
'system_notice.release_notes.feature_docsync_title': 'Dokumentumszinkron',
|
||||
'system_notice.release_notes.feature_docsync_body':
|
||||
'Paperless-ngx, Papra, Nextcloud, OpenCloud és Synology Drive. Egy út dokumentumai mindkét irányban áramlanak az általad már használt tárolóval.',
|
||||
};
|
||||
export default system_notice;
|
||||
|
||||
@@ -55,20 +55,23 @@ const system_notice: TranslationStrings = {
|
||||
'system_notice.v3014_whitespace_collision.body':
|
||||
'Pembaruan 3.0.14 mendeteksi satu atau lebih konflik nama pengguna atau email yang disebabkan oleh spasi di awal atau akhir nilai yang tersimpan. Akun yang terpengaruh telah diganti nama secara otomatis. Periksa log server untuk baris yang dimulai dengan **[migration] WHITESPACE COLLISION** guna mengidentifikasi akun mana yang perlu ditinjau.',
|
||||
'system_notice.release_notes.eyebrow': 'Pembaruan terpasang',
|
||||
'system_notice.release_notes.headline': 'Empat hal yang kini dilakukan TREK sendiri.',
|
||||
'system_notice.release_notes.headline': 'TREK terbesar sejauh ini.',
|
||||
'system_notice.release_notes.intro':
|
||||
'API tempat sendiri, perjalanan darat yang direncanakan dari awal sampai akhir, riwayat lokasi di tangan Anda, dan dokumen Anda tersinkron.',
|
||||
'Tur untuk setiap hari di alam terbuka, studio untuk buku foto Anda, asisten yang merencanakan bersama Anda, dan bantuan di setiap layar.',
|
||||
'system_notice.release_notes.features_label': 'Sorotan utama',
|
||||
'system_notice.release_notes.features_aside': 'Masih banyak lagi',
|
||||
'system_notice.release_notes.feature_places_title': 'API Places TREK',
|
||||
'system_notice.release_notes.feature_places_body':
|
||||
'Perencana perjalanan open source pertama dengan API tempat sendiri. 73,6 juta tempat, tanpa kunci, tanpa kuota.',
|
||||
'system_notice.release_notes.feature_roadtrip_title': 'Addon Perjalanan Darat',
|
||||
'system_notice.release_notes.feature_roadtrip_body':
|
||||
'Merencanakan perjalanan sendiri: rute, jarak, jam, dan perhentian. Nonaktif sampai admin mengaktifkannya.',
|
||||
'system_notice.release_notes.feature_dawarich_title': 'Integrasi Dawarich',
|
||||
'system_notice.release_notes.feature_dawarich_body':
|
||||
'Alternatif self-hosted untuk Google Timeline, kini bisa dibaca langsung di TREK. TREK hanya membaca, tidak lebih.',
|
||||
'system_notice.release_notes.feature_tours_title': 'Tur',
|
||||
'system_notice.release_notes.feature_tours_body':
|
||||
'Pendakian, bersepeda, lari, berkuda, mendayung, jalan kaki di kota, dan hari bermain ski, dengan profil ketinggian, tingkat kesulitan, dan terbang lintas 3D.',
|
||||
'system_notice.release_notes.feature_studio_title': 'TREK Studio', // same-as-en
|
||||
'system_notice.release_notes.feature_studio_body':
|
||||
'Mengubah perjalanan menjadi buku foto yang tertata: 17 tema, 34 jenis huruf, 160 stiker, dan tata letak otomatis dalam satu klik.',
|
||||
'system_notice.release_notes.feature_assistant_title': 'Asisten AI',
|
||||
'system_notice.release_notes.feature_assistant_body':
|
||||
'Merencanakan bersama Anda di perjalanan nyata Anda, dengan akun AI yang sudah Anda miliki. Setiap perubahan tampil lebih dulu dan bisa dibatalkan.',
|
||||
'system_notice.release_notes.feature_help_title': 'Bantuan di setiap layar, dalam 27 bahasa',
|
||||
'system_notice.release_notes.feature_help_body':
|
||||
'Sekitar 370 panduan langkah demi langkah dengan 1.600 gambar, tepat di tempat Anda membutuhkannya. Baru: Azerbaijan, Estonia, Slowakia, dan Thai.',
|
||||
'system_notice.release_notes.footnote': 'Ditambah deretan panjang perubahan kecil di bagian TREK lainnya.',
|
||||
'system_notice.release_notes.notes_label': 'Catatan rilis',
|
||||
'system_notice.release_notes.note_eyebrow': 'Catatan dari maintainer',
|
||||
@@ -88,8 +91,5 @@ const system_notice: TranslationStrings = {
|
||||
'Jika TREK sudah mendapat tempat dalam perjalananmu, traktir saya secangkir kopi dan bantu rilis berikutnya terus hadir.',
|
||||
'system_notice.release_notes.cta_bmc': 'Traktir aku kopi',
|
||||
'system_notice.release_notes.cta_kofi': 'Dukung di Ko-fi',
|
||||
'system_notice.release_notes.feature_docsync_title': 'Sinkronisasi Dokumen',
|
||||
'system_notice.release_notes.feature_docsync_body':
|
||||
'Paperless-ngx, Papra, Nextcloud, OpenCloud, dan Synology Drive. Dokumen perjalanan mengalir dua arah dengan penyimpanan yang sudah Anda jalankan.',
|
||||
};
|
||||
export default system_notice;
|
||||
|
||||
@@ -55,18 +55,21 @@ const system_notice: TranslationStrings = {
|
||||
'system_notice.v3014_whitespace_collision.body':
|
||||
"L'aggiornamento 3.0.14 ha rilevato uno o più conflitti di nome utente o e-mail causati da spazi iniziali o finali nei valori memorizzati. Gli account interessati sono stati rinominati automaticamente. Controlla i log del server per le righe che iniziano con **[migration] WHITESPACE COLLISION** per identificare quali account richiedono revisione.",
|
||||
'system_notice.release_notes.eyebrow': 'Aggiornato',
|
||||
'system_notice.release_notes.headline': 'Quattro cose che TREK ora fa da solo.',
|
||||
'system_notice.release_notes.headline': 'Il TREK più grande di sempre.',
|
||||
'system_notice.release_notes.features_label': 'I protagonisti',
|
||||
'system_notice.release_notes.features_aside': 'E non è tutto',
|
||||
'system_notice.release_notes.feature_places_title': 'TREK Places API', // same-as-en
|
||||
'system_notice.release_notes.feature_places_body':
|
||||
'Il primo pianificatore di viaggi open source con una propria API dei luoghi. 73,6 milioni di luoghi, senza chiave, senza quota.',
|
||||
'system_notice.release_notes.feature_roadtrip_title': 'Addon Viaggio su strada',
|
||||
'system_notice.release_notes.feature_roadtrip_body':
|
||||
'Pianifica il viaggio da solo: percorso, distanza, ore e tappe. Spento finché un admin non lo attiva.',
|
||||
'system_notice.release_notes.feature_dawarich_title': 'Integrazione Dawarich',
|
||||
'system_notice.release_notes.feature_dawarich_body':
|
||||
'L’alternativa self-hosted a Google Timeline, ora consultabile dentro TREK. TREK legge, e si limita a leggere.',
|
||||
'system_notice.release_notes.feature_tours_title': 'Escursioni',
|
||||
'system_notice.release_notes.feature_tours_body':
|
||||
'Trekking, bici, corsa, uscite a cavallo, pagaia, passeggiate in città e giornate sugli sci, con profilo altimetrico, gradi di difficoltà e sorvolo in 3D.',
|
||||
'system_notice.release_notes.feature_studio_title': 'TREK Studio', // same-as-en
|
||||
'system_notice.release_notes.feature_studio_body':
|
||||
'Trasforma un viaggio in un fotolibro impaginato: 17 temi, 34 caratteri, 160 sticker e impaginazione automatica con un clic.',
|
||||
'system_notice.release_notes.feature_assistant_title': 'Assistente IA',
|
||||
'system_notice.release_notes.feature_assistant_body':
|
||||
'Pianifica con te sui tuoi viaggi reali, con l’account IA che hai già. Ogni modifica la vedi prima e puoi annullarla.',
|
||||
'system_notice.release_notes.feature_help_title': 'Aiuto in ogni schermata, in 27 lingue',
|
||||
'system_notice.release_notes.feature_help_body':
|
||||
'Circa 370 guide passo passo con 1.600 immagini, proprio dove servono. Novità: azero, estone, slovacco e thailandese.',
|
||||
'system_notice.release_notes.footnote': 'E in più una lunga lista di piccole modifiche in tutto il resto di TREK.',
|
||||
'system_notice.release_notes.notes_label': 'Note di rilascio',
|
||||
'system_notice.release_notes.note_eyebrow': 'Una nota dal maintainer',
|
||||
@@ -87,9 +90,6 @@ const system_notice: TranslationStrings = {
|
||||
'system_notice.release_notes.cta_bmc': 'Offrimi un caffè',
|
||||
'system_notice.release_notes.cta_kofi': 'Supporta su Ko-fi',
|
||||
'system_notice.release_notes.intro':
|
||||
'Una propria API dei luoghi, viaggi on the road pianificati dall’inizio alla fine, la tua cronologia delle posizioni nelle tue mani, e i tuoi documenti sincronizzati.',
|
||||
'system_notice.release_notes.feature_docsync_title': 'Sincronizzazione documenti',
|
||||
'system_notice.release_notes.feature_docsync_body':
|
||||
'Paperless-ngx, Papra, Nextcloud, OpenCloud e Synology Drive. I documenti di un viaggio viaggiano in entrambe le direzioni con l’archivio che già usi.',
|
||||
'Escursioni per ogni giornata all’aperto, uno studio per i tuoi fotolibri, un assistente che pianifica con te e aiuto in ogni schermata.',
|
||||
};
|
||||
export default system_notice;
|
||||
|
||||
@@ -53,20 +53,23 @@ const system_notice: TranslationStrings = {
|
||||
'system_notice.pager.goto': '通知{n}へ',
|
||||
'system_notice.pager.position': '{total}件中{current}件目',
|
||||
'system_notice.release_notes.eyebrow': 'アップデート完了',
|
||||
'system_notice.release_notes.headline': 'TREKが自分でできるようになった4つのこと。',
|
||||
'system_notice.release_notes.headline': 'これまでで最大の TREK。',
|
||||
'system_notice.release_notes.intro':
|
||||
'独自の場所API、最初から最後まで計画できるロードトリップ、あなたの手に戻る位置情報の履歴、そして同期される書類。',
|
||||
'あらゆるアウトドアの一日のためのツアー、フォトブックのためのスタジオ、一緒に計画するアシスタント、そしてすべての画面にヘルプ。',
|
||||
'system_notice.release_notes.features_label': '今回の目玉',
|
||||
'system_notice.release_notes.features_aside': 'ほんの一部です',
|
||||
'system_notice.release_notes.feature_places_title': 'TREK Places API',
|
||||
'system_notice.release_notes.feature_places_body':
|
||||
'独自の場所APIを持つ初のオープンソース旅行プランナー。7,360万件の場所、キー不要、制限なし。',
|
||||
'system_notice.release_notes.feature_roadtrip_title': 'ロードトリップアドオン',
|
||||
'system_notice.release_notes.feature_roadtrip_body':
|
||||
'走行を自動で計画:ルート、距離、時間、停車地。管理者が有効にするまではオフ。',
|
||||
'system_notice.release_notes.feature_dawarich_title': 'Dawarich連携',
|
||||
'system_notice.release_notes.feature_dawarich_body':
|
||||
'Google Timelineのセルフホスト版とも言えるDawarichを、TREKの中で読めるようになりました。TREKは読み取るだけで、それ以上のことはしません。',
|
||||
'system_notice.release_notes.feature_tours_title': 'ツアー',
|
||||
'system_notice.release_notes.feature_tours_body':
|
||||
'ハイキング、サイクリング、ラン、乗馬、パドル、街歩き、スキーの日まで。標高プロファイル、難易度、3D フライオーバー付き。',
|
||||
'system_notice.release_notes.feature_studio_title': 'TREK Studio', // same-as-en
|
||||
'system_notice.release_notes.feature_studio_body':
|
||||
'旅をデザインされたフォトブックに。17 のテーマ、34 の書体、160 のステッカー、ワンクリックの自動レイアウト。',
|
||||
'system_notice.release_notes.feature_assistant_title': 'AI アシスタント',
|
||||
'system_notice.release_notes.feature_assistant_body':
|
||||
'お手持ちの AI アカウントで、実際の旅を一緒に計画します。変更はすべて先に確認でき、元に戻せます。',
|
||||
'system_notice.release_notes.feature_help_title': 'すべての画面にヘルプ、27 言語で',
|
||||
'system_notice.release_notes.feature_help_body':
|
||||
'約 370 のステップごとのガイドと 1,600 枚の画像を、必要な場所に。アゼルバイジャン語、エストニア語、スロバキア語、タイ語が加わりました。',
|
||||
'system_notice.release_notes.footnote': 'ほかにも、TREKのあちこちに細かな変更がたくさん入っています。',
|
||||
'system_notice.release_notes.notes_label': 'リリースノート',
|
||||
'system_notice.release_notes.note_eyebrow': '開発者より',
|
||||
@@ -86,8 +89,5 @@ const system_notice: TranslationStrings = {
|
||||
'TREKがあなたの旅に欠かせないものになっていたら、コーヒーを一杯おごってください。次のリリースを届ける力になります。',
|
||||
'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':
|
||||
'Paperless-ngx、Papra、Nextcloud、OpenCloud、Synology Drive。旅程の書類が、すでに運用しているストレージと双方向で同期されます。',
|
||||
};
|
||||
export default system_notice;
|
||||
|
||||
@@ -55,20 +55,23 @@ const system_notice: TranslationStrings = {
|
||||
'system_notice.pager.goto': '{n}번 공지로 이동',
|
||||
'system_notice.pager.position': '공지 {current}/{total}',
|
||||
'system_notice.release_notes.eyebrow': '업데이트 완료',
|
||||
'system_notice.release_notes.headline': 'TREK가 이제 스스로 해내는 네 가지.',
|
||||
'system_notice.release_notes.headline': '지금까지 가장 큰 TREK.',
|
||||
'system_notice.release_notes.intro':
|
||||
'자체 장소 API, 처음부터 끝까지 계획되는 로드트립, 내 손에 있는 위치 기록, 그리고 동기화되는 문서.',
|
||||
'모든 야외 일정을 위한 투어, 포토북을 위한 스튜디오, 함께 계획하는 어시스턴트, 그리고 모든 화면의 도움말.',
|
||||
'system_notice.release_notes.features_label': '이번 릴리스의 주역',
|
||||
'system_notice.release_notes.features_aside': '이게 전부는 아닙니다',
|
||||
'system_notice.release_notes.feature_places_title': 'TREK Places API',
|
||||
'system_notice.release_notes.feature_places_body':
|
||||
'자체 장소 API를 갖춘 최초의 오픈소스 여행 플래너. 7,360만 개 장소, 키 없음, 할당량 없음.',
|
||||
'system_notice.release_notes.feature_roadtrip_title': '로드트립 애드온',
|
||||
'system_notice.release_notes.feature_roadtrip_body':
|
||||
'주행을 스스로 계획합니다: 경로, 거리, 시간, 정차지. 관리자가 켤 때까지 꺼져 있습니다.',
|
||||
'system_notice.release_notes.feature_dawarich_title': 'Dawarich 연동',
|
||||
'system_notice.release_notes.feature_dawarich_body':
|
||||
'Google Timeline의 자체 호스팅 대안인 Dawarich를 이제 TREK 안에서 읽을 수 있습니다. TREK은 읽기만, 오직 읽기만 합니다.',
|
||||
'system_notice.release_notes.feature_tours_title': '투어',
|
||||
'system_notice.release_notes.feature_tours_body':
|
||||
'하이킹, 자전거, 러닝, 승마, 패들링, 도시 산책, 스키까지. 고도 프로필, 난이도 등급, 3D 플라이오버와 함께.',
|
||||
'system_notice.release_notes.feature_studio_title': 'TREK Studio', // same-as-en
|
||||
'system_notice.release_notes.feature_studio_body':
|
||||
'여행을 디자인된 포토북으로. 17가지 테마, 34가지 서체, 160개의 스티커, 클릭 한 번의 자동 레이아웃.',
|
||||
'system_notice.release_notes.feature_assistant_title': 'AI 어시스턴트',
|
||||
'system_notice.release_notes.feature_assistant_body':
|
||||
'이미 가진 AI 계정으로 실제 여행을 함께 계획합니다. 모든 변경 사항을 먼저 확인하고 되돌릴 수 있습니다.',
|
||||
'system_notice.release_notes.feature_help_title': '모든 화면에 도움말, 27개 언어로',
|
||||
'system_notice.release_notes.feature_help_body':
|
||||
'약 370개의 단계별 가이드와 1,600장의 이미지를 필요한 바로 그곳에서. 아제르바이잔어, 에스토니아어, 슬로바키아어, 태국어가 새로 추가되었습니다.',
|
||||
'system_notice.release_notes.footnote': '그 밖에도 TREK 곳곳에 작은 변경이 많이 담겨 있습니다.',
|
||||
'system_notice.release_notes.notes_label': '릴리스 노트',
|
||||
'system_notice.release_notes.note_eyebrow': '개발자의 한마디',
|
||||
@@ -88,8 +91,5 @@ const system_notice: TranslationStrings = {
|
||||
'TREK이 여러분의 여행에 자리를 잡았다면, 커피 한 잔으로 다음 릴리스가 계속 나올 수 있게 도와주세요.',
|
||||
'system_notice.release_notes.cta_bmc': 'Buy me a coffee',
|
||||
'system_notice.release_notes.cta_kofi': 'Ko-fi에서 후원하기',
|
||||
'system_notice.release_notes.feature_docsync_title': '문서 동기화',
|
||||
'system_notice.release_notes.feature_docsync_body':
|
||||
'Paperless-ngx, Papra, Nextcloud, OpenCloud, Synology Drive. 여행의 문서가 이미 운영 중인 저장소와 양방향으로 흐릅니다.',
|
||||
};
|
||||
export default system_notice;
|
||||
|
||||
@@ -56,20 +56,23 @@ const system_notice: TranslationStrings = {
|
||||
'De 3.0.14-upgrade heeft één of meer conflicten in gebruikersnaam of e-mailadres gedetecteerd, veroorzaakt door spaties aan het begin of einde van opgeslagen waarden. Getroffen accounts zijn automatisch hernoemd. Controleer de serverlogboeken op regels die beginnen met **[migration] WHITESPACE COLLISION** om te achterhalen welke accounts moeten worden beoordeeld.',
|
||||
// The release modal. One stable set of keys: each big release swaps the copy in place.
|
||||
'system_notice.release_notes.eyebrow': 'Update geïnstalleerd',
|
||||
'system_notice.release_notes.headline': 'Vier dingen die TREK nu zelf doet.',
|
||||
'system_notice.release_notes.headline': 'De grootste TREK tot nu toe.',
|
||||
'system_notice.release_notes.intro':
|
||||
'Een eigen plaatsen-API, roadtrips van begin tot eind gepland, je locatiegeschiedenis in eigen hand, en je documenten gesynchroniseerd.',
|
||||
'Tochten voor elke dag buiten, een studio voor je fotoboeken, een assistent die met je meeplant en hulp op elk scherm.',
|
||||
'system_notice.release_notes.features_label': 'De hoogtepunten',
|
||||
'system_notice.release_notes.features_aside': 'Lang niet alles',
|
||||
'system_notice.release_notes.feature_places_title': 'TREK Places API', // same-as-en
|
||||
'system_notice.release_notes.feature_places_body':
|
||||
'De eerste open source reisplanner met een eigen plaatsen-API. 73,6 miljoen plaatsen, geen sleutel, geen quotum.',
|
||||
'system_notice.release_notes.feature_roadtrip_title': 'Roadtrip-addon',
|
||||
'system_notice.release_notes.feature_roadtrip_body':
|
||||
'Plant de rit zelf: route, afstand, uren en stops. Uit totdat een admin het inschakelt.',
|
||||
'system_notice.release_notes.feature_dawarich_title': 'Dawarich-integratie',
|
||||
'system_notice.release_notes.feature_dawarich_body':
|
||||
'Het zelfgehoste antwoord op Google Timeline, nu rechtstreeks leesbaar in TREK. TREK leest, en alleen dat.',
|
||||
'system_notice.release_notes.feature_tours_title': 'Tochten',
|
||||
'system_notice.release_notes.feature_tours_body':
|
||||
'Wandelingen, fietstochten, hardlooprondes, ritten te paard, peddeltochten, stadswandelingen en skidagen, met hoogteprofiel, moeilijkheidsgraden en een 3D-vlucht.',
|
||||
'system_notice.release_notes.feature_studio_title': 'TREK Studio', // same-as-en
|
||||
'system_notice.release_notes.feature_studio_body':
|
||||
'Maakt van een reis een ontworpen fotoboek: 17 thema’s, 34 lettertypen, 160 stickers en automatische opmaak in één klik.',
|
||||
'system_notice.release_notes.feature_assistant_title': 'AI-assistent',
|
||||
'system_notice.release_notes.feature_assistant_body':
|
||||
'Plant met je mee in je echte reizen, met het AI-account dat je al hebt. Elke wijziging zie je eerst en kun je ongedaan maken.',
|
||||
'system_notice.release_notes.feature_help_title': 'Hulp op elk scherm, in 27 talen',
|
||||
'system_notice.release_notes.feature_help_body':
|
||||
'Zo’n 370 stapsgewijze handleidingen met 1.600 afbeeldingen, precies waar je ze nodig hebt. Nieuw: Azerbeidzjaans, Ests, Slowaaks en Thai.',
|
||||
'system_notice.release_notes.footnote': 'En daarnaast een lange lijst kleinere wijzigingen in de rest van TREK.',
|
||||
'system_notice.release_notes.notes_label': 'Release notes',
|
||||
'system_notice.release_notes.note_eyebrow': 'Een woord van de maintainer',
|
||||
@@ -89,8 +92,5 @@ const system_notice: TranslationStrings = {
|
||||
'Als TREK een plek in je reizen heeft verdiend, trakteer me dan op een kopje koffie en help zo de volgende release op weg.',
|
||||
'system_notice.release_notes.cta_bmc': 'Buy me a coffee',
|
||||
'system_notice.release_notes.cta_kofi': 'Steun op Ko-fi',
|
||||
'system_notice.release_notes.feature_docsync_title': 'Documentsynchronisatie',
|
||||
'system_notice.release_notes.feature_docsync_body':
|
||||
'Paperless-ngx, Papra, Nextcloud, OpenCloud en Synology Drive. De documenten van een reis stromen in beide richtingen met de opslag die je al draait.',
|
||||
};
|
||||
export default system_notice;
|
||||
|
||||
@@ -56,20 +56,23 @@ const system_notice: TranslationStrings = {
|
||||
'Aktualizacja 3.0.14 wykryła jeden lub więcej konfliktów nazwy użytkownika lub adresu e-mail spowodowanych spacjami na początku lub końcu przechowywanych wartości. Dotknięte konta zostały automatycznie przemianowane. Sprawdź logi serwera pod kątem wierszy zaczynających się od **[migration] WHITESPACE COLLISION**, aby zidentyfikować konta wymagające przeglądu.',
|
||||
// The release modal. One stable set of keys: each big release swaps the copy in place.
|
||||
'system_notice.release_notes.eyebrow': 'Aktualizacja zainstalowana',
|
||||
'system_notice.release_notes.headline': 'Cztery rzeczy, które TREK robi teraz sam.',
|
||||
'system_notice.release_notes.headline': 'Największy TREK w historii.',
|
||||
'system_notice.release_notes.intro':
|
||||
'Własne API miejsc, podróże samochodem zaplanowane od A do Z, historia lokalizacji w Twoich rękach, i dokumenty w synchronizacji.',
|
||||
'Wycieczki na każdy dzień w terenie, studio do Twoich fotoksiążek, asystent, który planuje razem z Tobą, i pomoc na każdym ekranie.',
|
||||
'system_notice.release_notes.features_label': 'Najważniejsze nowości',
|
||||
'system_notice.release_notes.features_aside': 'To nie wszystko',
|
||||
'system_notice.release_notes.feature_places_title': 'TREK Places API', // same-as-en
|
||||
'system_notice.release_notes.feature_places_body':
|
||||
'Pierwszy open source planer podróży z własnym API miejsc. 73,6 mln miejsc, bez klucza, bez limitu.',
|
||||
'system_notice.release_notes.feature_roadtrip_title': 'Dodatek Podróż samochodowa',
|
||||
'system_notice.release_notes.feature_roadtrip_body':
|
||||
'Sam planuje jazdę: trasę, dystans, godziny i przystanki. Wyłączone, dopóki admin tego nie włączy.',
|
||||
'system_notice.release_notes.feature_dawarich_title': 'Integracja z Dawarich',
|
||||
'system_notice.release_notes.feature_dawarich_body':
|
||||
'Samodzielnie hostowana odpowiedź na Google Timeline, teraz dostępna w TREK. TREK tylko odczytuje, nic więcej.',
|
||||
'system_notice.release_notes.feature_tours_title': 'Wycieczki',
|
||||
'system_notice.release_notes.feature_tours_body':
|
||||
'Wędrówki, rower, bieganie, jazda konna, kajaki, spacery po mieście i dni na nartach, z profilem wysokości, stopniami trudności i przelotem 3D.',
|
||||
'system_notice.release_notes.feature_studio_title': 'TREK Studio', // same-as-en
|
||||
'system_notice.release_notes.feature_studio_body':
|
||||
'Zamienia podróż w zaprojektowaną fotoksiążkę: 17 motywów, 34 kroje pisma, 160 naklejek i automatyczny układ jednym kliknięciem.',
|
||||
'system_notice.release_notes.feature_assistant_title': 'Asystent AI',
|
||||
'system_notice.release_notes.feature_assistant_body':
|
||||
'Planuje z Tobą Twoje prawdziwe podróże, przez konto AI, które już masz. Każdą zmianę widzisz najpierw i możesz ją cofnąć.',
|
||||
'system_notice.release_notes.feature_help_title': 'Pomoc na każdym ekranie, w 27 językach',
|
||||
'system_notice.release_notes.feature_help_body':
|
||||
'Około 370 przewodników krok po kroku z 1600 ilustracjami, dokładnie tam, gdzie ich potrzebujesz. Nowe języki: azerski, estoński, słowacki i tajski.',
|
||||
'system_notice.release_notes.footnote': 'Do tego długa lista mniejszych zmian w całej reszcie TREK.',
|
||||
'system_notice.release_notes.notes_label': 'Notatki do wydania',
|
||||
'system_notice.release_notes.note_eyebrow': 'Słowo od autora',
|
||||
@@ -89,8 +92,5 @@ const system_notice: TranslationStrings = {
|
||||
'Jeśli TREK zasłużył na miejsce w Twoich podróżach, postaw mi kawę i pomóż, żeby powstało kolejne wydanie.',
|
||||
'system_notice.release_notes.cta_bmc': 'Postaw mi kawę',
|
||||
'system_notice.release_notes.cta_kofi': 'Wesprzyj na Ko-fi',
|
||||
'system_notice.release_notes.feature_docsync_title': 'Synchronizacja dokumentów',
|
||||
'system_notice.release_notes.feature_docsync_body':
|
||||
'Paperless-ngx, Papra, Nextcloud, OpenCloud i Synology Drive. Dokumenty podróży przepływają w obie strony z magazynem, który już prowadzisz.',
|
||||
};
|
||||
export default system_notice;
|
||||
|
||||
@@ -56,20 +56,23 @@ const system_notice: TranslationStrings = {
|
||||
'Обновление 3.0.14 обнаружило один или несколько конфликтов имён пользователей или адресов электронной почты, вызванных ведущими или завершающими пробелами в сохранённых значениях. Затронутые учётные записи были автоматически переименованы. Проверьте логи сервера на строки, начинающиеся с **[migration] WHITESPACE COLLISION**, чтобы определить учётные записи, требующие проверки.',
|
||||
// The release modal. One stable set of keys: each big release swaps the copy in place.
|
||||
'system_notice.release_notes.eyebrow': 'Обновление установлено',
|
||||
'system_notice.release_notes.headline': 'Четыре вещи, которые TREK теперь делает сам.',
|
||||
'system_notice.release_notes.headline': 'Самый большой TREK на сегодня.',
|
||||
'system_notice.release_notes.intro':
|
||||
'Собственный API мест, автопутешествия от начала до конца, ваша история местоположений в ваших руках, и ваши документы в синхронизации.',
|
||||
'Туры на любой день на природе, студия для ваших фотокниг, ассистент, который планирует вместе с вами, и справка на каждом экране.',
|
||||
'system_notice.release_notes.features_label': 'Главные новинки',
|
||||
'system_notice.release_notes.features_aside': 'И это далеко не всё',
|
||||
'system_notice.release_notes.feature_places_title': 'TREK Places API',
|
||||
'system_notice.release_notes.feature_places_body':
|
||||
'Первый планировщик путешествий с открытым кодом и собственным API мест. 73,6 миллиона мест, без ключа и квот.',
|
||||
'system_notice.release_notes.feature_roadtrip_title': 'Дополнение «Автопутешествие»',
|
||||
'system_notice.release_notes.feature_roadtrip_body':
|
||||
'Планирует поездку сам: маршрут, расстояние, часы и остановки. Выключено, пока админ не включит.',
|
||||
'system_notice.release_notes.feature_dawarich_title': 'Интеграция с Dawarich',
|
||||
'system_notice.release_notes.feature_dawarich_body':
|
||||
'Альтернатива Google Timeline на вашем собственном сервере, и теперь её данные видны прямо в TREK. TREK читает, и только читает.',
|
||||
'system_notice.release_notes.feature_tours_title': 'Туры',
|
||||
'system_notice.release_notes.feature_tours_body':
|
||||
'Походы, велотуры, пробежки, конные прогулки, сплавы, прогулки по городу и дни на лыжах, с профилем высот, категориями сложности и 3D-облётом.',
|
||||
'system_notice.release_notes.feature_studio_title': 'TREK Studio', // same-as-en
|
||||
'system_notice.release_notes.feature_studio_body':
|
||||
'Превращает путешествие в оформленную фотокнигу: 17 тем, 34 шрифта, 160 стикеров и автоматическая вёрстка одним нажатием.',
|
||||
'system_notice.release_notes.feature_assistant_title': 'ИИ-ассистент',
|
||||
'system_notice.release_notes.feature_assistant_body':
|
||||
'Планирует вместе с вами ваши настоящие поездки через уже имеющийся у вас аккаунт ИИ. Каждое изменение вы видите заранее и можете отменить.',
|
||||
'system_notice.release_notes.feature_help_title': 'Справка на каждом экране, на 27 языках',
|
||||
'system_notice.release_notes.feature_help_body':
|
||||
'Около 370 пошаговых инструкций и 1600 иллюстраций прямо там, где они нужны. Новые языки: азербайджанский, эстонский, словацкий и тайский.',
|
||||
'system_notice.release_notes.footnote': 'Плюс длинный список небольших изменений во всём остальном TREK.',
|
||||
'system_notice.release_notes.notes_label': 'Заметки к релизу',
|
||||
'system_notice.release_notes.note_eyebrow': 'Слово от мейнтейнера',
|
||||
@@ -89,8 +92,5 @@ const system_notice: TranslationStrings = {
|
||||
'Если TREK заслужил место в ваших поездках, угостите меня кофе и помогите выпустить следующий релиз.',
|
||||
'system_notice.release_notes.cta_bmc': 'Buy me a coffee',
|
||||
'system_notice.release_notes.cta_kofi': 'Поддержать на Ko-fi',
|
||||
'system_notice.release_notes.feature_docsync_title': 'Синхронизация документов',
|
||||
'system_notice.release_notes.feature_docsync_body':
|
||||
'Paperless-ngx, Papra, Nextcloud, OpenCloud и Synology Drive. Документы поездки идут в обе стороны с хранилищем, которое у вас уже есть.',
|
||||
};
|
||||
export default system_notice;
|
||||
|
||||
@@ -52,20 +52,23 @@ const system_notice: TranslationStrings = {
|
||||
'system_notice.thank_you_support.cta_kofi': 'Podporiť na Ko-fi',
|
||||
// The release modal. One stable set of keys: each big release swaps the copy in place.
|
||||
'system_notice.release_notes.eyebrow': 'Aktualizácia nainštalovaná',
|
||||
'system_notice.release_notes.headline': 'Štyri veci, ktoré TREK teraz zvládne sám.',
|
||||
'system_notice.release_notes.headline': 'Najväčší TREK doteraz.',
|
||||
'system_notice.release_notes.intro':
|
||||
'Vlastné API miest, cesty autom naplánované od začiatku do konca, história polohy späť vo vašich rukách a vaše dokumenty v synchronizácii.',
|
||||
'Túry na každý deň vonku, štúdio pre vaše fotoknihy, asistent, ktorý plánuje s vami, a pomoc na každej obrazovke.',
|
||||
'system_notice.release_notes.features_label': 'Hlavné novinky',
|
||||
'system_notice.release_notes.features_aside': 'Zďaleka nie všetko',
|
||||
'system_notice.release_notes.feature_places_title': 'TREK Places API', // same-as-en
|
||||
'system_notice.release_notes.feature_places_body':
|
||||
'Prvý open source plánovač ciest s vlastným API miest. 73,6 milióna miest, bez kľúča, bez limitu.',
|
||||
'system_notice.release_notes.feature_roadtrip_title': 'Doplnok Cesta autom',
|
||||
'system_notice.release_notes.feature_roadtrip_body':
|
||||
'Naplánuje jazdu sám: trasu, vzdialenosť, hodiny a zastávky. Vypnuté, kým to správca nezapne.',
|
||||
'system_notice.release_notes.feature_dawarich_title': 'Integrácia Dawarich',
|
||||
'system_notice.release_notes.feature_dawarich_body':
|
||||
'Self-hosted odpoveď na Google Timeline, teraz čitateľná priamo v TREK. TREK číta, a len číta.',
|
||||
'system_notice.release_notes.feature_tours_title': 'Túry',
|
||||
'system_notice.release_notes.feature_tours_body':
|
||||
'Turistika, cyklotúry, behy, vychádzky na koni, pádlovanie, prechádzky mestom a dni na lyžiach, s výškovým profilom, obťažnosťou a 3D preletom.',
|
||||
'system_notice.release_notes.feature_studio_title': 'TREK Studio', // same-as-en
|
||||
'system_notice.release_notes.feature_studio_body':
|
||||
'Premení cestu na navrhnutú fotoknihu: 17 motívov, 34 písiem, 160 nálepiek a automatické rozloženie jedným kliknutím.',
|
||||
'system_notice.release_notes.feature_assistant_title': 'AI asistent',
|
||||
'system_notice.release_notes.feature_assistant_body':
|
||||
'Plánuje s vami vaše skutočné cesty cez účet AI, ktorý už máte. Každú zmenu najprv uvidíte a môžete ju vrátiť.',
|
||||
'system_notice.release_notes.feature_help_title': 'Pomoc na každej obrazovke, v 27 jazykoch',
|
||||
'system_notice.release_notes.feature_help_body':
|
||||
'Približne 370 návodov krok za krokom s 1 600 obrázkami, presne tam, kde ich potrebujete. Novinka: azerbajdžančina, estónčina, slovenčina a thajčina.',
|
||||
'system_notice.release_notes.footnote': 'A k tomu dlhý zoznam menších zmien naprieč zvyškom TREK.',
|
||||
'system_notice.release_notes.notes_label': 'Poznámky k vydaniu',
|
||||
'system_notice.release_notes.note_eyebrow': 'Slovo od správcu',
|
||||
@@ -90,8 +93,5 @@ const system_notice: TranslationStrings = {
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': 'Prejsť na oznámenie {n}',
|
||||
'system_notice.pager.position': 'Oznámenie {current} z {total}',
|
||||
'system_notice.release_notes.feature_docsync_title': 'Synchronizácia dokumentov',
|
||||
'system_notice.release_notes.feature_docsync_body':
|
||||
'Paperless-ngx, Papra, Nextcloud, OpenCloud a Synology Drive. Dokumenty cesty prúdia obomi smermi s úložiskom, ktoré už prevádzkujete.',
|
||||
};
|
||||
export default system_notice;
|
||||
|
||||
@@ -56,20 +56,23 @@ const system_notice: TranslationStrings = {
|
||||
'system_notice.pager.position': 'Meddlenade {current} av {total}',
|
||||
// The release modal. One stable set of keys: each big release swaps the copy in place.
|
||||
'system_notice.release_notes.eyebrow': 'Uppdatering klar',
|
||||
'system_notice.release_notes.headline': 'Fyra saker TREK nu gör på egen hand.',
|
||||
'system_notice.release_notes.headline': 'Den största TREK hittills.',
|
||||
'system_notice.release_notes.intro':
|
||||
'Ett eget plats-API, bilresor planerade från början till slut, din platshistorik i dina händer, och dina dokument i synk.',
|
||||
'Turer för varje dag ute, en studio för dina fotoböcker, en assistent som planerar med dig och hjälp på varje skärm.',
|
||||
'system_notice.release_notes.features_label': 'Höjdpunkterna',
|
||||
'system_notice.release_notes.features_aside': 'Långt ifrån allt',
|
||||
'system_notice.release_notes.feature_places_title': 'TREK Places API', // same-as-en
|
||||
'system_notice.release_notes.feature_places_body':
|
||||
'Den första reseplaneraren med öppen källkod och eget plats-API. 73,6 miljoner platser, ingen nyckel, ingen kvot.',
|
||||
'system_notice.release_notes.feature_roadtrip_title': 'Tillägget Bilresa',
|
||||
'system_notice.release_notes.feature_roadtrip_body':
|
||||
'Planerar körningen själv: rutt, sträcka, timmar och stopp. Av tills en admin slår på det.',
|
||||
'system_notice.release_notes.feature_dawarich_title': 'Dawarich-integration',
|
||||
'system_notice.release_notes.feature_dawarich_body':
|
||||
'Det självhostade svaret på Google Timeline, nu läsbart direkt i TREK. TREK läser, och bara läser.',
|
||||
'system_notice.release_notes.feature_tours_title': 'Turer',
|
||||
'system_notice.release_notes.feature_tours_body':
|
||||
'Vandringar, cykelturer, löprundor, ridturer, paddling, stadspromenader och skiddagar, med höjdprofil, svårighetsgrader och 3D-överflygning.',
|
||||
'system_notice.release_notes.feature_studio_title': 'TREK Studio', // same-as-en
|
||||
'system_notice.release_notes.feature_studio_body':
|
||||
'Gör en resa till en formgiven fotobok: 17 teman, 34 typsnitt, 160 klistermärken och automatisk layout med ett klick.',
|
||||
'system_notice.release_notes.feature_assistant_title': 'AI-assistent',
|
||||
'system_notice.release_notes.feature_assistant_body':
|
||||
'Planerar med dig i dina riktiga resor, med det AI-konto du redan har. Varje ändring ser du först och kan ångra.',
|
||||
'system_notice.release_notes.feature_help_title': 'Hjälp på varje skärm, på 27 språk',
|
||||
'system_notice.release_notes.feature_help_body':
|
||||
'Omkring 370 steg-för-steg-guider med 1 600 bilder, precis där du behöver dem. Nytt: azerbajdzjanska, estniska, slovakiska och thailändska.',
|
||||
'system_notice.release_notes.footnote': 'Dessutom en lång rad mindre ändringar i resten av TREK.',
|
||||
'system_notice.release_notes.notes_label': 'Versionsnyheter',
|
||||
'system_notice.release_notes.note_eyebrow': 'Ett ord från utvecklaren',
|
||||
@@ -89,8 +92,5 @@ const system_notice: TranslationStrings = {
|
||||
'Om TREK har förtjänat en plats på dina resor, så bjud mig gärna på en kaffe och hjälp till att nästa utgåva blir av.',
|
||||
'system_notice.release_notes.cta_bmc': 'Buy me a coffee',
|
||||
'system_notice.release_notes.cta_kofi': 'Stöd på Ko-fi',
|
||||
'system_notice.release_notes.feature_docsync_title': 'Dokumentsynk',
|
||||
'system_notice.release_notes.feature_docsync_body':
|
||||
'Paperless-ngx, Papra, Nextcloud, OpenCloud och Synology Drive. En resas dokument flödar åt båda håll med lagringen du redan kör.',
|
||||
};
|
||||
export default system_notice;
|
||||
|
||||
@@ -55,24 +55,24 @@ const system_notice: TranslationStrings = {
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': 'ไปที่ประกาศ {n}',
|
||||
'system_notice.pager.position': 'ประกาศ {current} จาก {total}',
|
||||
'system_notice.release_notes.feature_docsync_title': 'ซิงค์เอกสาร',
|
||||
'system_notice.release_notes.feature_docsync_body':
|
||||
'Paperless-ngx, Papra, Nextcloud, OpenCloud และ Synology Drive เอกสารของการเดินทางซิงค์ได้ทั้งสองทางกับที่จัดเก็บที่คุณใช้อยู่แล้ว',
|
||||
'system_notice.release_notes.eyebrow': 'ติดตั้งอัปเดตแล้ว',
|
||||
'system_notice.release_notes.headline': 'สามสิ่งที่ TREK ทำได้ด้วยตัวเองแล้ว',
|
||||
'system_notice.release_notes.headline': 'TREK ครั้งใหญ่ที่สุดเท่าที่เคยมีมา',
|
||||
'system_notice.release_notes.intro':
|
||||
'API สถานที่ของตัวเอง การวางแผนโรดทริปตั้งแต่ต้นจนจบ และประวัติตำแหน่งของคุณกลับมาอยู่ในมือคุณ',
|
||||
'ทัวร์สำหรับทุกวันกลางแจ้ง สตูดิโอสำหรับโฟโต้บุ๊กของคุณ ผู้ช่วยที่วางแผนไปกับคุณ และความช่วยเหลือในทุกหน้าจอ',
|
||||
'system_notice.release_notes.features_label': 'ไฮไลต์สำคัญ',
|
||||
'system_notice.release_notes.features_aside': 'ไกลจากทุกสิ่ง',
|
||||
'system_notice.release_notes.feature_places_title': 'TREK Places API',
|
||||
'system_notice.release_notes.feature_places_body':
|
||||
'โปรแกรมวางแผนท่องเที่ยวโอเพนซอร์สรายแรกที่โฮสต์ API สถานที่ของตัวเอง 73.6 ล้านสถานที่ สร้างใหม่ทุกเดือน ไม่ต้องมีคีย์ ไม่มีโควตา',
|
||||
'system_notice.release_notes.feature_roadtrip_title': 'ส่วนเสริมโรดทริป',
|
||||
'system_notice.release_notes.feature_roadtrip_body':
|
||||
'โหมดโรดทริปวางแผนการขับรถเอง: เส้นทาง ระยะทาง เวลา และจุดแวะ เป็นส่วนเสริมที่ปิดไว้จนกว่าผู้ดูแลจะเปิดใช้',
|
||||
'system_notice.release_notes.feature_dawarich_title': 'การเชื่อมต่อ Dawarich',
|
||||
'system_notice.release_notes.feature_dawarich_body':
|
||||
'คำตอบแบบโฮสต์เองแทน Google Timeline ซึ่งอ่านได้แล้วใน TREK TREK อ่านอย่างเดียว และจะไม่เขียนข้อมูลกลับ',
|
||||
'system_notice.release_notes.feature_tours_title': 'ทัวร์',
|
||||
'system_notice.release_notes.feature_tours_body':
|
||||
'เดินป่า ปั่นจักรยาน วิ่ง ขี่ม้า พายเรือ เดินชมเมือง และวันเล่นสกี พร้อมกราฟความสูง ระดับความยาก และการบินชมแบบ 3D',
|
||||
'system_notice.release_notes.feature_studio_title': 'TREK Studio', // same-as-en
|
||||
'system_notice.release_notes.feature_studio_body':
|
||||
'เปลี่ยนการเดินทางให้เป็นโฟโต้บุ๊กที่ออกแบบอย่างสวยงาม: 17 ธีม 34 แบบอักษร 160 สติกเกอร์ และจัดหน้าอัตโนมัติในคลิกเดียว',
|
||||
'system_notice.release_notes.feature_assistant_title': 'ผู้ช่วย AI',
|
||||
'system_notice.release_notes.feature_assistant_body':
|
||||
'วางแผนทริปจริงของคุณไปพร้อมกับคุณ ด้วยบัญชี AI ที่คุณมีอยู่แล้ว ทุกการเปลี่ยนแปลงแสดงให้เห็นก่อนและย้อนกลับได้',
|
||||
'system_notice.release_notes.feature_help_title': 'ความช่วยเหลือในทุกหน้าจอ ใน 27 ภาษา',
|
||||
'system_notice.release_notes.feature_help_body':
|
||||
'คู่มือทีละขั้นตอนราว 370 รายการ พร้อมภาพ 1,600 ภาพ อยู่ตรงที่คุณต้องการ ภาษาใหม่: อาเซอร์ไบจาน เอสโตเนีย สโลวัก และไทย',
|
||||
'system_notice.release_notes.footnote': 'รวมถึงรายการการเปลี่ยนแปลงเล็ก ๆ อีกยาวเหยียดทั่วทั้ง TREK',
|
||||
'system_notice.release_notes.notes_label': 'บันทึกประจำรุ่น',
|
||||
'system_notice.release_notes.note_eyebrow': 'ข้อความจากผู้ดูแลโครงการ',
|
||||
|
||||
@@ -57,15 +57,18 @@ const system_notice: TranslationStrings = {
|
||||
'system_notice.release_notes.eyebrow': 'Güncelleme yüklendi',
|
||||
'system_notice.release_notes.features_label': 'Öne çıkanlar',
|
||||
'system_notice.release_notes.features_aside': 'Hepsi bu kadar değil',
|
||||
'system_notice.release_notes.feature_places_title': 'TREK Places API', // same-as-en
|
||||
'system_notice.release_notes.feature_places_body':
|
||||
'Kendi yer API’sine sahip ilk açık kaynak seyahat planlayıcısı. 73,6 milyon yer, anahtar yok, kota yok.',
|
||||
'system_notice.release_notes.feature_roadtrip_title': 'Yol gezisi eklentisi',
|
||||
'system_notice.release_notes.feature_roadtrip_body':
|
||||
'Sürüşü kendi planlar: rota, mesafe, saatler ve duraklar. Bir yönetici açana kadar kapalı.',
|
||||
'system_notice.release_notes.feature_dawarich_title': 'Dawarich entegrasyonu',
|
||||
'system_notice.release_notes.feature_dawarich_body':
|
||||
'Google Timeline’ın kendi sunucunda çalışan alternatifi, artık TREK içinden okunabiliyor. TREK okur, yalnızca okur.',
|
||||
'system_notice.release_notes.feature_tours_title': 'Turlar',
|
||||
'system_notice.release_notes.feature_tours_body':
|
||||
'Yürüyüşler, bisiklet turları, koşular, at binme, kürek, şehir yürüyüşleri ve kayak günleri; yükseklik profili, zorluk dereceleri ve 3B uçuşla.',
|
||||
'system_notice.release_notes.feature_studio_title': 'TREK Studio', // same-as-en
|
||||
'system_notice.release_notes.feature_studio_body':
|
||||
'Bir yolculuğu tasarlanmış bir fotoğraf kitabına dönüştürür: 17 tema, 34 yazı tipi, 160 çıkartma ve tek tıkla otomatik yerleşim.',
|
||||
'system_notice.release_notes.feature_assistant_title': 'Yapay zekâ asistanı',
|
||||
'system_notice.release_notes.feature_assistant_body':
|
||||
'Zaten sahip olduğunuz yapay zekâ hesabıyla gerçek seyahatlerinizi sizinle birlikte planlar. Her değişikliği önce görür, isterseniz geri alırsınız.',
|
||||
'system_notice.release_notes.feature_help_title': 'Her ekranda yardım, 27 dilde',
|
||||
'system_notice.release_notes.feature_help_body':
|
||||
'Yaklaşık 370 adım adım rehber ve 1.600 görsel, tam ihtiyaç duyduğunuz yerde. Yeni: Azerbaycanca, Estonca, Slovakça ve Tayca.',
|
||||
'system_notice.release_notes.footnote':
|
||||
"Ayrıca TREK'in geri kalanına yayılmış, daha küçük değişikliklerden oluşan uzun bir liste var.",
|
||||
'system_notice.release_notes.notes_label': 'Sürüm notları',
|
||||
@@ -86,11 +89,8 @@ const system_notice: TranslationStrings = {
|
||||
'TREK seyahatlerinde kendine bir yer edindiyse, bana bir kahve ısmarla ve bir sonraki sürümün gelmesine yardım et.',
|
||||
'system_notice.release_notes.cta_bmc': 'Bana bir kahve ısmarla',
|
||||
'system_notice.release_notes.cta_kofi': "Ko-fi'de Destek Ol",
|
||||
'system_notice.release_notes.headline': 'TREK’in artık kendi başına yaptığı dört şey.',
|
||||
'system_notice.release_notes.headline': 'Şimdiye kadarki en büyük TREK.',
|
||||
'system_notice.release_notes.intro':
|
||||
'Kendi yer API’si, baştan sona planlanan yol gezileri, konum geçmişiniz sizin elinizde, ve belgeleriniz eşitlenmiş.',
|
||||
'system_notice.release_notes.feature_docsync_title': 'Belge Eşitleme',
|
||||
'system_notice.release_notes.feature_docsync_body':
|
||||
'Paperless-ngx, Papra, Nextcloud, OpenCloud ve Synology Drive. Bir seyahatin belgeleri, zaten kullandığınız depoyla iki yönlü akar.',
|
||||
'Açık havadaki her gün için turlar, fotoğraf kitaplarınız için bir stüdyo, sizinle birlikte plan yapan bir asistan ve her ekranda yardım.',
|
||||
};
|
||||
export default system_notice;
|
||||
|
||||
@@ -56,20 +56,23 @@ const system_notice: TranslationStrings = {
|
||||
'Оновлення 3.0.14 виявило один або кілька конфліктів імен користувачів або електронних адрес, спричинених початковими або кінцевими пробілами в збережених облікових записах. Уражені облікові записи було автоматично перейменовано. Перевірте журнали сервера на рядки, що починаються з **[migration] WHITESPACE COLLISION**, щоб визначити, які облікові записи потребують перевірки.',
|
||||
// The release modal. One stable set of keys: each big release swaps the copy in place.
|
||||
'system_notice.release_notes.eyebrow': 'Оновлення встановлено',
|
||||
'system_notice.release_notes.headline': 'Чотири речі, які TREK тепер робить сам.',
|
||||
'system_notice.release_notes.headline': 'Найбільший TREK на сьогодні.',
|
||||
'system_notice.release_notes.intro':
|
||||
'Власний API місць, автоподорожі від початку до кінця, ваша історія розташувань у ваших руках, і ваші документи в синхронізації.',
|
||||
'Тури на будь-який день просто неба, студія для ваших фотокниг, асистент, що планує разом з вами, і довідка на кожному екрані.',
|
||||
'system_notice.release_notes.features_label': 'Головні новинки',
|
||||
'system_notice.release_notes.features_aside': 'І це далеко не все',
|
||||
'system_notice.release_notes.feature_places_title': 'TREK Places API',
|
||||
'system_notice.release_notes.feature_places_body':
|
||||
'Перший планувальник подорожей з відкритим кодом і власним API місць. 73,6 мільйона місць, без ключа й квот.',
|
||||
'system_notice.release_notes.feature_roadtrip_title': 'Доповнення «Автоподорож»',
|
||||
'system_notice.release_notes.feature_roadtrip_body':
|
||||
'Планує поїздку сам: маршрут, відстань, години й зупинки. Вимкнено, поки адмін не ввімкне.',
|
||||
'system_notice.release_notes.feature_dawarich_title': 'Інтеграція з Dawarich',
|
||||
'system_notice.release_notes.feature_dawarich_body':
|
||||
'Self-hosted альтернатива Google Timeline, яку тепер можна читати прямо в TREK. TREK читає, і тільки читає.',
|
||||
'system_notice.release_notes.feature_tours_title': 'Тури',
|
||||
'system_notice.release_notes.feature_tours_body':
|
||||
'Походи, велотури, пробіжки, кінні прогулянки, сплави, прогулянки містом і дні на лижах, з профілем висот, категоріями складності та 3D-обльотом.',
|
||||
'system_notice.release_notes.feature_studio_title': 'TREK Studio', // same-as-en
|
||||
'system_notice.release_notes.feature_studio_body':
|
||||
'Перетворює подорож на оформлену фотокнигу: 17 тем, 34 шрифти, 160 стікерів і автоматична верстка одним натисканням.',
|
||||
'system_notice.release_notes.feature_assistant_title': 'ШІ-асистент',
|
||||
'system_notice.release_notes.feature_assistant_body':
|
||||
'Планує разом з вами ваші справжні подорожі через обліковий запис ШІ, який у вас уже є. Кожну зміну ви бачите заздалегідь і можете скасувати.',
|
||||
'system_notice.release_notes.feature_help_title': 'Довідка на кожному екрані, 27 мовами',
|
||||
'system_notice.release_notes.feature_help_body':
|
||||
'Близько 370 покрокових інструкцій і 1600 ілюстрацій саме там, де вони потрібні. Нові мови: азербайджанська, естонська, словацька й тайська.',
|
||||
'system_notice.release_notes.footnote': 'А також довгий список дрібніших змін по всьому TREK.',
|
||||
'system_notice.release_notes.notes_label': 'Нотатки до релізу',
|
||||
'system_notice.release_notes.note_eyebrow': 'Слово від мейнтейнера',
|
||||
@@ -89,8 +92,5 @@ const system_notice: TranslationStrings = {
|
||||
'Якщо TREK заслужив місце у ваших подорожах, пригостіть мене кавою і допоможіть наблизити наступний реліз.',
|
||||
'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':
|
||||
'Paperless-ngx, Papra, Nextcloud, OpenCloud і Synology Drive. Документи подорожі рухаються в обидва боки зі сховищем, яке ви вже використовуєте.',
|
||||
};
|
||||
export default system_notice;
|
||||
|
||||
@@ -57,20 +57,23 @@ const system_notice: TranslationStrings = {
|
||||
'system_notice.pager.position': 'Thông báo {current} của {total}',
|
||||
// The release modal. One stable set of keys: each big release swaps the copy in place.
|
||||
'system_notice.release_notes.eyebrow': 'Đã cập nhật',
|
||||
'system_notice.release_notes.headline': 'Bốn điều TREK giờ tự làm được.',
|
||||
'system_notice.release_notes.headline': 'Phiên bản TREK lớn nhất từ trước đến nay.',
|
||||
'system_notice.release_notes.intro':
|
||||
'API địa điểm riêng, chuyến đi đường bộ được lên kế hoạch từ đầu đến cuối, lịch sử vị trí trong tay bạn, và tài liệu được đồng bộ.',
|
||||
'Chuyến tham quan cho mọi ngày ngoài trời, studio cho sách ảnh của bạn, trợ lý cùng bạn lên kế hoạch và trợ giúp trên mọi màn hình.',
|
||||
'system_notice.release_notes.features_label': 'Những điểm nhấn',
|
||||
'system_notice.release_notes.features_aside': 'Chưa phải tất cả',
|
||||
'system_notice.release_notes.feature_places_title': 'TREK Places API', // same-as-en
|
||||
'system_notice.release_notes.feature_places_body':
|
||||
'Trình lập kế hoạch du lịch mã nguồn mở đầu tiên có API địa điểm riêng. 73,6 triệu địa điểm, không cần khóa, không giới hạn.',
|
||||
'system_notice.release_notes.feature_roadtrip_title': 'Tiện ích Chuyến đi đường bộ',
|
||||
'system_notice.release_notes.feature_roadtrip_body':
|
||||
'Tự lên kế hoạch chuyến lái: tuyến đường, quãng đường, số giờ và điểm dừng. Tắt cho đến khi quản trị viên bật.',
|
||||
'system_notice.release_notes.feature_dawarich_title': 'Tích hợp Dawarich',
|
||||
'system_notice.release_notes.feature_dawarich_body':
|
||||
'Lựa chọn tự lưu trữ thay cho Google Timeline, giờ đã đọc được ngay trong TREK. TREK chỉ đọc, và chỉ đọc mà thôi.',
|
||||
'system_notice.release_notes.feature_tours_title': 'Chuyến tham quan',
|
||||
'system_notice.release_notes.feature_tours_body':
|
||||
'Đi bộ đường dài, đạp xe, chạy bộ, cưỡi ngựa, chèo thuyền, dạo phố và ngày trượt tuyết, với biểu đồ độ cao, cấp độ khó và bay lượn 3D.',
|
||||
'system_notice.release_notes.feature_studio_title': 'TREK Studio', // same-as-en
|
||||
'system_notice.release_notes.feature_studio_body':
|
||||
'Biến chuyến đi thành một cuốn sách ảnh được thiết kế: 17 chủ đề, 34 kiểu chữ, 160 nhãn dán và tự động dàn trang chỉ với một cú nhấp.',
|
||||
'system_notice.release_notes.feature_assistant_title': 'Trợ lý AI',
|
||||
'system_notice.release_notes.feature_assistant_body':
|
||||
'Cùng bạn lên kế hoạch cho những chuyến đi thật, bằng tài khoản AI bạn đã có. Mọi thay đổi đều hiện ra trước và có thể hoàn tác.',
|
||||
'system_notice.release_notes.feature_help_title': 'Trợ giúp trên mọi màn hình, bằng 27 ngôn ngữ',
|
||||
'system_notice.release_notes.feature_help_body':
|
||||
'Khoảng 370 hướng dẫn từng bước cùng 1.600 hình ảnh, ngay nơi bạn cần. Mới: tiếng Azerbaijan, Estonia, Slovakia và Thái.',
|
||||
'system_notice.release_notes.footnote':
|
||||
'Và còn cả một danh sách dài những thay đổi nhỏ hơn ở khắp phần còn lại của TREK.',
|
||||
'system_notice.release_notes.notes_label': 'Ghi chú phát hành',
|
||||
@@ -91,8 +94,5 @@ const system_notice: TranslationStrings = {
|
||||
'Nếu TREK đã có một chỗ trong những chuyến đi của bạn, hãy mời tôi một ly cà phê để bản phát hành tiếp theo tiếp tục ra đời.',
|
||||
'system_notice.release_notes.cta_bmc': 'Mời tôi một ly cà phê',
|
||||
'system_notice.release_notes.cta_kofi': 'Hỗ trợ trên Ko-fi',
|
||||
'system_notice.release_notes.feature_docsync_title': 'Đồng bộ tài liệu',
|
||||
'system_notice.release_notes.feature_docsync_body':
|
||||
'Paperless-ngx, Papra, Nextcloud, OpenCloud và Synology Drive. Tài liệu của chuyến đi luân chuyển hai chiều với kho lưu trữ bạn đang dùng.',
|
||||
};
|
||||
export default system_notice;
|
||||
|
||||
@@ -55,19 +55,23 @@ const system_notice: TranslationStrings = {
|
||||
'3.0.14 版本升級偵測到一個或多個由儲存帳戶中前後空白字元引發的使用者名稱或電子郵件衝突。受影響的帳戶已自動重新命名。請檢查伺服器日誌中以 **[migration] WHITESPACE COLLISION** 開頭的行,以確認哪些帳戶需要審查。',
|
||||
// The release modal. One stable set of keys: each big release swaps the copy in place.
|
||||
'system_notice.release_notes.eyebrow': '更新已安裝',
|
||||
'system_notice.release_notes.headline': 'TREK 現在能自己完成的四件事。',
|
||||
'system_notice.release_notes.headline': '迄今最大的 TREK。',
|
||||
'system_notice.release_notes.intro':
|
||||
'自有的地點 API、從頭到尾規劃的公路旅行、掌握在你手中的位置記錄,以及保持同步的文件。',
|
||||
'適合每個戶外日子的遊程、為你的相簿而生的工作室、與你一起規劃的助理,以及每個畫面上的說明。',
|
||||
'system_notice.release_notes.features_label': '本次重頭戲',
|
||||
'system_notice.release_notes.features_aside': '遠不止這些',
|
||||
'system_notice.release_notes.feature_places_title': 'TREK Places API',
|
||||
'system_notice.release_notes.feature_places_body':
|
||||
'首個擁有自有地點 API 的開源旅行規劃器。7360 萬個地點,無需金鑰,沒有配額。',
|
||||
'system_notice.release_notes.feature_roadtrip_title': '公路旅行擴充套件',
|
||||
'system_notice.release_notes.feature_roadtrip_body': '自動規劃行車:路線、距離、時長和停靠點。管理員開啟前預設關閉。',
|
||||
'system_notice.release_notes.feature_dawarich_title': 'Dawarich 整合',
|
||||
'system_notice.release_notes.feature_dawarich_body':
|
||||
'Google Timeline 的自架替代方案,現在可以直接在 TREK 裡檢視。TREK 只讀取,也僅止於讀取。',
|
||||
'system_notice.release_notes.feature_tours_title': '遊程',
|
||||
'system_notice.release_notes.feature_tours_body':
|
||||
'健行、單車、跑步、騎馬、划槳、城市散步和滑雪日,附海拔剖面、難度等級和 3D 飛覽。',
|
||||
'system_notice.release_notes.feature_studio_title': 'TREK Studio', // same-as-en
|
||||
'system_notice.release_notes.feature_studio_body':
|
||||
'把一趟旅程變成精心設計的相簿:17 種主題、34 款字體、160 張貼紙,一鍵自動排版。',
|
||||
'system_notice.release_notes.feature_assistant_title': 'AI 助理',
|
||||
'system_notice.release_notes.feature_assistant_body':
|
||||
'用你已有的 AI 帳號,在你真實的旅行中與你一起規劃。每項變更都會先顯示給你,而且可以復原。',
|
||||
'system_notice.release_notes.feature_help_title': '每個畫面都有說明,支援 27 種語言',
|
||||
'system_notice.release_notes.feature_help_body':
|
||||
'約 370 篇逐步指南、1,600 張圖片,就在你需要的地方。新增亞塞拜然語、愛沙尼亞語、斯洛伐克語和泰語。',
|
||||
'system_notice.release_notes.footnote': '除此之外,TREK 的其他部分還有一長串小改動。',
|
||||
'system_notice.release_notes.notes_label': '版本說明',
|
||||
'system_notice.release_notes.note_eyebrow': '來自維護者的話',
|
||||
@@ -86,8 +90,5 @@ const system_notice: TranslationStrings = {
|
||||
'如果它已經在你的旅行中佔有一席之地,請我喝杯咖啡,幫我把下一個版本繼續做出來吧。',
|
||||
'system_notice.release_notes.cta_bmc': 'Buy me a coffee',
|
||||
'system_notice.release_notes.cta_kofi': '在 Ko-fi 上支持我',
|
||||
'system_notice.release_notes.feature_docsync_title': '文件同步',
|
||||
'system_notice.release_notes.feature_docsync_body':
|
||||
'Paperless-ngx、Papra、Nextcloud、OpenCloud 和 Synology Drive。行程的文件與你已在運行的儲存雙向流動。',
|
||||
};
|
||||
export default system_notice;
|
||||
|
||||
@@ -54,19 +54,23 @@ const system_notice: TranslationStrings = {
|
||||
'3.0.14 版本升级检测到一个或多个由存储账户中首尾空白字符引发的用户名或邮箱冲突。受影响的账户已自动重命名。请检查服务器日志中以 **[migration] WHITESPACE COLLISION** 开头的行,以确认哪些账户需要审查。',
|
||||
// The release modal. One stable set of keys: each big release swaps the copy in place.
|
||||
'system_notice.release_notes.eyebrow': '更新已安装',
|
||||
'system_notice.release_notes.headline': 'TREK 现在能自己完成的四件事。',
|
||||
'system_notice.release_notes.headline': '迄今最大的 TREK。',
|
||||
'system_notice.release_notes.intro':
|
||||
'自有的地点 API、从头到尾规划的公路旅行、掌握在你手中的位置记录,以及保持同步的文档。',
|
||||
'适合每一个户外日子的游览、为你的相册而生的工作室、与你一起规划的助手,以及每个页面上的帮助。',
|
||||
'system_notice.release_notes.features_label': '本次重头戏',
|
||||
'system_notice.release_notes.features_aside': '远不止这些',
|
||||
'system_notice.release_notes.feature_places_title': 'TREK Places API',
|
||||
'system_notice.release_notes.feature_places_body':
|
||||
'首个拥有自有地点 API 的开源旅行规划器。7360 万个地点,无需密钥,没有配额。',
|
||||
'system_notice.release_notes.feature_roadtrip_title': '公路旅行扩展',
|
||||
'system_notice.release_notes.feature_roadtrip_body': '自动规划行车:路线、距离、时长和停靠点。管理员开启前默认关闭。',
|
||||
'system_notice.release_notes.feature_dawarich_title': 'Dawarich 集成',
|
||||
'system_notice.release_notes.feature_dawarich_body':
|
||||
'Google Timeline 的自托管替代方案,现在可以直接在 TREK 里查看。TREK 只读取,仅此而已。',
|
||||
'system_notice.release_notes.feature_tours_title': '游览',
|
||||
'system_notice.release_notes.feature_tours_body':
|
||||
'徒步、骑行、跑步、骑马、划桨、城市漫步和滑雪日,附带海拔剖面、难度等级和 3D 飞览。',
|
||||
'system_notice.release_notes.feature_studio_title': 'TREK Studio', // same-as-en
|
||||
'system_notice.release_notes.feature_studio_body':
|
||||
'把一段旅程变成精心设计的相册:17 种主题、34 款字体、160 枚贴纸,一键自动排版。',
|
||||
'system_notice.release_notes.feature_assistant_title': 'AI 助手',
|
||||
'system_notice.release_notes.feature_assistant_body':
|
||||
'用你已有的 AI 账户,在你真实的旅行中与你一起规划。每项更改都会先展示给你,并且可以撤销。',
|
||||
'system_notice.release_notes.feature_help_title': '每个页面都有帮助,支持 27 种语言',
|
||||
'system_notice.release_notes.feature_help_body':
|
||||
'约 370 篇分步指南、1,600 张配图,就在你需要的地方。新增阿塞拜疆语、爱沙尼亚语、斯洛伐克语和泰语。',
|
||||
'system_notice.release_notes.footnote': '除此之外,TREK 的其他部分还有一长串小改动。',
|
||||
'system_notice.release_notes.notes_label': '更新说明',
|
||||
'system_notice.release_notes.note_eyebrow': '来自维护者的话',
|
||||
@@ -84,8 +88,5 @@ const system_notice: TranslationStrings = {
|
||||
'如果它已经在你的旅行中占有一席之地,请我喝杯咖啡,帮我把下一个版本继续做出来吧。',
|
||||
'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':
|
||||
'Paperless-ngx、Papra、Nextcloud、OpenCloud 和 Synology Drive。行程的文档与你已在运行的存储双向流动。',
|
||||
};
|
||||
export default system_notice;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { haversineMetres } from '../geo/haversine';
|
||||
import { computeTourMetrics } from './tours.metrics';
|
||||
import { computeTourMetrics, elevationGainLoss } from './tours.metrics';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -35,6 +35,24 @@ describe('computeTourMetrics', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('elevationGainLoss', () => {
|
||||
it('leaves out jitter that turns back before it reaches the noise floor', () => {
|
||||
expect(elevationGainLoss([2, 6, 1, 5, 0, 4, 2])).toEqual({ gain: 0, loss: 0 });
|
||||
});
|
||||
|
||||
it('counts a climb made of small steps in full once it passes the floor', () => {
|
||||
expect(elevationGainLoss([100, 102, 104, 106, 108, 110, 112, 105])).toEqual({ gain: 12, loss: 7 });
|
||||
});
|
||||
|
||||
it('keeps large moves exactly as they are', () => {
|
||||
expect(elevationGainLoss([1000, 1100, 1050])).toEqual({ gain: 100, loss: 50 });
|
||||
});
|
||||
|
||||
it('reads nothing into an empty run', () => {
|
||||
expect(elevationGainLoss([])).toEqual({ gain: 0, loss: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('haversineMetres', () => {
|
||||
it('stays finite for antipodal points', () => {
|
||||
expect(haversineMetres(0, 0, 0, 180)).toBeCloseTo(Math.PI * 6371000, 0);
|
||||
|
||||
@@ -6,14 +6,44 @@ export type GeometryPoint = [number, number] | [number, number, number];
|
||||
export interface TourMetrics {
|
||||
/** Sum of segment haversine distances, in km. */
|
||||
distanceKm: number;
|
||||
/** Sum of positive elevation deltas, in metres. Null when the track carries no elevation. */
|
||||
/** Ascent in metres, jitter under ELEVATION_NOISE_M left out. Null when the track carries no elevation. */
|
||||
elevationGainM: number | null;
|
||||
/** Sum of negative elevation deltas (as a positive number), in metres. Null when no elevation. */
|
||||
/** Descent in metres, counted the same way. Null when no elevation. */
|
||||
elevationLossM: number | null;
|
||||
/** Whether every point in the geometry carried a 3rd (elevation) value. */
|
||||
hasElevation: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* How far, in metres, a height has to move from the last one that counted before
|
||||
* it counts as climbing or descending. Elevation models jitter a few metres from
|
||||
* one point to the next, and summed as it came, that jitter was the whole ascent
|
||||
* of a flat city walk: 121 m across Venice, whose highest point is 16 m. Measured
|
||||
* this way a real climb still adds up in full; only moves smaller than this that
|
||||
* turn back are left out.
|
||||
*/
|
||||
const ELEVATION_NOISE_M = 5;
|
||||
|
||||
/**
|
||||
* Ascent and descent over a run of heights, in metres, with the noise above
|
||||
* filtered out. The server's stored tour figures and the client's live analysis
|
||||
* both count with this, so a tour reads the same in the list and in its detail.
|
||||
*/
|
||||
export function elevationGainLoss(heights: readonly number[]): { gain: number; loss: number } {
|
||||
let gain = 0;
|
||||
let loss = 0;
|
||||
let [ref] = heights;
|
||||
if (ref === undefined) return { gain, loss };
|
||||
for (const height of heights) {
|
||||
const delta = height - ref;
|
||||
if (delta >= ELEVATION_NOISE_M) gain += delta;
|
||||
else if (delta <= -ELEVATION_NOISE_M) loss -= delta;
|
||||
else continue;
|
||||
ref = height;
|
||||
}
|
||||
return { gain, loss };
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives distance/elevation from a parsed `route_geometry` array. Never throws:
|
||||
* a track with fewer than 2 points, or with missing/mixed elevation, still
|
||||
@@ -30,23 +60,17 @@ export function computeTourMetrics(points: readonly GeometryPoint[]): TourMetric
|
||||
const hasElevation = points.every((p) => p.length === 3 && Number.isFinite(p[2]));
|
||||
|
||||
let distanceKm = 0;
|
||||
let gain = 0;
|
||||
let loss = 0;
|
||||
let prev = first;
|
||||
for (const point of rest) {
|
||||
distanceKm += haversineMetres(prev[0], prev[1], point[0], point[1]) / 1000;
|
||||
if (hasElevation) {
|
||||
const delta = (point[2] as number) - (prev[2] as number);
|
||||
if (delta > 0) gain += delta;
|
||||
else loss += -delta;
|
||||
}
|
||||
prev = point;
|
||||
}
|
||||
const elevation = hasElevation ? elevationGainLoss(points.map((point) => point[2] as number)) : null;
|
||||
|
||||
return {
|
||||
distanceKm,
|
||||
elevationGainM: hasElevation ? gain : null,
|
||||
elevationLossM: hasElevation ? loss : null,
|
||||
elevationGainM: elevation ? elevation.gain : null,
|
||||
elevationLossM: elevation ? elevation.loss : null,
|
||||
hasElevation,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user