mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(client): show Dawarich stays, routes and marks where they belong
The trip planner and the journal get a "From Dawarich" list: the stays recorded for those dates, each one a row that can be dismissed or turned into a place or an entry after a review step that shows time, name, note and day. A place that came from a recording carries the Dawarich mark next to its name so its origin stays visible afterwards. The recorded route lies over the trip and journal maps as an overlay, coloured per day and cut along the reader's own timezone rather than UTC. It is fetched for the window on screen and never stored. The Atlas gets its own panel beside the map for the countries and cities the recordings prove, and the wish list is ticked off there, on the day of the visit, undoably. The phone has its own take on all of it, in the mobile shell's colours and with native sheets.
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="#3c83f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
class="lucide lucide-tower-control-icon lucide-tower-control">
|
||||
<path d="M18.2 12.27 20 6H4l1.8 6.27a1 1 0 0 0 .95.73h10.5a1 1 0 0 0 .96-.73Z" />
|
||||
<path d="M8 13v9" />
|
||||
<path d="M16 22v-9" />
|
||||
<path d="m9 6 1 7" />
|
||||
<path d="m15 6-1 7" />
|
||||
<path d="M12 6V2" />
|
||||
<path d="M13 2h-2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 495 B |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,83 @@
|
||||
import apiClient from './client'
|
||||
import type {
|
||||
DawarichAccept,
|
||||
DawarichAcceptResult,
|
||||
DawarichAtlasSuggestions,
|
||||
DawarichBucketScan,
|
||||
DawarichConnection,
|
||||
DawarichSettings,
|
||||
DawarichStatus,
|
||||
DawarichSuggestion,
|
||||
DawarichSuggestionList,
|
||||
DawarichTrack,
|
||||
} from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The Dawarich surface, in its own file rather than in the `client.ts` god
|
||||
* module. Types come from the shared contracts, so a server change that the
|
||||
* client has not caught up with is a typecheck failure rather than a runtime
|
||||
* surprise.
|
||||
*/
|
||||
export const dawarichApi = {
|
||||
// ── Connection ─────────────────────────────────────────────────────────────
|
||||
getSettings: (): Promise<DawarichConnection> =>
|
||||
apiClient.get('/integrations/dawarich/settings').then(r => r.data),
|
||||
saveSettings: (data: DawarichSettings): Promise<{
|
||||
success: boolean
|
||||
/** The server's own sentence, English — the fallback when the code below is unknown here. */
|
||||
warning?: string
|
||||
/** What the warning is about, so the UI can say it in the reader's language. */
|
||||
warningCode?: string
|
||||
/** The address it resolved to, for the private-IP warning. */
|
||||
warningIp?: string
|
||||
}> =>
|
||||
apiClient.put('/integrations/dawarich/settings', data).then(r => r.data),
|
||||
disconnect: (): Promise<{ success: boolean }> =>
|
||||
apiClient.delete('/integrations/dawarich/settings').then(r => r.data),
|
||||
test: (data: Partial<DawarichSettings>): Promise<DawarichStatus> =>
|
||||
apiClient.post('/integrations/dawarich/test', data).then(r => r.data),
|
||||
syncNow: (): Promise<{ state: string; created: number; updated: number; missing: number; alreadyRunning?: boolean }> =>
|
||||
apiClient.post('/integrations/dawarich/sync').then(r => r.data),
|
||||
|
||||
// ── Suggestions ────────────────────────────────────────────────────────────
|
||||
listSuggestions: (params?: { tripId?: number; state?: string }): Promise<DawarichSuggestionList> =>
|
||||
apiClient.get('/integrations/dawarich/suggestions', { params }).then(r => r.data),
|
||||
accept: (id: number, body: DawarichAccept): Promise<DawarichAcceptResult> =>
|
||||
apiClient.post(`/integrations/dawarich/suggestions/${id}/accept`, body).then(r => r.data),
|
||||
setState: (id: number, state: 'new' | 'dismissed'): Promise<DawarichSuggestion> =>
|
||||
apiClient.put(`/integrations/dawarich/suggestions/${id}/state`, { state }).then(r => r.data),
|
||||
|
||||
// ── Bucket list ────────────────────────────────────────────────────────────
|
||||
scanBucketList: (): Promise<DawarichBucketScan> =>
|
||||
apiClient.post('/integrations/dawarich/bucket-list/scan').then(r => r.data),
|
||||
confirmBucketVisits: (itemIds: number[], visitedAt?: string): Promise<{ updated: number }> =>
|
||||
apiClient.post('/integrations/dawarich/bucket-list/confirm', { itemIds, visitedAt }).then(r => r.data),
|
||||
clearBucketVisit: (itemId: number): Promise<{ success: boolean }> =>
|
||||
apiClient.delete(`/integrations/dawarich/bucket-list/${itemId}/visit`).then(r => r.data),
|
||||
|
||||
// ── Atlas ──────────────────────────────────────────────────────────────────
|
||||
atlasSuggestions: (from: string, to: string): Promise<DawarichAtlasSuggestions> =>
|
||||
apiClient.get('/integrations/dawarich/atlas/suggestions', { params: { from, to } }).then(r => r.data),
|
||||
acceptAtlasCountries: (countryCodes: string[]): Promise<{ marked: number }> =>
|
||||
apiClient.post('/integrations/dawarich/atlas/accept', { countryCodes }).then(r => r.data),
|
||||
|
||||
// ── Track overlay ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Recorded points are bare instants, so the server cannot know which day they
|
||||
// belong to without being told whose day is meant. The browser is the only
|
||||
// party that knows, so it says so — otherwise an evening east of UTC lands on
|
||||
// the wrong day of the trip.
|
||||
tripTrack: (tripId: number, params?: { from?: string; to?: string }, signal?: AbortSignal): Promise<DawarichTrack> =>
|
||||
apiClient
|
||||
.get(`/integrations/dawarich/trips/${tripId}/track`, { params: { ...params, offset: localOffsetMinutes() }, signal })
|
||||
.then(r => r.data),
|
||||
windowTrack: (from: string, to: string, signal?: AbortSignal): Promise<DawarichTrack> =>
|
||||
apiClient
|
||||
.get('/integrations/dawarich/track', { params: { from, to, offset: localOffsetMinutes() }, signal })
|
||||
.then(r => r.data),
|
||||
}
|
||||
|
||||
/** The reader's own UTC offset in minutes — +120 for Berlin in summer. */
|
||||
function localOffsetMinutes(): number {
|
||||
return -new Date().getTimezoneOffset()
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { useToast } from '../shared/Toast'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2 } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import AirTrailIcon from '../shared/AirTrailIcon'
|
||||
import AddonTile from './AddonTile'
|
||||
import AddonSubRow from './AddonSubRow'
|
||||
|
||||
@@ -15,6 +17,9 @@ import AddonSubRow from './AddonSubRow'
|
||||
// used to land on the fallback.
|
||||
const ICON_MAP = {
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark, Users, Sparkles,
|
||||
// Dawarich ships a brand mark rather than a lucide name, the same way the photo
|
||||
// providers below do. Keyed by the string in the addons table.
|
||||
Dawarich: DawarichIcon,
|
||||
}
|
||||
|
||||
function ImmichIcon({ size = 14 }: { size?: number }) {
|
||||
@@ -59,9 +64,20 @@ interface ProviderOption {
|
||||
interface AddonIconProps {
|
||||
name: string
|
||||
size?: number
|
||||
/** A switched-off addon greys its icon out, brand marks included. */
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
function AddonIcon({ name, size = 20 }: AddonIconProps) {
|
||||
function AddonIcon({ name, size = 20, enabled = true }: AddonIconProps) {
|
||||
// The brand marks are badges, not glyphs: they fill the framed slot instead of
|
||||
// sitting in the middle of it at glyph size, and they grey out when the addon
|
||||
// is off — the lucide glyphs get that from the slot's text colour, an <img>
|
||||
// has to be told.
|
||||
if (name === 'Dawarich') return <DawarichIcon fill muted={!enabled} />
|
||||
// 'Plane' is the icon string airtrail was seeded with, and INSERT OR IGNORE
|
||||
// means every existing install still carries it — so the brand is keyed on
|
||||
// that rather than on a new name no row would ever have.
|
||||
if (name === 'Plane') return <AirTrailIcon fill muted={!enabled} />
|
||||
const Icon = ICON_MAP[name] || Puzzle
|
||||
return <Icon size={size} />
|
||||
}
|
||||
@@ -212,7 +228,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
return (
|
||||
<AddonTile
|
||||
key={addon.id}
|
||||
icon={<AddonIcon name={addon.icon} size={18} />}
|
||||
icon={<AddonIcon name={addon.icon} size={18} enabled={addon.enabled} />}
|
||||
name={label.name}
|
||||
description={label.description}
|
||||
enabled={addon.enabled}
|
||||
|
||||
@@ -44,7 +44,9 @@ export default function AddonTile({
|
||||
>
|
||||
<div className="flex items-start gap-3 p-4">
|
||||
<div
|
||||
className={`grid h-10 w-10 shrink-0 place-items-center rounded-xl ${
|
||||
// overflow-hidden so a brand mark that fills the slot keeps the slot's
|
||||
// rounded corners instead of squaring them off.
|
||||
className={`grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-xl ${
|
||||
enabled ? 'border border-accent bg-accent-subtle text-accent-on' : 'bg-surface-tertiary text-content-faint'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useRef, type RefObject } from 'react'
|
||||
|
||||
export interface GlassGlare {
|
||||
/** Goes on the panel itself — the glare is measured against its box. */
|
||||
panelRef: RefObject<HTMLDivElement | null>
|
||||
/** The soft light under the cursor. */
|
||||
glareRef: RefObject<HTMLDivElement | null>
|
||||
/** The lit stretch of border around it. */
|
||||
borderGlareRef: RefObject<HTMLDivElement | null>
|
||||
onMouseMove: (e: { clientX: number; clientY: number }) => void
|
||||
onMouseLeave: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The Atlas panels' liquid-glass hover: a pool of light under the cursor and the
|
||||
* piece of border nearest to it lighting up.
|
||||
*
|
||||
* Extracted so a second panel beside the first one behaves identically. Two
|
||||
* hand-copied versions of a gradient are two versions that drift, and the whole
|
||||
* point of the effect is that the panels look like one material.
|
||||
*
|
||||
* Written straight to the DOM rather than through state: this runs on every
|
||||
* mousemove, and a re-render per frame would cost more than the effect is worth.
|
||||
*/
|
||||
export function useGlassGlare(dark: boolean): GlassGlare {
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const glareRef = useRef<HTMLDivElement>(null)
|
||||
const borderGlareRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const onMouseMove = (e: { clientX: number; clientY: number }): void => {
|
||||
if (!panelRef.current || !glareRef.current || !borderGlareRef.current) return
|
||||
const rect = panelRef.current.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const y = e.clientY - rect.top
|
||||
glareRef.current.style.background = `radial-gradient(circle 300px at ${x}px ${y}px, ${dark ? 'rgba(255,255,255,0.025)' : 'rgba(255,255,255,0.25)'} 0%, transparent 70%)`
|
||||
glareRef.current.style.opacity = '1'
|
||||
borderGlareRef.current.style.opacity = '1'
|
||||
borderGlareRef.current.style.maskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
|
||||
borderGlareRef.current.style.webkitMaskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
|
||||
}
|
||||
|
||||
const onMouseLeave = (): void => {
|
||||
if (glareRef.current) glareRef.current.style.opacity = '0'
|
||||
if (borderGlareRef.current) borderGlareRef.current.style.opacity = '0'
|
||||
}
|
||||
|
||||
return { panelRef, glareRef, borderGlareRef, onMouseMove, onMouseLeave }
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { Gauge, Hourglass, CircleDashed, Star } from 'lucide-react'
|
||||
import type { DawarichAccept, DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared'
|
||||
import { DAWARICH_BUCKET_MATCH_MIN_MINUTES, DAWARICH_BUCKET_MATCH_RADIUS_M } from '@trek/shared'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import Modal from '../shared/Modal'
|
||||
import MSheet from '../../mobile/components/MSheet'
|
||||
import { FormSheetFooter, FormSheetHeader } from '../../mobile/screens/trip/sheets/PlSheetChrome'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import DawarichBadge from './DawarichBadge'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
|
||||
export interface AcceptTargetOption {
|
||||
id: number
|
||||
label: string
|
||||
/** Secondary half of the label — a day's date beside its number. */
|
||||
badge?: string
|
||||
}
|
||||
|
||||
export interface DawarichAcceptDialogProps {
|
||||
suggestion: DawarichSuggestion | null
|
||||
target: DawarichSuggestionTarget
|
||||
/** Trips the user may write to, for the `place` target. */
|
||||
trips?: AcceptTargetOption[]
|
||||
/** Days of the chosen trip, resolved by the caller. */
|
||||
daysForTrip?: (tripId: number) => AcceptTargetOption[]
|
||||
/** Journeys the user may edit, for the `journal` target. */
|
||||
journals?: AcceptTargetOption[]
|
||||
busy?: boolean
|
||||
onCancel: () => void
|
||||
onConfirm: (body: DawarichAccept) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The review step: see what was recorded, correct it, then decide where it goes.
|
||||
*
|
||||
* The whole reason the integration is not a one-click import. A visit detector
|
||||
* is right most of the time and confidently wrong the rest, and the difference
|
||||
* between the two is a judgement only the traveller can make — so the values
|
||||
* are prefilled from the recording and every one of them is editable before
|
||||
* anything is written.
|
||||
*
|
||||
* What it does NOT do is offer to edit the recording. Dawarich owns that, and
|
||||
* a correction made here would be lost the next time the detector ran.
|
||||
*
|
||||
* Three targets, because `DawarichSuggestionTarget` has three. The trip and
|
||||
* journal rails open the first two; `bucket_list` is reached through MCP, where
|
||||
* an assistant can accept a stay against a wish. The branch is kept for that and
|
||||
* for the day an Atlas-side entry point wants it — the server has always been
|
||||
* able to do it, and a dialog that could only handle two of three targets would
|
||||
* be the thing that had to be fixed then.
|
||||
*/
|
||||
export default function DawarichAcceptDialog({
|
||||
suggestion,
|
||||
target,
|
||||
trips = [],
|
||||
daysForTrip,
|
||||
journals = [],
|
||||
busy = false,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: DawarichAcceptDialogProps): React.ReactElement | null {
|
||||
const { t, locale } = useTranslation()
|
||||
const phone = useIsPhone()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [date, setDate] = useState('')
|
||||
const [time, setTime] = useState('')
|
||||
const [endTime, setEndTime] = useState('')
|
||||
const [tripId, setTripId] = useState<number | ''>('')
|
||||
const [dayId, setDayId] = useState<number | ''>('')
|
||||
const [journalId, setJournalId] = useState<number | ''>('')
|
||||
// Re-seeded whenever a different suggestion opens the dialog, so the fields
|
||||
// always describe the row the user just pressed rather than the previous one.
|
||||
const [seededFor, setSeededFor] = useState<number | null>(null)
|
||||
|
||||
if (suggestion && seededFor !== suggestion.id) {
|
||||
setSeededFor(suggestion.id)
|
||||
setName(suggestion.name)
|
||||
setNotes('')
|
||||
setDate(suggestion.localDate)
|
||||
setTime(clockOf(suggestion.startedAt))
|
||||
setEndTime(clockOf(suggestion.endedAt))
|
||||
setTripId(suggestion.tripId ?? (trips[0]?.id ?? ''))
|
||||
setDayId('')
|
||||
setJournalId(journals[0]?.id ?? '')
|
||||
}
|
||||
|
||||
const days = useMemo(
|
||||
() => (typeof tripId === 'number' && daysForTrip ? daysForTrip(tripId) : []),
|
||||
[tripId, daysForTrip],
|
||||
)
|
||||
|
||||
if (!suggestion) return null
|
||||
|
||||
const missingTarget =
|
||||
(target === 'place' && typeof tripId !== 'number') ||
|
||||
(target === 'journal' && typeof journalId !== 'number') ||
|
||||
(target === 'bucket_list' && suggestion.matchedBucketListItemId === null)
|
||||
|
||||
const confirm = () => {
|
||||
const body: DawarichAccept = { target }
|
||||
if (target === 'place') {
|
||||
if (typeof tripId === 'number') body.tripId = tripId
|
||||
if (typeof dayId === 'number') body.dayId = dayId
|
||||
}
|
||||
if (target === 'journal' && typeof journalId === 'number') body.journalId = journalId
|
||||
if (target === 'bucket_list' && suggestion.matchedBucketListItemId !== null) {
|
||||
body.bucketListItemId = suggestion.matchedBucketListItemId
|
||||
}
|
||||
if (name.trim() && name.trim() !== suggestion.name) body.name = name.trim()
|
||||
if (notes.trim()) body.notes = notes.trim()
|
||||
if (date && date !== suggestion.localDate) body.date = date
|
||||
if (time) body.time = time
|
||||
if (endTime) body.endTime = endTime
|
||||
onConfirm(body)
|
||||
}
|
||||
|
||||
// The same fields in both shells — only the frame around them differs.
|
||||
const fields = (
|
||||
<div className="space-y-4">
|
||||
{/* What was recorded, before anything the user changed. Kept visible so a
|
||||
corrected field can be compared against the source rather than
|
||||
replacing it invisibly. */}
|
||||
{/* What Dawarich recorded, as facts rather than as a sentence with
|
||||
separators in it: a line for when, and a row of the same badges the
|
||||
review list uses so the dialog and the list read as one thing. */}
|
||||
<div className="rounded-xl border p-3 border-edge bg-surface-secondary">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex-shrink-0 overflow-hidden rounded-[6px]">
|
||||
<DawarichIcon size={20} />
|
||||
</span>
|
||||
<p className="min-w-0 flex-1 text-caption text-content">
|
||||
{t('dawarich.accept.recorded', {
|
||||
from: new Date(suggestion.startedAt).toLocaleString(locale),
|
||||
to: new Date(suggestion.endedAt).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1">
|
||||
<DawarichBadge icon={Hourglass} size="sm">
|
||||
{t('dawarich.accept.duration', { minutes: suggestion.durationMinutes })}
|
||||
</DawarichBadge>
|
||||
{/* The source's own verdict, shown here rather than on every row of
|
||||
the list: since Dawarich 1.12.0 "unconfirmed" is the normal state
|
||||
of a visit, so it says nothing in a list and something here, where
|
||||
somebody is deciding whether to trust this one. */}
|
||||
{suggestion.sourceStatus === 'suggested' && (
|
||||
<DawarichBadge icon={CircleDashed} size="sm">{t('dawarich.sourceStatus.suggested')}</DawarichBadge>
|
||||
)}
|
||||
{KNOWN_BANDS.includes(suggestion.confidenceBand ?? '') && (
|
||||
<DawarichBadge
|
||||
icon={Gauge}
|
||||
size="sm"
|
||||
tone={suggestion.confidenceBand === 'low' ? 'warning' : 'neutral'}
|
||||
>
|
||||
{t('dawarich.confidence.' + suggestion.confidenceBand)}
|
||||
</DawarichBadge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Why this stay is being offered against a wish at all. The row that
|
||||
led here shows the wish's name and nothing else, which leaves the
|
||||
reader to guess what a star beside a café has to do with a museum —
|
||||
it is the rule, and the rule is short enough to state. */}
|
||||
{target === 'bucket_list' && suggestion.matchedBucketListName && (
|
||||
<p className="flex items-start gap-1.5 text-caption text-content-muted">
|
||||
<Star className="w-3 h-3 flex-shrink-0 mt-0.5 text-accent-on" />
|
||||
<span>
|
||||
{t('dawarich.suggestions.matchesWish', { name: suggestion.matchedBucketListName })}
|
||||
{' — '}
|
||||
{t('dawarich.bucket.rule', {
|
||||
meters: DAWARICH_BUCKET_MATCH_RADIUS_M,
|
||||
minutes: DAWARICH_BUCKET_MATCH_MIN_MINUTES,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="dawarich-accept-name" className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.accept.name')}
|
||||
</label>
|
||||
<input
|
||||
id="dawarich-accept-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-body focus:outline-none focus:ring-2 ring-accent border-edge bg-surface-input text-content"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* TREK's own pickers, not the browser's. A native `type="date"` paints
|
||||
itself from the OS locale and a native `type="time"` ignores the 12h/24h
|
||||
setting outright (#2067) — and neither follows the colour scheme. The
|
||||
date column is the wide one because a localized date is long and a
|
||||
clock never is. */}
|
||||
{target !== 'bucket_list' && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[minmax(0,1fr)_124px_124px] gap-2">
|
||||
<div>
|
||||
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.accept.date')}
|
||||
</span>
|
||||
<CustomDatePicker value={date} onChange={setDate} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.accept.from')}
|
||||
</span>
|
||||
<CustomTimePicker value={time} onChange={setTime} aria-label={t('dawarich.accept.from')} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.accept.to')}
|
||||
</span>
|
||||
<CustomTimePicker value={endTime} onChange={setEndTime} aria-label={t('dawarich.accept.to')} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stacked and full width, in the order the choice is actually made: the
|
||||
day is what somebody is deciding, the trip is usually already right.
|
||||
Side by side, both pickers were half as wide as their longest option
|
||||
and the menus opened narrower than the text in them. */}
|
||||
{target === 'place' && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.accept.day')}
|
||||
</span>
|
||||
{/* Unplanned is a real answer, not a missing one: half the places on
|
||||
a trip sit on no day, and forcing a choice here would invent one. */}
|
||||
<CustomSelect
|
||||
value={dayId === '' ? NO_DAY : dayId}
|
||||
onChange={next => setDayId(next === NO_DAY || typeof next !== 'number' ? '' : next)}
|
||||
options={[
|
||||
{ value: NO_DAY, label: t('dawarich.accept.noDay') },
|
||||
// The date rides as the option's badge rather than inside its
|
||||
// label: the day number is what people scan for, and the date
|
||||
// is the confirmation they read second.
|
||||
...days.map(day => ({ value: day.id, label: day.label, badge: day.badge })),
|
||||
]}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.accept.trip')}
|
||||
</span>
|
||||
<CustomSelect
|
||||
value={tripId}
|
||||
onChange={next => { setTripId(typeof next === 'number' ? next : ''); setDayId('') }}
|
||||
options={trips.map(trip => ({ value: trip.id, label: trip.label }))}
|
||||
placeholder={t('dawarich.accept.pickTrip')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{target === 'journal' && (
|
||||
<div>
|
||||
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.accept.journal')}
|
||||
</span>
|
||||
<CustomSelect
|
||||
value={journalId}
|
||||
onChange={next => setJournalId(typeof next === 'number' ? next : '')}
|
||||
options={journals.map(journal => ({ value: journal.id, label: journal.label }))}
|
||||
placeholder={t('dawarich.accept.pickJournal')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{target !== 'bucket_list' && (
|
||||
<div>
|
||||
<label htmlFor="dawarich-accept-notes" className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{target === 'journal' ? t('dawarich.accept.story') : t('dawarich.accept.notes')}
|
||||
</label>
|
||||
<textarea
|
||||
id="dawarich-accept-notes"
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
rows={target === 'journal' ? 5 : 3}
|
||||
placeholder={t('dawarich.accept.storyPlaceholder')}
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-body resize-none focus:outline-none focus:ring-2 ring-accent border-edge bg-surface-input text-content"
|
||||
/>
|
||||
{target === 'journal' && (
|
||||
<p className="mt-1 text-caption text-content-muted">{t('dawarich.accept.photosHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const title = t(`dawarich.accept.title.${target}`)
|
||||
|
||||
// The phone shell has its own sheet, its own rounding and its own footer pill,
|
||||
// and every other form on it uses them. A desktop modal scaled down would be
|
||||
// the one dialog in the app that came from somewhere else.
|
||||
if (phone) {
|
||||
return (
|
||||
<MSheet open onClose={onCancel} variant="bottom" material="glass" ariaLabel={title}>
|
||||
<div className="flex max-h-[88dvh] min-h-0 flex-col">
|
||||
<FormSheetHeader
|
||||
// The mark fills the tile edge to edge, rounded like every other
|
||||
// avatar-sized badge in the shell — a small logo floating in a grey
|
||||
// square reads as a placeholder.
|
||||
leading={
|
||||
<span className="flex h-10 w-10 flex-none overflow-hidden rounded-[13px]">
|
||||
<DawarichIcon size={40} />
|
||||
</span>
|
||||
}
|
||||
title={title}
|
||||
onClose={onCancel}
|
||||
closeLabel={t('common.cancel')}
|
||||
/>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-3">{fields}</div>
|
||||
<FormSheetFooter
|
||||
onCancel={onCancel}
|
||||
cancelLabel={t('common.cancel')}
|
||||
onSubmit={confirm}
|
||||
submitLabel={t(`dawarich.accept.confirm.${target}`)}
|
||||
submitDisabled={busy || missingTarget}
|
||||
/>
|
||||
</div>
|
||||
</MSheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onCancel}
|
||||
size="lg"
|
||||
title={title}
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 rounded-lg text-body border border-edge text-content-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confirm}
|
||||
disabled={busy || missingTarget}
|
||||
className="px-4 py-2 rounded-lg text-body font-medium bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{t(`dawarich.accept.confirm.${target}`)}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{fields}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The bands Dawarich actually emits (>=70 high, >=40 medium, else low).
|
||||
* A band a later version adds is shown as nothing rather than as a raw English
|
||||
* word or a missing translation key.
|
||||
*/
|
||||
const KNOWN_BANDS = ['high', 'medium', 'low']
|
||||
|
||||
/**
|
||||
* "No day" as a real option value.
|
||||
*
|
||||
* CustomSelect uses strict equality on its values and treats the empty string as
|
||||
* "nothing chosen", which would make the placeholder and the deliberate answer
|
||||
* indistinguishable — and unplanned is a deliberate answer here.
|
||||
*/
|
||||
const NO_DAY = '__none__'
|
||||
|
||||
/** `HH:MM` out of an ISO timestamp, in the offset the timestamp carries. */
|
||||
function clockOf(iso: string): string {
|
||||
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(iso) ? iso.slice(11, 16) : ''
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// FE-DAWARICH-ATLASDLG-001 to FE-DAWARICH-ATLASDLG-009
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render'
|
||||
import { fireEvent } from '@testing-library/react'
|
||||
import DawarichAtlasDialog from './DawarichAtlasDialog'
|
||||
|
||||
const toast = { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }
|
||||
vi.mock('../shared/Toast', () => ({ useToast: () => toast, default: () => toast }))
|
||||
|
||||
const repo = {
|
||||
bucketScan: vi.fn(),
|
||||
atlasSuggestions: vi.fn(),
|
||||
}
|
||||
vi.mock('../../repo/dawarichRepo', () => ({
|
||||
dawarichRepo: {
|
||||
bucketScan: (...args: unknown[]) => repo.bucketScan(...args),
|
||||
atlasSuggestions: (...args: unknown[]) => repo.atlasSuggestions(...args),
|
||||
},
|
||||
DawarichOfflineError: class extends Error {},
|
||||
}))
|
||||
|
||||
const api = {
|
||||
confirmBucketVisits: vi.fn(),
|
||||
acceptAtlasCountries: vi.fn(),
|
||||
}
|
||||
vi.mock('../../api/dawarich', () => ({
|
||||
dawarichApi: {
|
||||
confirmBucketVisits: (...args: unknown[]) => api.confirmBucketVisits(...args),
|
||||
acceptAtlasCountries: (...args: unknown[]) => api.acceptAtlasCountries(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
const SCAN = {
|
||||
matches: [
|
||||
{
|
||||
itemId: 11,
|
||||
name: 'Museum Ludwig',
|
||||
match: { at: '2026-09-10T14:00:00Z', minutes: 145, distanceMeters: 49, points: 60 },
|
||||
alreadyVisited: false,
|
||||
},
|
||||
{
|
||||
itemId: 12,
|
||||
name: 'Kölner Dom',
|
||||
match: { at: '2026-09-09T14:00:00Z', minutes: 30, distanceMeters: 80, points: 12 },
|
||||
alreadyVisited: true,
|
||||
},
|
||||
{ itemId: 13, name: 'Rheinpark', match: null, alreadyVisited: false },
|
||||
],
|
||||
skippedWithoutCoordinates: 2,
|
||||
truncated: false,
|
||||
fetchedAt: '2026-09-12T10:00:00Z',
|
||||
}
|
||||
|
||||
const COUNTRIES = {
|
||||
countries: [
|
||||
{ countryCode: 'DE', sourceName: 'Germany', cities: [{ name: 'Cologne', minutes: 400, lastSeenAt: '2026-09-10T10:00:00Z' }], alreadyVisited: true },
|
||||
{ countryCode: 'NL', sourceName: 'Netherlands', cities: [{ name: 'Maastricht', minutes: 90, lastSeenAt: '2026-09-08T10:00:00Z' }], alreadyVisited: false },
|
||||
],
|
||||
unresolved: ['Freedonia'],
|
||||
fetchedAt: '2026-09-12T10:00:00Z',
|
||||
}
|
||||
|
||||
function open(props: Partial<React.ComponentProps<typeof DawarichAtlasDialog>> = {}) {
|
||||
return render(<DawarichAtlasDialog isOpen onClose={vi.fn()} {...props} />)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
toast.success.mockReset()
|
||||
toast.error.mockReset()
|
||||
repo.bucketScan.mockReset().mockResolvedValue(SCAN)
|
||||
repo.atlasSuggestions.mockReset().mockResolvedValue(COUNTRIES)
|
||||
api.confirmBucketVisits.mockReset().mockResolvedValue({ updated: 1 })
|
||||
api.acceptAtlasCountries.mockReset().mockResolvedValue({ marked: 1 })
|
||||
})
|
||||
|
||||
describe('DawarichAtlasDialog', () => {
|
||||
it('FE-DAWARICH-ATLASDLG-001: asks nothing on its own — the scan runs when the reader says so', () => {
|
||||
open()
|
||||
expect(repo.bucketScan).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', { name: 'Check wishlist' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-002: offers no confirmation before there is anything to confirm', () => {
|
||||
open()
|
||||
// Hidden until a result is on screen rather than merely disabled: a greyed
|
||||
// out "Tick off 0" is a dead control asking to be understood, and `hidden`
|
||||
// keeps it out of the accessibility tree too.
|
||||
expect(screen.queryByRole('button', { name: /Tick off/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-003: a scan lists what it found, with the distance, the time and the day', async () => {
|
||||
open()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
|
||||
const row = screen.getByRole('checkbox', { name: 'Museum Ludwig' })
|
||||
expect(row.textContent).toContain('49 m away')
|
||||
expect(row.textContent).toContain('2 h 25 min')
|
||||
// A wish with no match at all is not a row — there is nothing to decide.
|
||||
expect(screen.queryByRole('checkbox', { name: 'Rheinpark' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-004: open wishes start selected, already ticked ones do not', async () => {
|
||||
open()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
|
||||
expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toHaveAttribute('aria-checked', 'true')
|
||||
expect(screen.getByRole('checkbox', { name: 'Kölner Dom' })).toHaveAttribute('aria-checked', 'false')
|
||||
expect(screen.getByRole('checkbox', { name: 'Kölner Dom' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-005: unticking a row takes it out of what gets written', async () => {
|
||||
open()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Museum Ludwig' }))
|
||||
|
||||
expect(screen.getByRole('button', { name: /Tick off 0/ })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-006: confirming writes exactly the picked wishes and reports back', async () => {
|
||||
const onChanged = vi.fn()
|
||||
open({ onChanged })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Tick off 1/ }))
|
||||
|
||||
// With the date the stay was actually reached on — a wish from last year
|
||||
// ticked off with today's date is a wrong entry in a list people keep.
|
||||
await waitFor(() => expect(api.confirmBucketVisits).toHaveBeenCalledWith([11], '2026-09-10T14:00:00Z'))
|
||||
expect(onChanged).toHaveBeenCalled()
|
||||
// Marked in place rather than rescanned — one upstream request per wish is
|
||||
// not worth spending to tell the reader what they just did.
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeDisabled())
|
||||
expect(repo.bucketScan).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-007: the countries tab offers only what the Atlas does not already have', async () => {
|
||||
open({ initialTab: 'countries' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
|
||||
expect(screen.queryByRole('checkbox', { name: 'Germany' })).not.toBeInTheDocument()
|
||||
// A country Dawarich named but TREK could not code is said out loud, not dropped.
|
||||
expect(screen.getByText(/Freedonia/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-008: confirming countries sends their codes', async () => {
|
||||
open({ initialTab: 'countries' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ }))
|
||||
|
||||
await waitFor(() => expect(api.acceptAtlasCountries).toHaveBeenCalledWith(['NL']))
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-009: a scan that fails says so and leaves the dialog usable', async () => {
|
||||
repo.bucketScan.mockRejectedValue({ response: { data: { error: 'Dawarich rejected the API key.' } } })
|
||||
open()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Dawarich rejected the API key.'))
|
||||
expect(screen.getByRole('button', { name: 'Check wishlist' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,578 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Building2, Check, CalendarDays, Globe2, Hourglass, Info, Loader2, MapPin, RefreshCw, Star } from 'lucide-react'
|
||||
import type { DawarichAtlasSuggestions, DawarichBucketScan } from '@trek/shared'
|
||||
import { DAWARICH_BUCKET_MATCH_MIN_MINUTES, DAWARICH_BUCKET_MATCH_RADIUS_M } from '@trek/shared'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Skeleton } from '../shared/Skeleton'
|
||||
import { SlidingTabs } from '../shared/SlidingTabs'
|
||||
import Modal from '../shared/Modal'
|
||||
import MSheet from '../../mobile/components/MSheet'
|
||||
import { FormSheetHeader } from '../../mobile/screens/trip/sheets/PlSheetChrome'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import DawarichBadge from './DawarichBadge'
|
||||
import { dawarichApi } from '../../api/dawarich'
|
||||
import { dawarichRepo, DawarichOfflineError } from '../../repo/dawarichRepo'
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode'
|
||||
import { formatDuration } from './dawarichSuggestionModel'
|
||||
import { cityLine, countryLabel, countryWindow, formatDistance, newCountries, orderedMatches } from './dawarichAtlasModel'
|
||||
|
||||
type Tab = 'wishes' | 'countries'
|
||||
|
||||
/**
|
||||
* Everything Dawarich can answer about the Atlas, in a room of its own.
|
||||
*
|
||||
* It used to be a card wedged under the wishlist, which made two different
|
||||
* questions — "which wishes did I actually reach?" and "which countries was I
|
||||
* in?" — look like one paragraph of small print. Both are offers that need
|
||||
* reading before they are accepted, and reading needs space, so they get a
|
||||
* dialog with a tab each, one selection model and one confirm button.
|
||||
*
|
||||
* Nothing here runs on its own. The Atlas is curated — it carries tombstones
|
||||
* precisely so a country somebody removed stays removed — and a background job
|
||||
* that ticked countries off would be fighting its owner.
|
||||
*/
|
||||
export default function DawarichAtlasDialog({
|
||||
isOpen,
|
||||
initialTab = 'wishes',
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
/** Which question was asked to open it — the panel has a button per tab. */
|
||||
initialTab?: Tab
|
||||
onClose: () => void
|
||||
/** Called after something was written, so the Atlas can re-read itself. */
|
||||
onChanged?: () => void
|
||||
}): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const phone = useIsPhone()
|
||||
const [tab, setTab] = useState<Tab>(initialTab)
|
||||
|
||||
const [scan, setScan] = useState<DawarichBucketScan | null>(null)
|
||||
const [scanning, setScanning] = useState(false)
|
||||
const [picked, setPicked] = useState<Set<number>>(new Set())
|
||||
|
||||
const [countries, setCountries] = useState<DawarichAtlasSuggestions | null>(null)
|
||||
const [loadingCountries, setLoadingCountries] = useState(false)
|
||||
const [pickedCountries, setPickedCountries] = useState<Set<string>>(new Set())
|
||||
// One write at a time: the button stays on screen while the request is in
|
||||
// flight, and a second press would send the same confirmation twice.
|
||||
const [writing, setWriting] = useState(false)
|
||||
|
||||
const matches = orderedMatches(scan?.matches ?? [])
|
||||
const openMatches = matches.filter(match => !match.alreadyVisited)
|
||||
const offered = newCountries(countries?.countries ?? [])
|
||||
|
||||
const runScan = async (): Promise<void> => {
|
||||
setScanning(true)
|
||||
try {
|
||||
const result = await dawarichRepo.bucketScan()
|
||||
setScan(result)
|
||||
// Everything with a match and not already ticked starts selected: the
|
||||
// common case is "yes, all of those", and unticking the odd one is less
|
||||
// work than ticking twenty.
|
||||
setPicked(new Set(result.matches.filter(m => m.match && !m.alreadyVisited).map(m => m.itemId)))
|
||||
} catch (err) {
|
||||
toast.error(failureText(err, t))
|
||||
} finally {
|
||||
setScanning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadCountries = async (): Promise<void> => {
|
||||
setLoadingCountries(true)
|
||||
try {
|
||||
const { from, to } = countryWindow(new Date())
|
||||
const result = await dawarichRepo.atlasSuggestions(from, to)
|
||||
setCountries(result)
|
||||
setPickedCountries(new Set(newCountries(result.countries).map(c => c.countryCode)))
|
||||
} catch (err) {
|
||||
toast.error(failureText(err, t))
|
||||
} finally {
|
||||
setLoadingCountries(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmWishes = async (): Promise<void> => {
|
||||
if (picked.size === 0 || writing) return
|
||||
// Writes are online-only: the reads are gated in the repo, and firing a
|
||||
// confirmation into a dead connection would hang to a timeout and then
|
||||
// report a server error for something the server never saw.
|
||||
if (isEffectivelyOffline()) {
|
||||
toast.error(t('dawarich.error.offline'))
|
||||
return
|
||||
}
|
||||
setWriting(true)
|
||||
try {
|
||||
// Ticked off on the day each wish was reached, not on today. The dialog is
|
||||
// holding that date for every row it is about to write, so it says it;
|
||||
// grouping by date keeps that to one request per day rather than per wish.
|
||||
const byDate = new Map<string, number[]>()
|
||||
for (const m of matches) {
|
||||
if (!picked.has(m.itemId) || !m.match) continue
|
||||
const bucket = byDate.get(m.match.at)
|
||||
if (bucket) bucket.push(m.itemId)
|
||||
else byDate.set(m.match.at, [m.itemId])
|
||||
}
|
||||
const results = await Promise.all(
|
||||
[...byDate].map(([at, ids]) => dawarichApi.confirmBucketVisits(ids, at)),
|
||||
)
|
||||
const result = { updated: results.reduce((sum, r) => sum + r.updated, 0) }
|
||||
toast.success(t('dawarich.bucket.confirmed', { count: result.updated }))
|
||||
// Marked in place rather than rescanned: the scan costs one upstream
|
||||
// request per wish, and the reader already knows what they just ticked.
|
||||
setScan(prev =>
|
||||
prev === null
|
||||
? prev
|
||||
: { ...prev, matches: prev.matches.map(m => (picked.has(m.itemId) ? { ...m, alreadyVisited: true } : m)) },
|
||||
)
|
||||
setPicked(new Set())
|
||||
onChanged?.()
|
||||
} catch (err) {
|
||||
toast.error(failureText(err, t))
|
||||
} finally {
|
||||
setWriting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmCountries = async (): Promise<void> => {
|
||||
if (pickedCountries.size === 0 || writing) return
|
||||
if (isEffectivelyOffline()) {
|
||||
toast.error(t('dawarich.error.offline'))
|
||||
return
|
||||
}
|
||||
setWriting(true)
|
||||
try {
|
||||
const result = await dawarichApi.acceptAtlasCountries([...pickedCountries])
|
||||
toast.success(t('dawarich.atlas.accepted', { count: result.marked }))
|
||||
setCountries(prev =>
|
||||
prev === null
|
||||
? prev
|
||||
: {
|
||||
...prev,
|
||||
countries: prev.countries.map(c =>
|
||||
pickedCountries.has(c.countryCode) ? { ...c, alreadyVisited: true } : c,
|
||||
),
|
||||
},
|
||||
)
|
||||
setPickedCountries(new Set())
|
||||
onChanged?.()
|
||||
} catch (err) {
|
||||
toast.error(failureText(err, t))
|
||||
} finally {
|
||||
setWriting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = <T,>(set: Set<T>, value: T): Set<T> => {
|
||||
const next = new Set(set)
|
||||
if (next.has(value)) next.delete(value)
|
||||
else next.add(value)
|
||||
return next
|
||||
}
|
||||
|
||||
const busy = tab === 'wishes' ? scanning : loadingCountries
|
||||
const loaded = tab === 'wishes' ? scan !== null : countries !== null
|
||||
const selected = tab === 'wishes' ? picked.size : pickedCountries.size
|
||||
// Nothing was found, so there is nothing to confirm — the empty state says so
|
||||
// in a sentence, and a greyed-out "Add 0 countries" underneath it only asks to
|
||||
// be understood.
|
||||
const anything = tab === 'wishes' ? matches.length > 0 : offered.length > 0
|
||||
|
||||
// The same two questions in both shells — only the frame differs.
|
||||
const body = (
|
||||
<div className="space-y-4">
|
||||
<SlidingTabs<Tab>
|
||||
tabs={[
|
||||
{ id: 'wishes', label: t('dawarich.atlas.tab.wishes'), icon: Star, count: openMatches.length || undefined },
|
||||
{ id: 'countries', label: t('dawarich.atlas.tab.countries'), icon: Globe2, count: offered.length || undefined },
|
||||
]}
|
||||
activeTab={tab}
|
||||
onChange={setTab}
|
||||
size="sm"
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
{tab === 'wishes' ? (
|
||||
<Section
|
||||
icon={Star}
|
||||
title={t('dawarich.bucket.title')}
|
||||
description={t('dawarich.bucket.description')}
|
||||
action={t('dawarich.bucket.scan')}
|
||||
busyLabel={t('dawarich.bucket.scanning')}
|
||||
busy={scanning}
|
||||
loaded={scan !== null}
|
||||
onRun={() => { void runScan() }}
|
||||
empty={matches.length === 0}
|
||||
emptyLabel={t('dawarich.bucket.noMatches')}
|
||||
notes={[
|
||||
scan && scan.skippedWithoutCoordinates > 0
|
||||
? t('dawarich.bucket.skipped', { count: scan.skippedWithoutCoordinates })
|
||||
: null,
|
||||
scan?.truncated ? t('dawarich.bucket.truncated') : null,
|
||||
t('dawarich.bucket.rule', {
|
||||
meters: DAWARICH_BUCKET_MATCH_RADIUS_M,
|
||||
minutes: DAWARICH_BUCKET_MATCH_MIN_MINUTES,
|
||||
}),
|
||||
]}
|
||||
>
|
||||
{matches.map(match => (
|
||||
<Row
|
||||
key={match.itemId}
|
||||
name={match.name}
|
||||
checked={picked.has(match.itemId)}
|
||||
disabled={match.alreadyVisited}
|
||||
onToggle={() => setPicked(prev => toggle(prev, match.itemId))}
|
||||
leading={
|
||||
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-surface-secondary">
|
||||
<Star className="w-4 h-4 text-accent-on" />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{match.alreadyVisited ? (
|
||||
<DawarichBadge icon={Check} tone="success" size="sm">
|
||||
{t('dawarich.bucket.alreadyVisited')}
|
||||
</DawarichBadge>
|
||||
) : (
|
||||
<>
|
||||
<DawarichBadge icon={MapPin} size="sm">
|
||||
{formatDistance(match.match!.distanceMeters, t)}
|
||||
</DawarichBadge>
|
||||
<DawarichBadge icon={Hourglass} size="sm">
|
||||
{formatDuration(match.match!.minutes, t)}
|
||||
</DawarichBadge>
|
||||
<DawarichBadge icon={CalendarDays} size="sm">
|
||||
{new Date(match.match!.at).toLocaleDateString(locale, {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})}
|
||||
</DawarichBadge>
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
))}
|
||||
</Section>
|
||||
) : (
|
||||
<Section
|
||||
icon={Globe2}
|
||||
title={t('dawarich.atlas.title')}
|
||||
description={t('dawarich.atlas.description')}
|
||||
action={t('dawarich.atlas.load')}
|
||||
busyLabel={t('dawarich.atlas.loading')}
|
||||
busy={loadingCountries}
|
||||
loaded={countries !== null}
|
||||
onRun={() => { void loadCountries() }}
|
||||
empty={offered.length === 0}
|
||||
emptyLabel={t('dawarich.atlas.empty')}
|
||||
notes={[
|
||||
countries && countries.unresolved.length > 0
|
||||
? t('dawarich.atlas.unresolved', { names: countries.unresolved.join(', ') })
|
||||
: null,
|
||||
countries ? t('dawarich.atlas.window') : null,
|
||||
]}
|
||||
>
|
||||
{offered.map(country => (
|
||||
<Row
|
||||
key={country.countryCode}
|
||||
name={countryLabel(country.countryCode, country.sourceName, locale)}
|
||||
checked={pickedCountries.has(country.countryCode)}
|
||||
onToggle={() => setPickedCountries(prev => toggle(prev, country.countryCode))}
|
||||
leading={
|
||||
// The Atlas page draws its countries with the same flags, and a
|
||||
// list of country names without them looks like a spreadsheet.
|
||||
<img
|
||||
src={`https://flagcdn.com/w40/${country.countryCode.toLowerCase()}.png`}
|
||||
alt=""
|
||||
className="h-6 w-8 flex-shrink-0 rounded object-cover"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{country.cities.length > 0 && (
|
||||
<>
|
||||
<DawarichBadge icon={Building2} size="sm">
|
||||
{country.cities.length === 1
|
||||
? t('dawarich.atlas.citiesOne')
|
||||
: t('dawarich.atlas.cities', { count: country.cities.length })}
|
||||
</DawarichBadge>
|
||||
<span className="min-w-0 truncate text-caption text-content-muted">
|
||||
{cityLine(country.cities)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Rendered or not, rather than `hidden`: the attribute loses to the display
|
||||
// utility on the class list, so the button stayed on screen greyed out.
|
||||
const confirmButton = !loaded || !anything ? null : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void (tab === 'wishes' ? confirmWishes() : confirmCountries()) }}
|
||||
disabled={selected === 0 || writing}
|
||||
className={
|
||||
phone
|
||||
? 'inline-flex h-[38px] items-center gap-2 rounded-full bg-m-act px-4 text-[0.8125rem] font-bold text-m-actfg disabled:opacity-40'
|
||||
: 'inline-flex items-center gap-2 rounded-lg bg-accent px-3.5 py-2 text-caption font-semibold text-accent-text hover:bg-accent-hover disabled:opacity-40'
|
||||
}
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
{tab === 'wishes'
|
||||
? t('dawarich.bucket.confirm', { count: selected })
|
||||
: t('dawarich.atlas.accept', { count: selected })}
|
||||
</button>
|
||||
)
|
||||
|
||||
const againButton = loaded ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void (tab === 'wishes' ? runScan() : loadCountries()) }}
|
||||
disabled={busy}
|
||||
className={
|
||||
phone
|
||||
? 'inline-flex h-[38px] items-center gap-2 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-4 text-[0.8125rem] font-bold text-m-muted disabled:opacity-50'
|
||||
: 'inline-flex items-center gap-2 rounded-lg border border-edge px-3 py-2 text-caption font-medium text-content-secondary hover:bg-surface-hover disabled:opacity-50'
|
||||
}
|
||||
>
|
||||
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
{t('dawarich.again')}
|
||||
</button>
|
||||
) : null
|
||||
|
||||
// The phone shell has its own sheet, its own rounding and its own pill
|
||||
// buttons, and every other panel on it uses them.
|
||||
if (phone) {
|
||||
return (
|
||||
<MSheet open={isOpen} onClose={onClose} variant="bottom" material="glass" ariaLabel={t('dawarich.title')}>
|
||||
<div className="flex max-h-[88dvh] min-h-0 flex-col">
|
||||
<FormSheetHeader
|
||||
leading={
|
||||
<span className="flex h-10 w-10 flex-none overflow-hidden rounded-[13px]">
|
||||
<DawarichIcon size={40} />
|
||||
</span>
|
||||
}
|
||||
title={t('dawarich.title')}
|
||||
subtitle={t('dawarich.atlas.dialogSubtitle')}
|
||||
onClose={onClose}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-3">{body}</div>
|
||||
<div className="flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
|
||||
<span className="min-w-0 flex-1 truncate font-geist text-[0.71875rem] text-m-muted">
|
||||
{selected > 0 ? t('dawarich.selected', { count: selected }) : ''}
|
||||
</span>
|
||||
{againButton}
|
||||
{confirmButton}
|
||||
</div>
|
||||
</div>
|
||||
</MSheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
title={
|
||||
<span className="flex items-center gap-2.5">
|
||||
{/* Rounded, like every other avatar-sized mark in TREK: the logo is a
|
||||
square badge and a hard corner beside a rounded dialog reads as a
|
||||
pasted-in image. */}
|
||||
<span className="flex-shrink-0 overflow-hidden rounded-lg">
|
||||
<DawarichIcon size={26} />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-subtitle font-semibold text-content">{t('dawarich.title')}</span>
|
||||
<span className="block truncate text-caption font-normal text-content-muted">
|
||||
{t('dawarich.atlas.dialogSubtitle')}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<span className="text-caption text-content-muted">
|
||||
{selected > 0 ? t('dawarich.selected', { count: selected }) : ''}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{againButton}
|
||||
{confirmButton}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{body}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One half of the dialog: the pitch before anything has been asked, the wait
|
||||
* while it is being asked, and the answer.
|
||||
*
|
||||
* Both halves behave identically, which is the point — the two questions are
|
||||
* different, the shape of answering them is not.
|
||||
*/
|
||||
function Section({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
busyLabel,
|
||||
busy,
|
||||
loaded,
|
||||
onRun,
|
||||
empty,
|
||||
emptyLabel,
|
||||
notes,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
title: string
|
||||
description: string
|
||||
action: string
|
||||
busyLabel: string
|
||||
busy: boolean
|
||||
loaded: boolean
|
||||
onRun: () => void
|
||||
empty: boolean
|
||||
emptyLabel: string
|
||||
notes: (string | null)[]
|
||||
children: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
if (busy) {
|
||||
return (
|
||||
<div className="space-y-2" aria-busy>
|
||||
<p className="text-caption text-content-muted">{busyLabel}</p>
|
||||
{[0, 1, 2].map(i => (
|
||||
<div key={i} className="flex items-center gap-3 rounded-xl border border-edge p-3">
|
||||
<Skeleton width={32} height={32} radius={8} />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton width="45%" height={13} radius={4} />
|
||||
<Skeleton width="70%" height={11} radius={4} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<div className="flex flex-col items-center rounded-xl border border-dashed border-edge px-6 py-8 text-center">
|
||||
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-surface-secondary">
|
||||
<Icon className="w-5 h-5 text-content-secondary" />
|
||||
</span>
|
||||
<p className="text-body font-semibold text-content">{title}</p>
|
||||
<p className="mt-1.5 max-w-md text-caption text-content-muted">{description}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRun}
|
||||
className="mt-4 inline-flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-caption font-semibold text-accent-text hover:bg-accent-hover"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
{action}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{empty ? (
|
||||
<div className="rounded-xl border border-dashed border-edge px-6 py-8 text-center text-caption text-content-muted">
|
||||
{emptyLabel}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">{children}</div>
|
||||
)}
|
||||
{notes.filter(Boolean).length > 0 && (
|
||||
<div className="space-y-1 border-t border-edge-faint pt-2.5">
|
||||
{notes.filter(Boolean).map(note => (
|
||||
<p key={note} className="flex items-start gap-1.5 text-caption text-content-faint">
|
||||
<Info className="w-3 h-3 flex-shrink-0 mt-0.5" />
|
||||
{note}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A selectable answer: what it is on the left, why it is being offered
|
||||
* underneath, and a tick that says whether it will be applied.
|
||||
*
|
||||
* The whole row is the control — a 16px checkbox is a hard target on a phone,
|
||||
* and there is nothing else in the row to click.
|
||||
*/
|
||||
function Row({
|
||||
name,
|
||||
checked,
|
||||
disabled = false,
|
||||
onToggle,
|
||||
leading,
|
||||
children,
|
||||
}: {
|
||||
name: string
|
||||
checked: boolean
|
||||
disabled?: boolean
|
||||
onToggle: () => void
|
||||
leading: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
aria-label={name}
|
||||
disabled={disabled}
|
||||
onClick={onToggle}
|
||||
className={`flex w-full items-center gap-3 rounded-xl border p-2.5 text-left transition-colors ${
|
||||
checked ? 'border-accent bg-accent-subtle' : 'border-edge hover:bg-surface-hover'
|
||||
} ${disabled ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded ${
|
||||
checked ? 'bg-accent' : 'border-[1.5px] border-edge'
|
||||
}`}
|
||||
>
|
||||
{checked && <Check className="w-2.5 h-2.5 text-accent-text" strokeWidth={3} />}
|
||||
</span>
|
||||
{leading}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-body font-medium text-content">{name}</span>
|
||||
<span className="mt-1 flex flex-wrap items-center gap-1">{children}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What to tell the reader when a question could not be asked.
|
||||
*
|
||||
* Being offline is not an error the server reported — nothing was sent — and
|
||||
* "something went wrong" for a plane journey is the kind of message that sends
|
||||
* somebody looking for a bug in their Dawarich instance.
|
||||
*/
|
||||
function failureText(err: unknown, t: (key: string) => string): string {
|
||||
if (err instanceof DawarichOfflineError) return t('dawarich.error.offline')
|
||||
return errorText(err) ?? t('dawarich.error.unknown')
|
||||
}
|
||||
|
||||
/** The server's `{ error }` envelope, when there is one. */
|
||||
function errorText(err: unknown): string | null {
|
||||
const data = (err as { response?: { data?: { error?: unknown } } })?.response?.data
|
||||
return typeof data?.error === 'string' ? data.error : null
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Globe2, Star } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { useGlassGlare } from '../Atlas/useGlassGlare'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import DawarichAtlasDialog from './DawarichAtlasDialog'
|
||||
|
||||
type Question = 'wishes' | 'countries'
|
||||
|
||||
/**
|
||||
* Dawarich's own panel on the Atlas, beside the stats.
|
||||
*
|
||||
* The Atlas is a map with a single glass panel floating over it, and everything
|
||||
* it offers lives in that panel. Dawarich answers two questions about the same
|
||||
* map — which wishes were actually reached, and which countries the recordings
|
||||
* say you were in — but they are *its* answers, not the Atlas's own numbers, and
|
||||
* squeezed under the wishlist they read as fine print nobody asked for.
|
||||
*
|
||||
* So it gets a panel of its own, in the same glass and stretched to the same
|
||||
* height, and the two questions are two tiles built like the stat blocks beside
|
||||
* them: a mark, then a small capitalised label. The lists they produce open in a
|
||||
* dialog — they are offers to read and confirm, and a bar over a world map is no
|
||||
* place to read anything.
|
||||
*
|
||||
* Renders nothing when the addon is off, so an Atlas without Dawarich looks
|
||||
* exactly as it did before.
|
||||
*/
|
||||
export default function DawarichAtlasSidePanel({
|
||||
style,
|
||||
dark,
|
||||
onChanged,
|
||||
}: {
|
||||
/** The glass the Atlas panels are made of, handed down so the two cannot drift. */
|
||||
style: React.CSSProperties
|
||||
dark: boolean
|
||||
/** Called after something was written, so the Atlas can re-read itself. */
|
||||
onChanged?: () => void
|
||||
}): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const addonEnabled = useAddonStore(state => state.isEnabled)
|
||||
const [asked, setAsked] = useState<Question | null>(null)
|
||||
const glare = useGlassGlare(dark)
|
||||
|
||||
if (!addonEnabled('dawarich')) return null
|
||||
|
||||
// theme-lint-disable — the panel floats on a blurred map: a solid surface
|
||||
// token would punch an opaque hole in the glass, so the tiles are a tint of
|
||||
// the same light the panel is made of.
|
||||
const tile = dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.05)'
|
||||
const tileHover = dark ? 'rgba(255,255,255,0.13)' : 'rgba(0,0,0,0.09)'
|
||||
|
||||
const question = (id: Question, Icon: React.ComponentType<{ size?: number; strokeWidth?: number }>, label: string) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAsked(id)}
|
||||
className="group flex flex-col items-center justify-center text-content-secondary hover:text-content"
|
||||
style={{
|
||||
background: tile,
|
||||
border: 'none',
|
||||
borderRadius: 14,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
gap: 6,
|
||||
minWidth: 74,
|
||||
padding: '10px 12px',
|
||||
transition: 'background 0.15s ease, color 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = tileHover }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = tile }}
|
||||
>
|
||||
<Icon size={17} strokeWidth={2} />
|
||||
<span
|
||||
className="font-bold uppercase tracking-wide whitespace-nowrap"
|
||||
style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))' }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={glare.panelRef}
|
||||
onMouseMove={glare.onMouseMove}
|
||||
onMouseLeave={glare.onMouseLeave}
|
||||
className="relative hidden md:flex flex-col justify-center gap-3 overflow-hidden px-5 py-4"
|
||||
style={style}
|
||||
>
|
||||
{/* Liquid glass glare, and the border glow that follows the cursor —
|
||||
the same two layers the Atlas panel carries. */}
|
||||
<div ref={glare.glareRef} className="absolute inset-0 pointer-events-none" style={{ opacity: 0, transition: 'opacity 0.3s ease', borderRadius: 20 }} />
|
||||
<div ref={glare.borderGlareRef} className="absolute inset-0 pointer-events-none" style={{
|
||||
opacity: 0, transition: 'opacity 0.3s ease', borderRadius: 20,
|
||||
border: dark ? '1.5px solid rgba(255,255,255,0.5)' : '2px solid rgba(0,0,0,0.15)',
|
||||
}} />
|
||||
{/* The long explanation moved into the tooltip: on a panel this size it
|
||||
was three lines of grey that said what the two tiles already say. */}
|
||||
<Tooltip label={t('dawarich.atlas.trigger')} placement="top">
|
||||
<span className="relative flex items-center justify-center gap-2.5">
|
||||
<span className="flex-shrink-0 overflow-hidden rounded-[9px]">
|
||||
<DawarichIcon size={30} />
|
||||
</span>
|
||||
<span
|
||||
className="truncate font-black tracking-tight text-content"
|
||||
style={{ fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))' }}
|
||||
>
|
||||
{t('dawarich.title')}
|
||||
</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<div className="relative flex items-stretch gap-2">
|
||||
{question('wishes', Star, t('dawarich.atlas.tab.wishes'))}
|
||||
{question('countries', Globe2, t('dawarich.atlas.tab.countries'))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mounted on open so every visit starts from a clean question rather than
|
||||
from a scan somebody ran an hour ago. */}
|
||||
{asked !== null && (
|
||||
<DawarichAtlasDialog
|
||||
isOpen
|
||||
initialTab={asked}
|
||||
onClose={() => setAsked(null)}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import React from 'react'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
|
||||
/**
|
||||
* One badge shape for everything Dawarich shows.
|
||||
*
|
||||
* Fixed height and a single radius, so a row of them lines up however many there
|
||||
* are; the tone is the only thing that varies, and it carries the meaning rather
|
||||
* than decorating it. `quiet` is for a fact worth stating and not worth looking
|
||||
* at twice.
|
||||
*
|
||||
* It lives in its own file because the review list, the accept dialog and the
|
||||
* Atlas dialog all use it, and three copies of a pill is how two of them end up
|
||||
* a pixel apart.
|
||||
*/
|
||||
export default function DawarichBadge({
|
||||
icon: Icon,
|
||||
tone = 'neutral',
|
||||
size = 'xs',
|
||||
title,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
tone?: 'neutral' | 'quiet' | 'accent' | 'success' | 'warning'
|
||||
/**
|
||||
* `xs` matches the places rail, where the panel sits beside rows of that size;
|
||||
* `sm` is for the dialogs, which are read at arm's length rather than scanned.
|
||||
*/
|
||||
size?: 'xs' | 'sm'
|
||||
/** Hover text. Defaults to `label` when the badge is icon-only. */
|
||||
title?: string
|
||||
/**
|
||||
* The accessible name for an icon-only badge. Passing it instead of children
|
||||
* is what makes a flag a round dot of colour without turning a screen reader
|
||||
* into someone staring at an unlabelled icon.
|
||||
*/
|
||||
label?: string
|
||||
children?: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
const tones: Record<string, string> = {
|
||||
neutral: 'bg-surface-secondary text-content-secondary',
|
||||
quiet: 'bg-surface-secondary text-content-faint',
|
||||
accent: 'bg-accent-subtle text-accent-on',
|
||||
success: 'bg-success-soft text-success',
|
||||
warning: 'bg-warning-soft text-warning',
|
||||
}
|
||||
const metrics =
|
||||
size === 'sm'
|
||||
? { style: { fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }, box: 'h-[20px]', icon: 'w-3 h-3' }
|
||||
: { style: { fontSize: 'calc(10px * var(--fs-scale-caption, 1))' }, box: 'h-[18px]', icon: 'w-2.5 h-2.5' }
|
||||
const iconOnly = children === undefined
|
||||
const hint = title ?? label
|
||||
const badge = (
|
||||
<span
|
||||
aria-label={iconOnly ? label : undefined}
|
||||
role={iconOnly ? 'img' : undefined}
|
||||
style={metrics.style}
|
||||
className={`inline-flex ${metrics.box} max-w-full flex-shrink-0 items-center gap-1 whitespace-nowrap rounded-full font-semibold ${
|
||||
iconOnly ? (size === 'sm' ? 'w-[20px] justify-center' : 'w-[18px] justify-center') : 'px-1.5'
|
||||
} ${tones[tone]}`}
|
||||
>
|
||||
{Icon && <Icon className={`${metrics.icon} flex-shrink-0`} />}
|
||||
{!iconOnly && <span className="truncate">{children}</span>}
|
||||
</span>
|
||||
)
|
||||
// TREK's own tooltip rather than the browser's: the native one ignores the
|
||||
// colour scheme, waits a second and a half, and cannot be read on a touch
|
||||
// device at all. A badge with nothing to add gets none.
|
||||
return hint ? <Tooltip label={hint} placement="top">{badge}</Tooltip> : badge
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronDown, ChevronRight, MapPin, BookOpen, Check, X, RotateCcw, AlertTriangle, Clock, Hourglass, CalendarDays, Briefcase, Gauge, History, Unplug } from 'lucide-react'
|
||||
import type { DawarichAccept, DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useDawarichSuggestions } from '../../hooks/useDawarichSuggestions'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import { relativeTime } from '../../utils/relativeTime'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import Badge from './DawarichBadge'
|
||||
import DawarichAcceptDialog, { type AcceptTargetOption } from './DawarichAcceptDialog'
|
||||
import { formatDayHeading, formatDuration, groupByDay, timeRange } from './dawarichSuggestionModel'
|
||||
|
||||
/**
|
||||
* The panel sits inside the places rail, and it has to read at the same size as
|
||||
* the rows below it — `text-body` (14px) and `text-caption` (12px) made every
|
||||
* line of it a size larger than the trip's own places, which is the one thing a
|
||||
* panel about somebody else's data must not be.
|
||||
*
|
||||
* These are the rail's own figures (PlacesSidebarRow uses exactly 13 / 11 / 10),
|
||||
* kept as calc() against the per-tier multipliers so the appearance text-size
|
||||
* control still reaches them — a bare number would not.
|
||||
*/
|
||||
const TYPE = {
|
||||
name: { fontSize: 'calc(13px * var(--fs-scale-body, 1))' },
|
||||
meta: { fontSize: 'calc(11px * var(--fs-scale-caption, 1))' },
|
||||
micro: { fontSize: 'calc(10px * var(--fs-scale-caption, 1))' },
|
||||
} as const
|
||||
|
||||
export interface DawarichSuggestionsPanelProps {
|
||||
/** Only stays that fall inside this trip. */
|
||||
tripId?: number
|
||||
/** Trips the user may write a place into. */
|
||||
trips?: AcceptTargetOption[]
|
||||
daysForTrip?: (tripId: number) => AcceptTargetOption[]
|
||||
/** Journeys the user may add an entry to. */
|
||||
journals?: AcceptTargetOption[]
|
||||
/** Called after something was written, so the host can refresh its own list. */
|
||||
onAccepted?: (suggestion: DawarichSuggestion) => void
|
||||
/** Start collapsed. The planner does; the journal does not. */
|
||||
initiallyCollapsed?: boolean
|
||||
/**
|
||||
* Drop the panel's own card and header.
|
||||
*
|
||||
* For a sheet that already carries both — the phone's journey opens this list
|
||||
* in one, and a second "From Dawarich" under the first is a stutter.
|
||||
*/
|
||||
bare?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The stays Dawarich recorded, waiting to be reviewed.
|
||||
*
|
||||
* One component for both shells. The phone and the desktop place it
|
||||
* differently, but the rows are the same rows, and a second copy of them is a
|
||||
* second thing to keep in step — the same reasoning as `TripRouteOverviewPanel`
|
||||
* next door, and the same reason SonarCloud's duplication budget exists.
|
||||
*
|
||||
* **Grouped by day, and coloured like the map.** A day of travel produces four
|
||||
* or five stays; repeating the full date on each of them buries the one thing
|
||||
* that tells them apart, which is the time. The day's dot carries the colour its
|
||||
* line is drawn in on the trip map, so "the purple day" means the same thing in
|
||||
* both places.
|
||||
*
|
||||
* The panel renders **nothing at all** when there is nothing to review. An
|
||||
* integration that is connected and quiet should be invisible, not a permanent
|
||||
* empty card explaining that it found nothing.
|
||||
*/
|
||||
export default function DawarichSuggestionsPanel({
|
||||
tripId,
|
||||
trips = [],
|
||||
daysForTrip,
|
||||
journals = [],
|
||||
onAccepted,
|
||||
initiallyCollapsed = false,
|
||||
bare = false,
|
||||
}: DawarichSuggestionsPanelProps): React.ReactElement | null {
|
||||
const { t, locale } = useTranslation()
|
||||
const S = useDawarichSuggestions({ tripId })
|
||||
// The phone shell has its own palette (`--m-*`): a card in the desktop surface
|
||||
// tokens glares next to it. Same component, the shell's own colours.
|
||||
const phone = useIsPhone()
|
||||
const [collapsed, setCollapsed] = useState(initiallyCollapsed)
|
||||
const [showHandled, setShowHandled] = useState(false)
|
||||
const [pending, setPending] = useState<{ suggestion: DawarichSuggestion; target: DawarichSuggestionTarget } | null>(null)
|
||||
|
||||
// Deleting the place an acceptance created is how somebody says "not that
|
||||
// one" after the fact: the server puts the stay back into review, and a
|
||||
// shrinking place count is what tells us to go and ask. Only a shrink — a
|
||||
// place being added is usually this panel's own doing.
|
||||
const placeCount = useTripStore(state => (tripId === undefined ? 0 : state.places.length))
|
||||
const lastPlaceCount = useRef(placeCount)
|
||||
const reload = S.reload
|
||||
useEffect(() => {
|
||||
const shrank = placeCount < lastPlaceCount.current
|
||||
lastPlaceCount.current = placeCount
|
||||
if (shrank) reload()
|
||||
}, [placeCount, reload])
|
||||
|
||||
const open = useMemo(() => S.suggestions.filter(item => item.state === 'new'), [S.suggestions])
|
||||
const handled = useMemo(() => S.suggestions.filter(item => item.state !== 'new'), [S.suggestions])
|
||||
const openDays = useMemo(() => groupByDay(open), [open])
|
||||
|
||||
// Nothing to review and nothing to report: stay out of the way entirely.
|
||||
if (S.status === 'idle') return null
|
||||
if (S.status === 'ready' && open.length === 0 && handled.length === 0) return null
|
||||
|
||||
const confirm = async (body: DawarichAccept) => {
|
||||
if (!pending) return
|
||||
const ok = await S.accept(pending.suggestion.id, body)
|
||||
if (ok) {
|
||||
onAccepted?.(pending.suggestion)
|
||||
setPending(null)
|
||||
}
|
||||
}
|
||||
|
||||
const rowProps = {
|
||||
phone,
|
||||
// Offered only where there is somewhere to put it: the planner has no
|
||||
// journey list, and a button that opens an empty picker is worse than none.
|
||||
allowJournal: journals.length > 0,
|
||||
allowPlace: trips.length > 0,
|
||||
// The trip name is noise when every row is from the same trip.
|
||||
showTrip: tripId === undefined,
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className={
|
||||
bare
|
||||
? ''
|
||||
: `rounded-2xl overflow-hidden ${phone ? 'bg-[color:var(--m-ic)]' : 'border border-edge bg-surface-secondary'}`
|
||||
}
|
||||
>
|
||||
{bare ? (
|
||||
<p className={`px-1 pb-2 ${phone ? 'text-m-muted' : 'text-content-muted'}`} style={TYPE.micro}>
|
||||
{statusLine(S, open.length, t, locale)}
|
||||
</p>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(value => !value)}
|
||||
aria-expanded={!collapsed}
|
||||
className={`w-full flex items-center gap-2.5 px-3.5 py-2.5 text-left ${
|
||||
phone ? 'active:bg-[color:var(--m-inner)]' : 'hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
{/* Rounded, like every other avatar-sized mark in TREK: the logo is a
|
||||
square badge and a hard corner beside a rounded card reads as a
|
||||
pasted-in image. */}
|
||||
<span className="flex-shrink-0 overflow-hidden rounded-[6px]">
|
||||
<DawarichIcon size={22} />
|
||||
</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className={`block truncate font-semibold leading-tight ${phone ? 'text-m-ink' : 'text-content'}`} style={TYPE.name}>
|
||||
{t('dawarich.suggestions.title')}
|
||||
</span>
|
||||
<span className={`block truncate ${phone ? 'text-m-muted' : 'text-content-muted'}`} style={TYPE.micro}>
|
||||
{statusLine(S, open.length, t, locale)}
|
||||
</span>
|
||||
</span>
|
||||
{open.length > 0 && (
|
||||
<span
|
||||
className={`flex-shrink-0 min-w-[18px] h-[18px] px-1.5 inline-flex items-center justify-center rounded-full font-bold ${
|
||||
phone ? 'bg-m-act text-m-actfg' : 'bg-accent text-accent-text'
|
||||
}`}
|
||||
style={TYPE.micro}
|
||||
>
|
||||
{open.length}
|
||||
</span>
|
||||
)}
|
||||
{collapsed
|
||||
? <ChevronRight className={`w-3.5 h-3.5 flex-shrink-0 ${phone ? 'text-m-faint' : 'text-content-faint'}`} />
|
||||
: <ChevronDown className={`w-3.5 h-3.5 flex-shrink-0 ${phone ? 'text-m-faint' : 'text-content-faint'}`} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(bare || !collapsed) && (
|
||||
<div className="border-t border-edge-secondary">
|
||||
{S.status !== 'ready' && (
|
||||
<p className={`px-3.5 py-2.5 ${S.status === 'unavailable' ? 'text-danger' : 'text-content-secondary'}`} style={TYPE.meta}>
|
||||
{statusLine(S, open.length, t, locale)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{S.lastSyncError && S.status === 'ready' && (
|
||||
<p className="px-3.5 pt-2.5 text-danger" style={TYPE.meta}>{S.lastSyncError}</p>
|
||||
)}
|
||||
|
||||
{open.length === 0 && S.status === 'ready' && (
|
||||
<p className={`px-3.5 py-2.5 ${phone ? 'text-m-muted' : 'text-content-muted'}`} style={TYPE.meta}>{t('dawarich.suggestions.allHandled')}</p>
|
||||
)}
|
||||
|
||||
{openDays.map(day => (
|
||||
<div key={day.date}>
|
||||
<div className="flex items-center gap-2 px-3.5 pt-2.5 pb-1">
|
||||
{/* The colour this day's line is drawn in on the map. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||
style={{ background: day.color }}
|
||||
/>
|
||||
<span className={`font-bold uppercase tracking-wider whitespace-nowrap ${phone ? 'text-m-muted' : 'text-content-muted'}`} style={TYPE.micro}>
|
||||
{formatDayHeading(day.date, locale)}
|
||||
</span>
|
||||
<span className="flex-1 h-px bg-edge-faint" />
|
||||
<Badge tone="quiet">{day.stays.length}</Badge>
|
||||
</div>
|
||||
{day.stays.map(suggestion => (
|
||||
<SuggestionRow
|
||||
key={suggestion.id}
|
||||
suggestion={suggestion}
|
||||
busy={S.busyId === suggestion.id}
|
||||
{...rowProps}
|
||||
onAccept={target => setPending({ suggestion, target })}
|
||||
onDismiss={() => S.dismiss(suggestion.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{handled.length > 0 && (
|
||||
<div className="border-t border-edge-faint">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHandled(value => !value)}
|
||||
className={`w-full px-3.5 py-2 text-left ${
|
||||
phone ? 'text-m-muted active:bg-[color:var(--m-inner)]' : 'text-content-muted hover:bg-surface-hover'
|
||||
}`}
|
||||
style={TYPE.meta}
|
||||
>
|
||||
{showHandled
|
||||
? t('dawarich.suggestions.hideHandled')
|
||||
: t('dawarich.suggestions.showHandled', { count: handled.length })}
|
||||
</button>
|
||||
{showHandled &&
|
||||
handled.map(suggestion => (
|
||||
<SuggestionRow
|
||||
key={suggestion.id}
|
||||
suggestion={suggestion}
|
||||
busy={S.busyId === suggestion.id}
|
||||
showTrip={rowProps.showTrip}
|
||||
showDate
|
||||
onRestore={suggestion.state === 'dismissed' ? () => S.restore(suggestion.id) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DawarichAcceptDialog
|
||||
suggestion={pending?.suggestion ?? null}
|
||||
target={pending?.target ?? 'journal'}
|
||||
trips={trips}
|
||||
daysForTrip={daysForTrip}
|
||||
journals={journals}
|
||||
busy={S.busyId !== null}
|
||||
onCancel={() => setPending(null)}
|
||||
onConfirm={confirm}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One stay: a name, when it was, and how to act on it.
|
||||
*
|
||||
* Two lines and no chip soup. The meta line carries only what varies — how long,
|
||||
* and the two things worth interrupting for: a wish this fulfils, and a
|
||||
* detection the source itself was unsure about. The "detected, unconfirmed"
|
||||
* label that used to sit on nearly every row is gone: since Dawarich 1.12.0
|
||||
* unconfirmed is the normal state of a visit, so saying it everywhere said
|
||||
* nothing. Reviewing it here *is* the confirmation.
|
||||
*
|
||||
* The actions fade in on hover on a pointer device and stay visible on touch,
|
||||
* where there is no hover to reveal them.
|
||||
*/
|
||||
function SuggestionRow({
|
||||
suggestion,
|
||||
busy,
|
||||
allowJournal = false,
|
||||
allowPlace = false,
|
||||
showTrip = false,
|
||||
showDate = false,
|
||||
phone = false,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
onRestore,
|
||||
}: {
|
||||
suggestion: DawarichSuggestion
|
||||
busy: boolean
|
||||
allowJournal?: boolean
|
||||
allowPlace?: boolean
|
||||
showTrip?: boolean
|
||||
showDate?: boolean
|
||||
/** Rendered inside the phone shell, which has its own palette and its own reach. */
|
||||
phone?: boolean
|
||||
onAccept?: (target: DawarichSuggestionTarget) => void
|
||||
onDismiss?: () => void
|
||||
onRestore?: () => void
|
||||
}): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex items-stretch gap-[6px] px-3 py-[5px]">
|
||||
{/* A stay is a card with its actions clipped to the side — the shape an
|
||||
expense already has in Costs. One layout in both shells; only the
|
||||
palette differs, because the phone paints from `--m-*` and the desktop
|
||||
from the semantic tokens, and neither set exists outside its own. */}
|
||||
<div
|
||||
className={`flex min-w-0 flex-1 flex-col justify-center rounded-2xl border px-[11px] py-[9px] ${
|
||||
phone ? 'border-[color:var(--m-gbr)] bg-m-card' : 'border-edge bg-surface-card'
|
||||
}`}
|
||||
>
|
||||
{/* The name gets the line, and the one badge that is about the name
|
||||
itself rides with it: "Unsure" qualifies what this place is called,
|
||||
not how long somebody stayed, and two lines below the name it read as
|
||||
one more fact among the times. It never pushes the name out — the
|
||||
name truncates, the flag does not. */}
|
||||
<p className={`flex items-center gap-1.5 font-medium ${phone ? 'text-m-ink' : 'text-content'}`} style={TYPE.name}>
|
||||
<span className="truncate">{suggestion.name}</span>
|
||||
{/* The mark, never the word: the name is what the line is for, and
|
||||
"Unsure" beside it is a caption competing with a title. The tooltip
|
||||
and the accessible name still say it in full. */}
|
||||
{suggestion.confidenceBand === 'low' && (
|
||||
<Badge icon={Gauge} tone="warning" label={t('dawarich.badge.lowConfidence')} title={t('dawarich.confidence.low')} />
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* One badge row, one badge shape, colour carrying the meaning: neutral
|
||||
for a fact, accent for a wish this fulfils, success for what was
|
||||
already taken, amber for the two things worth interrupting for.
|
||||
Nothing wraps — a row that reflows every time a name is long reads as
|
||||
a mess, so it stays one line and the badges that do not fit are cut
|
||||
off rather than pushed onto a second. */}
|
||||
{/* Wraps, and only here: each badge is a fixed-height pill whose own text
|
||||
never breaks, so a second line of them reads as a second line of
|
||||
badges rather than as a paragraph coming apart. Cutting them off
|
||||
instead would hide the wish a stay fulfils, which is the one thing in
|
||||
the row worth the space. */}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
{/* Ordered by what a reader needs first, because the rail is narrow and
|
||||
the row does not wrap: how long, then what it would fulfil, then the
|
||||
flags. The flags are icon-only — the word costs half the row and the
|
||||
sentence under the badges says it properly anyway. */}
|
||||
{showDate && (
|
||||
<Badge icon={CalendarDays}>{formatDayHeading(suggestion.localDate, locale)}</Badge>
|
||||
)}
|
||||
<Badge icon={Clock}>{timeRange(suggestion.startedAt, suggestion.endedAt)}</Badge>
|
||||
<Badge icon={Hourglass}>{formatDuration(suggestion.durationMinutes, t)}</Badge>
|
||||
|
||||
{suggestion.state === 'accepted' && (
|
||||
<Badge icon={Check} tone="success">
|
||||
{t('dawarich.suggestions.acceptedAs.' + (suggestion.target ?? 'journal'))}
|
||||
</Badge>
|
||||
)}
|
||||
{suggestion.state === 'dismissed' && (
|
||||
<Badge icon={X} tone="quiet">{t('dawarich.suggestions.dismissed')}</Badge>
|
||||
)}
|
||||
|
||||
{showTrip && suggestion.tripTitle && (
|
||||
<Badge icon={Briefcase}>{suggestion.tripTitle}</Badge>
|
||||
)}
|
||||
|
||||
{suggestion.sourceMissing && (
|
||||
<Badge icon={Unplug} tone="warning" label={t('dawarich.badge.sourceMissing')} />
|
||||
)}
|
||||
{!suggestion.sourceMissing && suggestion.sourceChanged && (
|
||||
<Badge icon={History} tone="warning" label={t('dawarich.badge.sourceChanged')} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The long form of the two warnings, under the badges: the badge is the
|
||||
flag, this is what it means, and somebody reading the panel for the
|
||||
first time should not have to hover to find out. */}
|
||||
{(suggestion.sourceChanged || suggestion.sourceMissing) && (
|
||||
<p className={`mt-1 flex items-start gap-1.5 ${phone ? 'text-m-muted' : 'text-content-muted'}`} style={TYPE.meta}>
|
||||
<AlertTriangle className="w-3 h-3 flex-shrink-0 mt-px text-warning" />
|
||||
{suggestion.sourceMissing
|
||||
? t('dawarich.suggestions.sourceMissing')
|
||||
: t('dawarich.suggestions.sourceChanged')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Always on screen. Hiding them until hover made the only two things a row
|
||||
is for invisible to anyone who had not discovered that hovering did
|
||||
something — and on a touch screen there is no hover at all.
|
||||
|
||||
A column beside the text in the desktop rail, which is narrow and tall;
|
||||
a row on a phone, where there is width to spare and a stack of small
|
||||
squares reads as a scrollbar. Centred either way, against the name and
|
||||
the time it belongs to. */}
|
||||
{onAccept && (
|
||||
<div className={ACTIONS(phone)}>
|
||||
{allowJournal && (
|
||||
<RowAction
|
||||
label={t('dawarich.suggestions.asJournal')}
|
||||
icon={<BookOpen className="w-3.5 h-3.5" />}
|
||||
disabled={busy}
|
||||
phone={phone}
|
||||
onClick={() => onAccept('journal')}
|
||||
/>
|
||||
)}
|
||||
{allowPlace && (
|
||||
<RowAction
|
||||
label={t('dawarich.suggestions.asPlace')}
|
||||
icon={<MapPin className="w-3.5 h-3.5" />}
|
||||
disabled={busy}
|
||||
phone={phone}
|
||||
onClick={() => onAccept('place')}
|
||||
/>
|
||||
)}
|
||||
{onDismiss && (
|
||||
<RowAction
|
||||
label={t('dawarich.suggestions.dismiss')}
|
||||
icon={<X className="w-3.5 h-3.5" />}
|
||||
disabled={busy}
|
||||
phone={phone}
|
||||
onClick={onDismiss}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onRestore && (
|
||||
<div className={ACTIONS(phone)}>
|
||||
<RowAction
|
||||
label={t('dawarich.suggestions.restore')}
|
||||
icon={<RotateCcw className="w-3.5 h-3.5" />}
|
||||
disabled={busy}
|
||||
phone={phone}
|
||||
onClick={onRestore}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a row keeps its actions: a pill clipped to the side of the card, the
|
||||
* same in both shells, painted from whichever palette the shell has.
|
||||
*/
|
||||
const ACTIONS = (phone: boolean): string =>
|
||||
'flex flex-none flex-col gap-1 rounded-full border p-[5px] ' +
|
||||
(phone ? 'border-[color:var(--m-gbr)] bg-[color:var(--m-glass)]' : 'border-edge bg-surface')
|
||||
|
||||
/**
|
||||
* One action on a row: a real target with a real accessible name.
|
||||
*
|
||||
* A square with a border rather than a bare glyph, because a bare glyph on a
|
||||
* white rail reads as decoration; this reads as a button, which is what it is.
|
||||
* The label is a tooltip and the accessible name — spelling it out beside the
|
||||
* icon would cost the row the width its name needs.
|
||||
*
|
||||
* All of them look the same. Marking one as the primary action would be TREK
|
||||
* deciding for the reader which of "put it on the map" and "write about it" they
|
||||
* meant, and the whole review step exists because that is theirs to decide.
|
||||
*
|
||||
* Hover fills the square with the inverse surface — near-black in the light
|
||||
* scheme, near-white in the dark one — so the button somebody is about to press
|
||||
* is unmistakable without inventing a colour the theme does not have.
|
||||
*/
|
||||
function RowAction({
|
||||
label,
|
||||
icon,
|
||||
disabled,
|
||||
phone = false,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
disabled?: boolean
|
||||
phone?: boolean
|
||||
onClick: () => void
|
||||
}): React.ReactElement {
|
||||
const button = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
className={`flex h-[26px] w-[26px] flex-none items-center justify-center rounded-full transition-all disabled:opacity-40 ${
|
||||
phone
|
||||
? 'text-m-muted active:scale-95'
|
||||
: 'text-content-secondary hover:bg-inverse hover:text-inverse-text'
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
)
|
||||
// No tooltip on a phone: there is no hover to open it with, and the label is
|
||||
// already the button's accessible name.
|
||||
return phone ? button : <Tooltip label={label} placement="left">{button}</Tooltip>
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the data came from and how fresh it is, in one line under the title.
|
||||
*
|
||||
* Relative rather than a stamp: "checked 2 minutes ago" answers the question
|
||||
* somebody actually has, and "9/12/2026, 6:40:19 PM" makes them do the
|
||||
* subtraction themselves — on two lines, seconds included.
|
||||
*/
|
||||
function statusLine(
|
||||
state: ReturnType<typeof useDawarichSuggestions>,
|
||||
openCount: number,
|
||||
t: (key: string, params?: Record<string, unknown>) => string,
|
||||
locale: string,
|
||||
): string {
|
||||
if (state.status === 'loading') return t('dawarich.suggestions.loading')
|
||||
if (state.status === 'disconnected') return t('dawarich.suggestions.notConnected')
|
||||
if (state.status === 'offline') return t('dawarich.trail.offline')
|
||||
if (state.status === 'unavailable') return t('dawarich.suggestions.unavailable')
|
||||
|
||||
// Just the freshness. The count sits in the badge beside this line, and
|
||||
// gluing the two together with a separator said the same thing twice.
|
||||
return state.lastSyncAt
|
||||
? t('dawarich.checkedAgo', { ago: relativeTime(Date.parse(state.lastSyncAt), locale) })
|
||||
: t('dawarich.neverSynced')
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// FE-DAWARICH-ATLASMODEL-001 to FE-DAWARICH-ATLASMODEL-010
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { DawarichAtlasCountry, DawarichBucketMatch } from '@trek/shared'
|
||||
import {
|
||||
cityLine,
|
||||
countryLabel,
|
||||
countryWindow,
|
||||
formatDistance,
|
||||
newCountries,
|
||||
orderedMatches,
|
||||
} from './dawarichAtlasModel'
|
||||
|
||||
function match(over: Partial<DawarichBucketMatch> & { itemId: number }): DawarichBucketMatch {
|
||||
return {
|
||||
itemId: over.itemId,
|
||||
name: over.name ?? `Wish ${over.itemId}`,
|
||||
match: over.match === undefined ? { at: '2026-09-01T10:00:00Z', minutes: 90, distanceMeters: 40, points: 12 } : over.match,
|
||||
alreadyVisited: over.alreadyVisited ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
function country(over: Partial<DawarichAtlasCountry> & { countryCode: string }): DawarichAtlasCountry {
|
||||
return {
|
||||
countryCode: over.countryCode,
|
||||
sourceName: over.sourceName ?? over.countryCode,
|
||||
cities: over.cities ?? [],
|
||||
alreadyVisited: over.alreadyVisited ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
const t = (key: string, params?: Record<string, unknown>): string =>
|
||||
`${key}:${JSON.stringify(params ?? {})}`
|
||||
|
||||
describe('countryWindow', () => {
|
||||
it('FE-DAWARICH-ATLASMODEL-001: asks for exactly the year before the given moment', () => {
|
||||
const now = new Date('2026-09-12T18:00:00Z')
|
||||
const { from, to } = countryWindow(now)
|
||||
expect(to).toBe('2026-09-12T18:00:00.000Z')
|
||||
expect(from).toBe('2025-09-12T18:00:00.000Z')
|
||||
})
|
||||
})
|
||||
|
||||
describe('orderedMatches', () => {
|
||||
it('FE-DAWARICH-ATLASMODEL-002: drops wishes the recordings found nothing for', () => {
|
||||
const ordered = orderedMatches([match({ itemId: 1, match: null }), match({ itemId: 2 })])
|
||||
expect(ordered.map(m => m.itemId)).toEqual([2])
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASMODEL-003: open wishes lead, already ticked ones follow', () => {
|
||||
const ordered = orderedMatches([
|
||||
match({ itemId: 1, alreadyVisited: true }),
|
||||
match({ itemId: 2 }),
|
||||
])
|
||||
expect(ordered.map(m => m.itemId)).toEqual([2, 1])
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASMODEL-004: within a group the most recent visit comes first', () => {
|
||||
const ordered = orderedMatches([
|
||||
match({ itemId: 1, match: { at: '2026-01-02T10:00:00Z', minutes: 30, distanceMeters: 10, points: 3 } }),
|
||||
match({ itemId: 2, match: { at: '2026-06-02T10:00:00Z', minutes: 30, distanceMeters: 10, points: 3 } }),
|
||||
])
|
||||
expect(ordered.map(m => m.itemId)).toEqual([2, 1])
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASMODEL-005: leaves the caller\'s array untouched', () => {
|
||||
const input = [match({ itemId: 1, alreadyVisited: true }), match({ itemId: 2 })]
|
||||
orderedMatches(input)
|
||||
expect(input.map(m => m.itemId)).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('newCountries', () => {
|
||||
it('FE-DAWARICH-ATLASMODEL-006: only the ones a confirmation would actually change', () => {
|
||||
const offered = newCountries([
|
||||
country({ countryCode: 'DE', alreadyVisited: true }),
|
||||
country({ countryCode: 'NL' }),
|
||||
])
|
||||
expect(offered.map(c => c.countryCode)).toEqual(['NL'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('countryLabel', () => {
|
||||
it('FE-DAWARICH-ATLASMODEL-007: names the country in the reader\'s language', () => {
|
||||
expect(countryLabel('NL', 'Netherlands', 'de')).toBe('Niederlande')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASMODEL-008: falls back to Dawarich\'s own spelling for a code it cannot name', () => {
|
||||
// Not an ISO region: Intl either throws or echoes it back, and neither is a
|
||||
// name a reader would recognise.
|
||||
expect(countryLabel('ZZZZ', 'Freedonia', 'en')).toBe('Freedonia')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cityLine', () => {
|
||||
it('FE-DAWARICH-ATLASMODEL-009: lists the first few cities and stops', () => {
|
||||
const cities = ['Cologne', 'Bonn', 'Aachen', 'Trier'].map(name => ({
|
||||
name,
|
||||
minutes: 60,
|
||||
lastSeenAt: '2026-09-01T10:00:00Z',
|
||||
}))
|
||||
expect(cityLine(cities)).toBe('Cologne, Bonn, Aachen')
|
||||
expect(cityLine(cities, 1)).toBe('Cologne')
|
||||
expect(cityLine([])).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDistance', () => {
|
||||
it('FE-DAWARICH-ATLASMODEL-010: metres up to a kilometre, then kilometres with one decimal', () => {
|
||||
expect(formatDistance(49.4, t)).toBe('dawarich.bucket.metersAway:{"meters":49}')
|
||||
expect(formatDistance(999, t)).toBe('dawarich.bucket.metersAway:{"meters":999}')
|
||||
expect(formatDistance(1400, t)).toBe('dawarich.bucket.kilometersAway:{"km":"1.4"}')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { DawarichAtlasCountry, DawarichBucketMatch } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* Pure shaping for the Atlas dialog — no React, so the ordering, the naming and
|
||||
* the window arithmetic can be tested without rendering anything.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A year back from the given moment.
|
||||
*
|
||||
* Long enough to cover the trips someone would be filling in, short enough that
|
||||
* a self-hosted instance is not asked to aggregate a decade of points for a card
|
||||
* nobody was looking at. Takes `now` rather than reading the clock so the window
|
||||
* is testable.
|
||||
*/
|
||||
export function countryWindow(now: Date): { from: string; to: string } {
|
||||
const from = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000)
|
||||
return { from: from.toISOString(), to: now.toISOString() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The wishes worth showing, best first.
|
||||
*
|
||||
* Only entries the recordings actually found; the ones still open lead, because
|
||||
* they are the ones the reader can act on, and within each group the most recent
|
||||
* visit comes first — a wish reached last week is easier to confirm than one
|
||||
* reached ten months ago.
|
||||
*/
|
||||
export function orderedMatches(matches: DawarichBucketMatch[]): DawarichBucketMatch[] {
|
||||
return matches
|
||||
.filter(match => match.match !== null)
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
if (a.alreadyVisited !== b.alreadyVisited) return a.alreadyVisited ? 1 : -1
|
||||
return (b.match?.at ?? '') < (a.match?.at ?? '') ? -1 : 1
|
||||
})
|
||||
}
|
||||
|
||||
/** The countries a confirmation would actually change. */
|
||||
export function newCountries(countries: DawarichAtlasCountry[]): DawarichAtlasCountry[] {
|
||||
return countries.filter(country => !country.alreadyVisited)
|
||||
}
|
||||
|
||||
/**
|
||||
* A country's name in the reader's language, falling back to Dawarich's own
|
||||
* spelling.
|
||||
*
|
||||
* `Intl.DisplayNames` is missing on older WebViews and throws on a code it does
|
||||
* not know, so both are caught — the Atlas page's own resolver does the same.
|
||||
*/
|
||||
export function countryLabel(code: string, sourceName: string, locale: string): string {
|
||||
try {
|
||||
return new Intl.DisplayNames([locale], { type: 'region' }).of(code) || sourceName
|
||||
} catch {
|
||||
return sourceName
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The first few cities, as one line.
|
||||
*
|
||||
* Three is what fits beside a flag at the width the dialog has on a phone, and
|
||||
* the count badge beside it already says how many there were in total.
|
||||
*/
|
||||
export function cityLine(cities: DawarichAtlasCountry['cities'], limit = 3): string {
|
||||
return cities
|
||||
.slice(0, limit)
|
||||
.map(city => city.name)
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
* A distance a person would say out loud: metres up to a kilometre, then one
|
||||
* decimal of a kilometre. `1400 m away` is a measurement; `1.4 km away` is how
|
||||
* far it was.
|
||||
*/
|
||||
export function formatDistance(
|
||||
meters: number,
|
||||
t: (key: string, params?: Record<string, unknown>) => string,
|
||||
): string {
|
||||
return meters < 1000
|
||||
? t('dawarich.bucket.metersAway', { meters: Math.round(meters) })
|
||||
: t('dawarich.bucket.kilometersAway', { km: (meters / 1000).toFixed(1) })
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// FE-DAWARICH-SUGMODEL-001 to FE-DAWARICH-SUGMODEL-012
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { DawarichSuggestion } from '@trek/shared'
|
||||
import {
|
||||
clockOf,
|
||||
formatDayHeading,
|
||||
formatDayOption,
|
||||
formatDuration,
|
||||
groupByDay,
|
||||
timeRange,
|
||||
} from './dawarichSuggestionModel'
|
||||
|
||||
function stay(over: Partial<DawarichSuggestion> & { id: number; localDate: string }): DawarichSuggestion {
|
||||
return {
|
||||
id: over.id,
|
||||
sourceVisitId: String(over.id),
|
||||
tripId: over.tripId ?? null,
|
||||
tripTitle: over.tripTitle ?? null,
|
||||
name: over.name ?? 'Cafe Reichard',
|
||||
lat: over.lat ?? 50.94,
|
||||
lng: over.lng ?? 6.96,
|
||||
startedAt: over.startedAt ?? `${over.localDate}T10:15:00+02:00`,
|
||||
endedAt: over.endedAt ?? `${over.localDate}T12:40:00+02:00`,
|
||||
durationMinutes: over.durationMinutes ?? 145,
|
||||
localDate: over.localDate,
|
||||
sourceStatus: over.sourceStatus ?? 'suggested',
|
||||
confidence: over.confidence ?? null,
|
||||
confidenceBand: over.confidenceBand ?? null,
|
||||
state: over.state ?? 'new',
|
||||
target: over.target ?? null,
|
||||
acceptedPlaceId: over.acceptedPlaceId ?? null,
|
||||
acceptedJournalEntryId: over.acceptedJournalEntryId ?? null,
|
||||
acceptedBucketListItemId: over.acceptedBucketListItemId ?? null,
|
||||
sourceChanged: over.sourceChanged ?? false,
|
||||
sourceMissing: over.sourceMissing ?? false,
|
||||
matchedBucketListItemId: over.matchedBucketListItemId ?? null,
|
||||
matchedBucketListName: over.matchedBucketListName ?? null,
|
||||
countryCode: over.countryCode ?? null,
|
||||
firstSeenAt: over.firstSeenAt ?? '2026-09-10T06:00:00Z',
|
||||
lastSeenAt: over.lastSeenAt ?? '2026-09-10T06:00:00Z',
|
||||
}
|
||||
}
|
||||
|
||||
const t = (key: string, params?: Record<string, unknown>): string =>
|
||||
`${key}:${JSON.stringify(params ?? {})}`
|
||||
|
||||
describe('groupByDay', () => {
|
||||
it('FE-DAWARICH-SUGMODEL-001: newest day first, earliest stay first within a day', () => {
|
||||
const days = groupByDay([
|
||||
stay({ id: 1, localDate: '2026-09-10', startedAt: '2026-09-10T18:00:00+02:00' }),
|
||||
stay({ id: 2, localDate: '2026-09-11' }),
|
||||
stay({ id: 3, localDate: '2026-09-10', startedAt: '2026-09-10T09:00:00+02:00' }),
|
||||
])
|
||||
expect(days.map(d => d.date)).toEqual(['2026-09-11', '2026-09-10'])
|
||||
expect(days[1].stays.map(s => s.id)).toEqual([3, 1])
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-SUGMODEL-002: the colour counts from the oldest day, like the map does', () => {
|
||||
const days = groupByDay([
|
||||
stay({ id: 1, localDate: '2026-09-10' }),
|
||||
stay({ id: 2, localDate: '2026-09-11' }),
|
||||
])
|
||||
const oldest = days.find(d => d.date === '2026-09-10')!
|
||||
const newest = days.find(d => d.date === '2026-09-11')!
|
||||
expect(oldest.color).not.toBe(newest.color)
|
||||
// Same input in a different order paints the same day the same colour.
|
||||
const again = groupByDay([
|
||||
stay({ id: 2, localDate: '2026-09-11' }),
|
||||
stay({ id: 1, localDate: '2026-09-10' }),
|
||||
])
|
||||
expect(again.find(d => d.date === '2026-09-10')!.color).toBe(oldest.color)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-SUGMODEL-003: nothing in, nothing out', () => {
|
||||
expect(groupByDay([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('clockOf / timeRange', () => {
|
||||
it('FE-DAWARICH-SUGMODEL-004: reads the clock out of the timestamp\'s own offset', () => {
|
||||
expect(clockOf('2026-09-10T10:15:00+02:00')).toBe('10:15')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-SUGMODEL-005: an unreadable timestamp has no clock', () => {
|
||||
expect(clockOf('yesterday')).toBe('')
|
||||
expect(clockOf('')).toBe('')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-SUGMODEL-006: a range when both ends read, the arrival alone when the end does not', () => {
|
||||
expect(timeRange('2026-09-10T10:15:00+02:00', '2026-09-10T12:40:00+02:00')).toBe('10:15 – 12:40')
|
||||
expect(timeRange('2026-09-10T10:15:00+02:00', 'nonsense')).toBe('10:15')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-SUGMODEL-007: no arrival, no range', () => {
|
||||
expect(timeRange('nonsense', '2026-09-10T12:40:00+02:00')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDuration', () => {
|
||||
it('FE-DAWARICH-SUGMODEL-008: minutes under an hour, hours above it, both when there is a remainder', () => {
|
||||
expect(formatDuration(45, t)).toBe('dawarich.duration.minutes:{"minutes":45}')
|
||||
expect(formatDuration(120, t)).toBe('dawarich.duration.hours:{"hours":2}')
|
||||
expect(formatDuration(145, t)).toBe('dawarich.duration.hoursMinutes:{"hours":2,"minutes":25}')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDayHeading', () => {
|
||||
it('FE-DAWARICH-SUGMODEL-009: reads as the local date, not as yesterday', () => {
|
||||
// Built from the date parts rather than from a Date at midnight UTC, which
|
||||
// is the difference between "Thu, 10 Sept" and "Wed, 9 Sept" west of UTC.
|
||||
expect(formatDayHeading('2026-09-10', 'en-GB')).toContain('10')
|
||||
expect(formatDayHeading('2026-09-10', 'en-GB')).toMatch(/Thu/)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-SUGMODEL-010: an unparseable date is shown as it came', () => {
|
||||
expect(formatDayHeading('not-a-date', 'en-GB')).toBe('not-a-date')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDayOption', () => {
|
||||
it('FE-DAWARICH-SUGMODEL-011: the day number is the label, the date rides along as a badge', () => {
|
||||
const option = formatDayOption(3, '2026-09-10', 'en-GB', t)
|
||||
expect(option.label).toBe('planner.dayN:{"n":3}')
|
||||
expect(option.badge).toMatch(/10/)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-SUGMODEL-012: a day without a date gets no badge rather than an empty one', () => {
|
||||
expect(formatDayOption(1, null, 'en-GB', t)).toEqual({ label: 'planner.dayN:{"n":1}', badge: undefined })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { DawarichSuggestion } from '@trek/shared'
|
||||
import { dayColor } from '../Map/dawarichTrail'
|
||||
|
||||
/**
|
||||
* Pure shaping for the suggestions panel — no React, so the grouping and the
|
||||
* formatting can be tested without rendering anything.
|
||||
*/
|
||||
|
||||
export interface SuggestionDay {
|
||||
/** `YYYY-MM-DD`, the stay's own local date. */
|
||||
date: string
|
||||
/** The colour this day is drawn in on the map, so the panel and the line agree. */
|
||||
color: string
|
||||
stays: DawarichSuggestion[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Group stays by the local day they happened on, newest day first, and give each
|
||||
* day the colour the map draws it in.
|
||||
*
|
||||
* The grouping is what turns a flat list into something readable: a day of
|
||||
* travel produces four or five stays, and repeating the full date on every row
|
||||
* buries the one thing that distinguishes them — the time.
|
||||
*
|
||||
* The colour index counts days from the OLDEST, matching how the track overlay
|
||||
* numbers them, so "the purple line" and "the purple day" are the same day.
|
||||
*/
|
||||
export function groupByDay(suggestions: DawarichSuggestion[]): SuggestionDay[] {
|
||||
const byDate = new Map<string, DawarichSuggestion[]>()
|
||||
for (const stay of suggestions) {
|
||||
const bucket = byDate.get(stay.localDate)
|
||||
if (bucket) bucket.push(stay)
|
||||
else byDate.set(stay.localDate, [stay])
|
||||
}
|
||||
|
||||
const oldestFirst = [...byDate.keys()].sort()
|
||||
return oldestFirst
|
||||
.map((date, index) => ({
|
||||
date,
|
||||
color: dayColor(index),
|
||||
// Within a day, earliest first: that is the order they were lived in.
|
||||
stays: byDate.get(date)!.slice().sort((a, b) => (a.startedAt < b.startedAt ? -1 : 1)),
|
||||
}))
|
||||
.reverse()
|
||||
}
|
||||
|
||||
/** `HH:MM` out of an ISO timestamp, in the offset the timestamp carries. */
|
||||
export function clockOf(iso: string): string {
|
||||
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(iso) ? iso.slice(11, 16) : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* `10:15 – 12:40`, or just the arrival when the end is unreadable.
|
||||
*
|
||||
* An en dash with hair spaces rather than a hyphen: this is a range, and it sits
|
||||
* next to times often enough that the difference is visible.
|
||||
*/
|
||||
export function timeRange(startedAt: string, endedAt: string): string {
|
||||
const from = clockOf(startedAt)
|
||||
const to = clockOf(endedAt)
|
||||
if (!from) return ''
|
||||
return to ? `${from} – ${to}` : from
|
||||
}
|
||||
|
||||
/**
|
||||
* A duration a person would say out loud.
|
||||
*
|
||||
* "285 min" is a number from a database; "4 h 45 min" is how long somebody was
|
||||
* somewhere. The two label keys carry the units so a locale can put them where
|
||||
* it wants them.
|
||||
*/
|
||||
export function formatDuration(
|
||||
minutes: number,
|
||||
t: (key: string, params?: Record<string, unknown>) => string,
|
||||
): string {
|
||||
if (minutes < 60) return t('dawarich.duration.minutes', { minutes })
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const rest = minutes % 60
|
||||
return rest === 0
|
||||
? t('dawarich.duration.hours', { hours })
|
||||
: t('dawarich.duration.hoursMinutes', { hours, minutes: rest })
|
||||
}
|
||||
|
||||
/**
|
||||
* A stay's day header, in the reader's language: `Thu, 10 Sept`.
|
||||
*
|
||||
* Built from the date string rather than from a Date at midnight UTC, so a
|
||||
* reader east or west of UTC does not see yesterday.
|
||||
*/
|
||||
export function formatDayHeading(date: string, locale: string): string {
|
||||
const [year, month, day] = date.split('-').map(Number)
|
||||
if (!year || !month || !day) return date
|
||||
return new Date(year, month - 1, day).toLocaleDateString(locale, {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* How a trip day reads in the accept dialog's picker.
|
||||
*
|
||||
* `1. 2026-09-09` is a row out of a database. What a person recognises is the
|
||||
* number the planner gives the day and the weekday it fell on, which is exactly
|
||||
* what the day rail shows them — so the picker says the same thing.
|
||||
*/
|
||||
export function formatDayOption(
|
||||
dayNumber: number,
|
||||
date: string | null | undefined,
|
||||
locale: string,
|
||||
t: (key: string, params?: Record<string, unknown>) => string,
|
||||
): { label: string; badge?: string } {
|
||||
return {
|
||||
label: t('planner.dayN', { n: dayNumber }),
|
||||
badge: date ? formatDayHeading(date, locale) : undefined,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
|
||||
/**
|
||||
* Pull the trip back in after a stay was accepted into it.
|
||||
*
|
||||
* The acceptance is written server-side, so the planner knows nothing about the
|
||||
* place it produced until somebody reloads the page — which is exactly what a
|
||||
* person should never have to do after pressing a button. Both trip shells call
|
||||
* this, which is also why it is a plain function rather than a hook: one line at
|
||||
* each call site keeps the desktop and phone copies from drifting.
|
||||
*
|
||||
* Places and days both, because a stay accepted onto a day creates an assignment
|
||||
* as well, and the day rail would otherwise stay one place short.
|
||||
*/
|
||||
export async function refreshTripAfterAccept(tripId: number | string): Promise<void> {
|
||||
const store = useTripStore.getState()
|
||||
await Promise.all([store.refreshPlaces(tripId), store.refreshDays(tripId)])
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Polyline } from 'react-leaflet'
|
||||
import type { DawarichTrack } from '@trek/shared'
|
||||
import {
|
||||
DAWARICH_TRAIL_CASING,
|
||||
trailSegments,
|
||||
type DawarichTrailSegment,
|
||||
} from './dawarichTrail'
|
||||
|
||||
/**
|
||||
* The recorded route on the Leaflet renderer.
|
||||
*
|
||||
* Three lines per segment, the same construction the GPX tracks next door use:
|
||||
* a white casing so the line survives a satellite tile, the coloured line
|
||||
* itself, and nothing on top — this overlay is not clickable. That last part is
|
||||
* deliberate: a fat invisible hit-line would sit over the places and the
|
||||
* planned route, and swallow the clicks that select them, in exchange for a
|
||||
* popup nobody asked for.
|
||||
*
|
||||
* Dashed rather than solid. The planned route is already a solid line in the
|
||||
* user's accent, and the whole point of this layer is to be visibly the other
|
||||
* thing — what actually happened, next to what was planned. Telling them apart
|
||||
* has to survive being printed, screenshotted and looked at by someone who
|
||||
* cannot distinguish the two hues.
|
||||
*/
|
||||
export default function DawarichTrailLayer({
|
||||
track,
|
||||
selectedDate,
|
||||
casingPane,
|
||||
}: {
|
||||
track: DawarichTrack | null
|
||||
/** When set, only that local day is drawn. */
|
||||
selectedDate?: string | null
|
||||
/** The `trek-track-casing` pane, when the renderer supports panes. */
|
||||
casingPane?: string
|
||||
}) {
|
||||
const segments = useMemo(() => trailSegments(track, selectedDate), [track, selectedDate])
|
||||
if (segments.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{segments.map((segment: DawarichTrailSegment) => (
|
||||
<Polyline
|
||||
key={`dawarich-casing-${segment.id}`}
|
||||
positions={segment.points}
|
||||
pane={casingPane}
|
||||
// theme-lint-disable — map paint
|
||||
pathOptions={{
|
||||
color: DAWARICH_TRAIL_CASING,
|
||||
weight: 6,
|
||||
opacity: 0.55,
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round',
|
||||
}}
|
||||
interactive={false}
|
||||
/>
|
||||
))}
|
||||
{/* pathOptions rather than bare props: react-leaflet only calls setStyle
|
||||
when the pathOptions reference changes, so a bare `color` would stick
|
||||
at its mount-time value and a day recolour would never arrive. */}
|
||||
{segments.map((segment: DawarichTrailSegment) => (
|
||||
<Polyline
|
||||
key={`dawarich-line-${segment.id}`}
|
||||
positions={segment.points}
|
||||
pathOptions={{
|
||||
color: segment.color,
|
||||
weight: 3,
|
||||
opacity: 0.85,
|
||||
dashArray: '6 5',
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round',
|
||||
}}
|
||||
interactive={false}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
|
||||
import type { DawarichTrailStatus } from './useDawarichTrail'
|
||||
|
||||
/**
|
||||
* Toggle for the recorded-route overlay (#2279), in the same frosted shell as
|
||||
* the compass, the layer switcher and the trip-route overview so the map's
|
||||
* controls stay one family.
|
||||
*
|
||||
* The status rides on the button rather than in a banner: the states that
|
||||
* matter — loading, nothing recorded for these dates, instance unreachable,
|
||||
* device offline — are all answers to "why is there no line", and the place
|
||||
* someone looks for that answer is the control they just pressed.
|
||||
*/
|
||||
export function DawarichTrailPill({
|
||||
active,
|
||||
status,
|
||||
onToggle,
|
||||
}: {
|
||||
active: boolean
|
||||
status: DawarichTrailStatus
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const label = !active
|
||||
? t('dawarich.trail.show')
|
||||
: status === 'loading'
|
||||
? t('dawarich.trail.loading')
|
||||
: status === 'empty'
|
||||
? t('dawarich.trail.empty')
|
||||
: status === 'offline'
|
||||
? t('dawarich.trail.offline')
|
||||
: status === 'unavailable'
|
||||
? t('dawarich.trail.unavailable')
|
||||
: t('dawarich.trail.hide')
|
||||
|
||||
// A problem worth seeing without reading the tooltip: the layer is on and
|
||||
// there is nothing to show.
|
||||
const muted = active && (status === 'empty' || status === 'offline' || status === 'unavailable')
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
padding: 4,
|
||||
borderRadius: 999,
|
||||
pointerEvents: 'auto',
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||
boxShadow: MAP_CONTROL_SHADOW,
|
||||
}}
|
||||
>
|
||||
{/* TREK's own tooltip: the native one is unreadable in dark mode and never
|
||||
appears on a touch device, and this control's whole job is to explain
|
||||
why there is no line. */}
|
||||
<Tooltip label={label} placement="left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
data-testid="dawarich-trail-pill"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 999,
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
background: 'transparent',
|
||||
padding: 0,
|
||||
// The mark fills the circle and is clipped by it, so the control reads
|
||||
// as the Dawarich button rather than as a frame with a logo in it.
|
||||
overflow: 'hidden',
|
||||
// Its own colours stay; on/off is carried by opacity, because a
|
||||
// recoloured logo is a different logo.
|
||||
opacity: muted ? 0.35 : active ? 1 : 0.6,
|
||||
transition: 'opacity 0.14s, transform 0.14s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'none' }}
|
||||
>
|
||||
<DawarichIcon fill />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -53,6 +53,7 @@ import { NightPauseDrag } from './NightPauseDrag'
|
||||
import type { DayBoundaryControls } from './dayBoundaryDrag'
|
||||
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
||||
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
|
||||
import DawarichTrailLayer from './DawarichTrailLayer'
|
||||
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_MAXZOOM, AMAP_SATELLITE, attributionForTile } from '../../constants/mapDefaults'
|
||||
import { crsForBasemap } from './gcj02Crs'
|
||||
import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl'
|
||||
@@ -669,6 +670,8 @@ export const MapView = memo(function MapView({
|
||||
routeVias = [],
|
||||
dayBoundaryControls,
|
||||
hazards,
|
||||
dawarichTrack = null,
|
||||
dawarichSelectedDate = null,
|
||||
accessLines = [],
|
||||
onPoiDropOnRoute,
|
||||
onRouteClick,
|
||||
@@ -1179,6 +1182,15 @@ export const MapView = memo(function MapView({
|
||||
/>
|
||||
|
||||
{hazards?.length > 0 && <HazardLayers hazards={hazards} />}
|
||||
|
||||
{/* The route as it was actually recorded (#2279). Drawn in the casing
|
||||
pane's sibling order so it sits under the planned route rather than
|
||||
over it — the plan is what the user is editing. */}
|
||||
<DawarichTrailLayer
|
||||
track={dawarichTrack}
|
||||
selectedDate={dawarichSelectedDate}
|
||||
casingPane={hasCasingPane ? TRACK_CASING_PANE : undefined}
|
||||
/>
|
||||
<ClusteredPois pois={pois} enabled={clusterLoosely} onPoiClick={onPoiClick}>{poiMarkers}</ClusteredPois>
|
||||
{/* Charging stops / rest areas a plugin route places on the drawn day route.
|
||||
Host-vetted data (server-normalized), rendered as plain tone dots. */}
|
||||
|
||||
@@ -20,6 +20,9 @@ import { useTranslation } from '../../i18n/TranslationContext'
|
||||
export function MapViewAuto(props: any) {
|
||||
const { t } = useTranslation()
|
||||
const hazards = useRoadtripHazards(props.tripId, !!props.clusterLoosely)
|
||||
// `dawarichTrack` arrives as a prop rather than being fetched here: the pill
|
||||
// that switches it on lives at page level and needs the load status, so the
|
||||
// fetch sits in useTripPlanner and both shells read the same one.
|
||||
const mapProps = { ...props, hazards: hazards.feed?.hazards }
|
||||
const provider = useSettingsStore(s => s.settings.map_provider)
|
||||
const token = useSettingsStore(s => s.settings.mapbox_access_token)
|
||||
|
||||
@@ -34,6 +34,11 @@ const glMap = vi.hoisted(() => ({
|
||||
addSource: vi.fn(),
|
||||
getSource: vi.fn().mockReturnValue(null),
|
||||
addLayer: vi.fn(),
|
||||
// Every overlay that adds a layer takes it away again on cleanup; a real
|
||||
// MapLibre/Mapbox map has both halves, and a double with only the adding half
|
||||
// fails the teardown rather than the behaviour under test.
|
||||
removeLayer: vi.fn(),
|
||||
removeSource: vi.fn(),
|
||||
setLayoutProperty: vi.fn(),
|
||||
getStyle: vi.fn().mockReturnValue({ layers: [] }),
|
||||
isStyleLoaded: vi.fn().mockReturnValue(true),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useMemo, useState, createElement, useCallback } from 'react'
|
||||
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
import type { DawarichTrack, RoadtripVia } from '@trek/shared'
|
||||
import { useStableVias } from './viaMarkerState'
|
||||
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
|
||||
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays'
|
||||
@@ -30,6 +30,7 @@ import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker'
|
||||
import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters'
|
||||
import type { RoadtripHazard } from '@trek/shared'
|
||||
import { useHazardLayerGL } from './useHazardLayerGL'
|
||||
import { useDawarichTrailGL } from './useDawarichTrailGL'
|
||||
import { bindDayBoundaryDrag, type DayBoundaryControls } from './dayBoundaryDrag'
|
||||
import NightPauseTooltip from './NightPauseTooltip'
|
||||
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
||||
@@ -190,6 +191,10 @@ interface Props {
|
||||
*/
|
||||
clusterLoosely?: boolean
|
||||
hazards?: RoadtripHazard[]
|
||||
/** The route recorded in Dawarich, already fetched by MapViewAuto (#2279). */
|
||||
dawarichTrack?: DawarichTrack | null
|
||||
/** Draw only this local day of the recording. */
|
||||
dawarichSelectedDate?: string | null
|
||||
/** Via points to draw as draggable handles, keyed by day (#1797). */
|
||||
roadtripVias?: Record<number, RoadtripVia[]>
|
||||
onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void
|
||||
@@ -635,6 +640,8 @@ export function MapViewGL({
|
||||
focusPoints,
|
||||
clusterLoosely = false,
|
||||
hazards,
|
||||
dawarichTrack = null,
|
||||
dawarichSelectedDate = null,
|
||||
dayOrderMap = NO_DAY_ORDER,
|
||||
leftWidth = 0,
|
||||
rightWidth = 0,
|
||||
@@ -712,6 +719,8 @@ export function MapViewGL({
|
||||
const mapRef = useRef<any | null>(null)
|
||||
const hazardPopupFactory = useCallback(() => new gl.Popup({ className: 'map-tooltip trek-hazard-popup', maxWidth: '320px' }), [gl])
|
||||
useHazardLayerGL(mapRef.current, mapReady, hazards, hazardPopupFactory)
|
||||
// Beneath the planned route's casing, the GL twin of the Leaflet pane order.
|
||||
useDawarichTrailGL(mapRef.current, mapReady, dawarichTrack, dawarichSelectedDate, 'trip-route-casing')
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const markersRef = useRef<Map<number, PlacePin>>(new Map())
|
||||
// Own layer for the hand-positioned place pins (MapLibre path, see makePlacePin).
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Route } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { profileIcon } from '../Planner/DayPlanSidebarRouteConnector'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
|
||||
@@ -21,25 +22,30 @@ export function TripRouteOverviewPill({ active, onToggle }: { active: boolean; o
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||
boxShadow: MAP_CONTROL_SHADOW,
|
||||
}}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
aria-pressed={active}
|
||||
data-testid="trip-overview-pill"
|
||||
className={active ? 'text-accent' : 'text-content-muted'}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
|
||||
background: 'transparent', padding: 0,
|
||||
transition: 'background 0.14s, color 0.14s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<Route size={17} strokeWidth={2} />
|
||||
</button>
|
||||
{/* TREK's own tooltip, not the browser's — the native one ignores the
|
||||
colour scheme, waits a second and a half, and cannot be read on a touch
|
||||
device at all. `left`, because these controls hug the right edge of the
|
||||
map and a tooltip to the right would hang off it. */}
|
||||
<Tooltip label={label} placement="left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
data-testid="trip-overview-pill"
|
||||
className={active ? 'text-accent' : 'text-content-muted'}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
|
||||
background: 'transparent', padding: 0,
|
||||
transition: 'background 0.14s, color 0.14s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<Route size={17} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { DawarichTrack, DawarichTrackDay } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* Everything both map renderers need to agree on for the recorded-route
|
||||
* overlay, in one module.
|
||||
*
|
||||
* TREK draws no line twice the same way — Leaflet takes `<Polyline>` elements
|
||||
* and GL takes a GeoJSON source — so the *shape* cannot be shared. The colour,
|
||||
* the day ordering and the identity of a segment can, and they are exactly what
|
||||
* would drift between the two renderers if each carried its own copy.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The palette for the recorded route.
|
||||
*
|
||||
* Deliberately not derived from `var(--accent)`: the planned route already uses
|
||||
* the accent, and the whole point of this overlay is that it reads as *the
|
||||
* other thing* — what happened, next to what was planned. These are the same
|
||||
* hues Dawarich itself uses for its track modes, which makes the two products
|
||||
* agree about a line the user has seen in both.
|
||||
*
|
||||
* theme-lint-disable — map paint: Leaflet path options and GL paint
|
||||
* expressions cannot read a CSS variable, and the map is the one surface where
|
||||
* a fixed palette is the documented exception (client/src/theme/README.md).
|
||||
*/
|
||||
export const DAWARICH_TRAIL_COLOR = '#2563EB' // theme-lint-disable — map paint
|
||||
/** The white casing under the line, so it stays visible over a satellite tile. */
|
||||
export const DAWARICH_TRAIL_CASING = '#FFFFFF' // theme-lint-disable — map paint
|
||||
|
||||
/**
|
||||
* Per-day hues, cycled.
|
||||
*
|
||||
* A multi-day trip drawn in one colour is a scribble; per-day colours make "we
|
||||
* were here on Tuesday" readable at a glance. Six is enough to tell adjacent
|
||||
* days apart and few enough that the map does not turn into a rainbow.
|
||||
*
|
||||
* theme-lint-disable — map paint, same reason as above.
|
||||
*/
|
||||
export const DAWARICH_DAY_COLORS = [
|
||||
'#2563EB', // theme-lint-disable — map paint
|
||||
'#7C3AED', // theme-lint-disable — map paint
|
||||
'#0891B2', // theme-lint-disable — map paint
|
||||
'#DB2777', // theme-lint-disable — map paint
|
||||
'#EA580C', // theme-lint-disable — map paint
|
||||
'#059669', // theme-lint-disable — map paint
|
||||
] as const
|
||||
|
||||
export function dayColor(index: number): string {
|
||||
return DAWARICH_DAY_COLORS[index % DAWARICH_DAY_COLORS.length]!
|
||||
}
|
||||
|
||||
/** One drawable piece of the overlay, flattened out of the day/segment nesting. */
|
||||
export interface DawarichTrailSegment {
|
||||
/** Stable across refetches, so a renderer can key on it instead of on array order. */
|
||||
id: string
|
||||
date: string
|
||||
color: string
|
||||
/** `[lat, lng]`, TREK's internal order. GeoJSON flips it at the boundary. */
|
||||
points: Array<[number, number]>
|
||||
mode: string | null
|
||||
startedAt: string
|
||||
endedAt: string
|
||||
distanceMeters: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a fetched track into drawable segments, optionally limited to one day.
|
||||
*
|
||||
* The day filter is what the timeline scrubber drives: selecting a day on the
|
||||
* planner should dim the rest of the trip rather than reload it, so the filter
|
||||
* happens here over data that is already in memory.
|
||||
*/
|
||||
export function trailSegments(
|
||||
track: DawarichTrack | null,
|
||||
onlyDate?: string | null,
|
||||
): DawarichTrailSegment[] {
|
||||
if (!track) return []
|
||||
const days: DawarichTrackDay[] = onlyDate
|
||||
? track.days.filter(day => day.date === onlyDate)
|
||||
: track.days
|
||||
|
||||
const out: DawarichTrailSegment[] = []
|
||||
for (const day of days) {
|
||||
// The colour index comes from the day's position in the WHOLE track, not in
|
||||
// the filtered list — otherwise picking a day would repaint it.
|
||||
const index = track.days.findIndex(candidate => candidate.date === day.date)
|
||||
day.segments.forEach((segment, i) => {
|
||||
// Zod's tuple infers as "maybe two numbers, maybe more", so the pairs are
|
||||
// narrowed at runtime rather than cast. In practice the schema has already
|
||||
// guaranteed both are numbers; this is what makes the type honest without
|
||||
// an `as` at the boundary.
|
||||
const points = segment.points.flatMap((pair): Array<[number, number]> =>
|
||||
typeof pair[0] === 'number' && typeof pair[1] === 'number' ? [[pair[0], pair[1]]] : [],
|
||||
)
|
||||
if (points.length < 2) return
|
||||
out.push({
|
||||
id: `${day.date}-${i}`,
|
||||
date: day.date,
|
||||
color: dayColor(index < 0 ? 0 : index),
|
||||
points,
|
||||
mode: segment.mode,
|
||||
startedAt: segment.startedAt,
|
||||
endedAt: segment.endedAt,
|
||||
distanceMeters: segment.distanceMeters,
|
||||
})
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The overlay as a GeoJSON FeatureCollection, for the GL renderer. */
|
||||
export function trailGeoJson(segments: DawarichTrailSegment[]): {
|
||||
type: 'FeatureCollection'
|
||||
features: Array<{
|
||||
type: 'Feature'
|
||||
properties: { id: string; color: string; date: string; mode: string | null }
|
||||
geometry: { type: 'LineString'; coordinates: Array<[number, number]> }
|
||||
}>
|
||||
} {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: segments.map(segment => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: segment.id,
|
||||
color: segment.color,
|
||||
date: segment.date,
|
||||
mode: segment.mode,
|
||||
},
|
||||
geometry: {
|
||||
type: 'LineString' as const,
|
||||
// Internal order is [lat, lng] everywhere in TREK; GeoJSON wants the
|
||||
// other one, and this is the single place the overlay flips it.
|
||||
coordinates: segment.points.map((point): [number, number] => [point[1], point[0]]),
|
||||
},
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/** Total kilometres the overlay covers, for the panel that reports it. */
|
||||
export function trailDistanceKm(segments: DawarichTrailSegment[]): number | null {
|
||||
const known = segments.filter(segment => segment.distanceMeters !== null)
|
||||
if (known.length === 0) return null
|
||||
return known.reduce((sum, segment) => sum + (segment.distanceMeters ?? 0), 0) / 1000
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { DawarichTrack } from '@trek/shared'
|
||||
import { dawarichRepo } from '../../repo/dawarichRepo'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { isEffectivelyOffline, onNetworkModeChange } from '../../sync/networkMode'
|
||||
|
||||
export type DawarichTrailStatus = 'idle' | 'loading' | 'ready' | 'empty' | 'unavailable' | 'offline'
|
||||
|
||||
export interface DawarichTrailState {
|
||||
track: DawarichTrack | null
|
||||
status: DawarichTrailStatus
|
||||
/** ISO-8601 of the last fetch that actually returned something. */
|
||||
fetchedAt: string | null
|
||||
reload: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The recorded route for a trip, fetched while the overlay is on.
|
||||
*
|
||||
* Called **once** in `MapViewAuto` and mirrored into both renderers as a prop —
|
||||
* the same shape `useRoadtripHazards` uses, and for the same reason: two
|
||||
* renderers each fetching would double the load on somebody's self-hosted
|
||||
* instance and could draw two different answers.
|
||||
*
|
||||
* Refreshes on a timer while the layer is on, so a trip in progress catches up
|
||||
* without anyone reloading the page. Two minutes rather than seconds: this
|
||||
* reaches across the network to a server the user runs, and the server caches
|
||||
* for a minute anyway, so a tighter loop would buy nothing and cost them.
|
||||
*/
|
||||
const REFRESH_MS = 120_000
|
||||
|
||||
export function useDawarichTrail(
|
||||
tripId: number | undefined,
|
||||
enabled: boolean,
|
||||
range?: { from?: string; to?: string },
|
||||
): DawarichTrailState {
|
||||
const addonEnabled = useAddonStore(s => s.isEnabled)
|
||||
const active = enabled && !!tripId && addonEnabled('dawarich')
|
||||
|
||||
const [track, setTrack] = useState<DawarichTrack | null>(null)
|
||||
const [status, setStatus] = useState<DawarichTrailStatus>('idle')
|
||||
const [fetchedAt, setFetchedAt] = useState<string | null>(null)
|
||||
const [reloadToken, setReloadToken] = useState(0)
|
||||
|
||||
// Kept in a ref so changing the window does not re-create the effect's
|
||||
// identity on every render of the parent — the object literal would.
|
||||
const rangeRef = useRef(range)
|
||||
rangeRef.current = range
|
||||
const rangeKey = `${range?.from ?? ''}|${range?.to ?? ''}`
|
||||
|
||||
const reload = useCallback(() => setReloadToken(token => token + 1), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !tripId) {
|
||||
setTrack(null)
|
||||
setStatus('idle')
|
||||
return
|
||||
}
|
||||
|
||||
let controller: AbortController | null = null
|
||||
let cancelled = false
|
||||
|
||||
const load = () => {
|
||||
controller?.abort()
|
||||
controller = new AbortController()
|
||||
const signal = controller.signal
|
||||
|
||||
if (isEffectivelyOffline()) {
|
||||
setStatus('offline')
|
||||
return
|
||||
}
|
||||
// The previous line stays on screen while the next one loads: blanking it
|
||||
// makes a refresh look like the recording disappeared.
|
||||
setStatus(current => (current === 'ready' ? 'ready' : 'loading'))
|
||||
|
||||
dawarichRepo
|
||||
.tripTrack(tripId, rangeRef.current, signal)
|
||||
.then(result => {
|
||||
if (cancelled || signal.aborted) return
|
||||
setTrack(result)
|
||||
setFetchedAt(result.fetchedAt)
|
||||
setStatus(result.days.length > 0 ? 'ready' : 'empty')
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled || signal.aborted) return
|
||||
// Keep whatever was drawn. A line from two minutes ago is more useful
|
||||
// than an empty map, and the status is what says it may be stale.
|
||||
setStatus(isEffectivelyOffline() ? 'offline' : 'unavailable')
|
||||
})
|
||||
}
|
||||
|
||||
load()
|
||||
const timer = window.setInterval(load, REFRESH_MS)
|
||||
const unsubscribe = onNetworkModeChange(load)
|
||||
return () => {
|
||||
cancelled = true
|
||||
controller?.abort()
|
||||
window.clearInterval(timer)
|
||||
unsubscribe()
|
||||
}
|
||||
}, [active, tripId, rangeKey, reloadToken])
|
||||
|
||||
return { track: active ? track : null, status: active ? status : 'idle', fetchedAt, reload }
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect } from 'react'
|
||||
import type { Map } from 'mapbox-gl'
|
||||
import type { GeoJSONSource } from 'mapbox-gl'
|
||||
import type { DawarichTrack } from '@trek/shared'
|
||||
import { DAWARICH_TRAIL_CASING, trailGeoJson, trailSegments } from './dawarichTrail'
|
||||
|
||||
/**
|
||||
* The recorded route on the GL renderer — the imperative twin of
|
||||
* `DawarichTrailLayer`.
|
||||
*
|
||||
* The ids start with `trek-` because `applySatellite` finds the first layer
|
||||
* whose id carries one of TREK's own prefixes and inserts the satellite tile
|
||||
* beneath it. A layer named `dawarich-trail` would end up *under* the
|
||||
* satellite image and simply not be there when someone switched basemap on.
|
||||
*
|
||||
* Everything is rebuilt on `style.load`, because the whole GL map is torn down
|
||||
* and reassembled when the provider, the style or the token changes, and a
|
||||
* source added outside that effect does not survive it.
|
||||
*/
|
||||
const SOURCE = 'trek-dawarich-trail'
|
||||
const CASING_LAYER = 'trek-dawarich-trail-casing'
|
||||
const LINE_LAYER = 'trek-dawarich-trail-line'
|
||||
|
||||
export function useDawarichTrailGL(
|
||||
map: Map | null,
|
||||
ready: boolean,
|
||||
track: DawarichTrack | null | undefined,
|
||||
selectedDate?: string | null,
|
||||
/** Insert beneath this layer, so the planned route stays on top. */
|
||||
beforeId?: string,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!map || !ready) return
|
||||
|
||||
const data = trailGeoJson(trailSegments(track ?? null, selectedDate))
|
||||
|
||||
const draw = () => {
|
||||
if (!map.isStyleLoaded()) return
|
||||
const existing = map.getSource(SOURCE) as GeoJSONSource | undefined
|
||||
if (existing) {
|
||||
// setData rather than remove-and-add: a rebuilt source flickers, and a
|
||||
// refresh every two minutes would flicker every two minutes.
|
||||
existing.setData(data as never)
|
||||
return
|
||||
}
|
||||
map.addSource(SOURCE, { type: 'geojson', data: data as never })
|
||||
const anchor = beforeId && map.getLayer(beforeId) ? beforeId : undefined
|
||||
map.addLayer(
|
||||
{
|
||||
id: CASING_LAYER,
|
||||
type: 'line',
|
||||
source: SOURCE,
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
// theme-lint-disable — map paint
|
||||
paint: { 'line-color': DAWARICH_TRAIL_CASING, 'line-width': 6, 'line-opacity': 0.55 },
|
||||
},
|
||||
anchor,
|
||||
)
|
||||
map.addLayer(
|
||||
{
|
||||
id: LINE_LAYER,
|
||||
type: 'line',
|
||||
source: SOURCE,
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
paint: {
|
||||
// Per-day colour off the feature, with the same fallback the GPX
|
||||
// layer uses when a feature somehow carries none.
|
||||
'line-color': ['coalesce', ['get', 'color'], DAWARICH_TRAIL_CASING],
|
||||
'line-width': 3,
|
||||
'line-opacity': 0.85,
|
||||
// Dashed, so the recording is distinguishable from the planned
|
||||
// route without relying on colour alone.
|
||||
'line-dasharray': [2, 1.6],
|
||||
},
|
||||
},
|
||||
anchor,
|
||||
)
|
||||
}
|
||||
|
||||
draw()
|
||||
map.on('style.load', draw)
|
||||
return () => {
|
||||
map.off('style.load', draw)
|
||||
for (const layer of [LINE_LAYER, CASING_LAYER]) {
|
||||
if (map.getLayer(layer)) map.removeLayer(layer)
|
||||
}
|
||||
if (map.getSource(SOURCE)) map.removeSource(SOURCE)
|
||||
}
|
||||
}, [map, ready, track, selectedDate, beforeId])
|
||||
}
|
||||
@@ -512,13 +512,16 @@ describe('DayPlanSidebar', () => {
|
||||
const onToggleConnection = vi.fn()
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [res as any], onOpenTransit: vi.fn(), onToggleConnection, visibleConnectionIds: [] })} />)
|
||||
// No map-connections toggle on transit rows — the expander replaces it.
|
||||
expect(screen.queryByTitle(/connections/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText(/connections/i)).not.toBeInTheDocument()
|
||||
// Collapsed: no stop names beyond the chips.
|
||||
expect(screen.queryByText('Alexanderplatz')).not.toBeInTheDocument()
|
||||
await user.click(screen.getByLabelText('Expand'))
|
||||
expect(await screen.findByText('Alexanderplatz')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Platform 2/)).toBeInTheDocument()
|
||||
await user.click(screen.getByLabelText('Collapse'))
|
||||
// The day's own chevron carries the same accessible name, so pick the one
|
||||
// that is not in the day's action block.
|
||||
const rowCollapse = screen.getAllByLabelText('Collapse').find(el => !el.closest('.dp-day-actions'))!
|
||||
await user.click(rowCollapse)
|
||||
expect(screen.queryByText('Alexanderplatz')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -2159,7 +2162,7 @@ describe('DayPlanSidebar', () => {
|
||||
days: [day], places: [place], assignments: { '10': [assignment] }, reservations: [res],
|
||||
onEditReservation, onEditTransport,
|
||||
})} />)
|
||||
const pencil = screen.getByTitle(/edit/i)
|
||||
const pencil = screen.getByLabelText(/edit/i)
|
||||
await user.click(pencil)
|
||||
expect(onEditReservation).toHaveBeenCalledWith(res)
|
||||
expect(onEditTransport).not.toHaveBeenCalled()
|
||||
@@ -2177,7 +2180,7 @@ describe('DayPlanSidebar', () => {
|
||||
days: [day], places: [place], assignments: { '10': [assignment] }, reservations: [res],
|
||||
onEditReservation, onEditTransport,
|
||||
})} />)
|
||||
const pencil = screen.getByTitle(/edit/i)
|
||||
const pencil = screen.getByLabelText(/edit/i)
|
||||
await user.click(pencil)
|
||||
expect(onEditTransport).toHaveBeenCalledWith(res)
|
||||
expect(onEditReservation).not.toHaveBeenCalled()
|
||||
@@ -3435,7 +3438,7 @@ describe('DayPlanSidebar', () => {
|
||||
const { rerender } = render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day], reservations: [flight], onToggleConnection,
|
||||
})} />)
|
||||
const show = screen.getByTitle('Show booking routes')
|
||||
const show = screen.getByLabelText('Show booking routes')
|
||||
await user.click(show)
|
||||
expect(onToggleConnection).toHaveBeenCalledWith(510)
|
||||
fireEvent.mouseEnter(show)
|
||||
@@ -3444,7 +3447,7 @@ describe('DayPlanSidebar', () => {
|
||||
rerender(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day], reservations: [flight], onToggleConnection, visibleConnectionIds: [510],
|
||||
})} />)
|
||||
expect(screen.getByTitle('Hide booking routes')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Hide booking routes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-157: a non-transport booking row opens the reservation editor, unless read-only', async () => {
|
||||
@@ -3560,9 +3563,9 @@ describe('DayPlanSidebar', () => {
|
||||
})} />)
|
||||
expect(screen.getByText('08:30 – 10:05')).toBeInTheDocument()
|
||||
expect(screen.getByText('Air France AF1235')).toBeInTheDocument()
|
||||
await user.click(screen.getByTitle('Show booking routes'))
|
||||
await user.click(screen.getByLabelText('Show booking routes'))
|
||||
expect(onToggleConnection).toHaveBeenCalledWith(520)
|
||||
const edit = screen.getByTitle('Edit')
|
||||
const edit = screen.getByLabelText('Edit')
|
||||
fireEvent.mouseEnter(edit)
|
||||
fireEvent.mouseLeave(edit)
|
||||
await user.click(edit)
|
||||
@@ -3608,7 +3611,7 @@ describe('DayPlanSidebar', () => {
|
||||
expect(screen.getByText(/Reservation confirmed/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Reservation pending/)).toBeInTheDocument()
|
||||
// Earliest first, so the first pencil belongs to the parking pass.
|
||||
await user.click(screen.getAllByTitle('Edit')[0])
|
||||
await user.click(screen.getAllByLabelText('Edit')[0])
|
||||
expect(onEditReservation).toHaveBeenCalledWith(parking)
|
||||
})
|
||||
|
||||
@@ -3636,12 +3639,12 @@ describe('DayPlanSidebar', () => {
|
||||
days: [day], places: [place], assignments: { '10': [a] }, onAddBookingToAssignment,
|
||||
})} />)
|
||||
const row = dragRow(screen.getByText('Hover place'))
|
||||
expect(screen.queryByTitle('Add booking')).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('Add booking')).not.toBeInTheDocument()
|
||||
fireEvent.mouseEnter(row)
|
||||
await user.click(screen.getByTitle('Add booking'))
|
||||
await user.click(screen.getByLabelText('Add booking'))
|
||||
expect(onAddBookingToAssignment).toHaveBeenCalledWith(10, 11)
|
||||
fireEvent.mouseLeave(row)
|
||||
expect(screen.queryByTitle('Add booking')).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('Add booking')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-166: the lock tooltip switches once a stop is pinned, and unlocking clears it', async () => {
|
||||
@@ -3797,7 +3800,7 @@ describe('DayPlanSidebar', () => {
|
||||
],
|
||||
}
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], assignments, selectedDayId: 10, routeShown: true })} />)
|
||||
const connector = await screen.findByTitle('Change travel mode')
|
||||
const connector = await screen.findByLabelText('Change travel mode')
|
||||
await user.click(connector)
|
||||
await user.click(contextMenu().getByRole('button', { name: 'Walking' }))
|
||||
expect(vi.mocked(assignmentsApi.updateTransport)).toHaveBeenCalledWith(1, 11, 'walking')
|
||||
@@ -3805,7 +3808,7 @@ describe('DayPlanSidebar', () => {
|
||||
'10': [expect.objectContaining({ id: 11, leg_transport_mode: 'walking' }), expect.objectContaining({ id: 12 })],
|
||||
})
|
||||
|
||||
await user.click(await screen.findByTitle('Change travel mode'))
|
||||
await user.click(await screen.findByLabelText('Change travel mode'))
|
||||
await user.click(contextMenu().getByRole('button', { name: 'Use day default' }))
|
||||
expect(vi.mocked(assignmentsApi.updateTransport)).toHaveBeenLastCalledWith(1, 11, null)
|
||||
})
|
||||
@@ -3821,7 +3824,7 @@ describe('DayPlanSidebar', () => {
|
||||
],
|
||||
}
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], assignments, selectedDayId: 10, routeShown: true, onPlanTransitLeg })} />)
|
||||
await user.click(await screen.findByTitle('Change travel mode'))
|
||||
await user.click(await screen.findByLabelText('Change travel mode'))
|
||||
await user.click(contextMenu().getByRole('button', { name: 'Public transit' }))
|
||||
// Origin/destination + this stop's departure time, resolved back from the leg coords.
|
||||
expect(onPlanTransitLeg).toHaveBeenCalledWith({
|
||||
@@ -3842,7 +3845,7 @@ describe('DayPlanSidebar', () => {
|
||||
],
|
||||
}
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], assignments, selectedDayId: 10, routeShown: true })} />)
|
||||
await user.click(await screen.findByTitle('Change travel mode'))
|
||||
await user.click(await screen.findByLabelText('Change travel mode'))
|
||||
expect(contextMenu().queryByRole('button', { name: 'Public transit' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -3866,7 +3869,7 @@ describe('DayPlanSidebar', () => {
|
||||
}
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days, assignments, accommodations, selectedDayId: 11, routeShown: true, onPlanTransitLeg })} />)
|
||||
// The morning bookend (hotel -> first stop) is the first connector in the list.
|
||||
const connectors = await screen.findAllByTitle('Change travel mode')
|
||||
const connectors = await screen.findAllByLabelText('Change travel mode')
|
||||
await user.click(connectors[0])
|
||||
await user.click(contextMenu().getByRole('button', { name: 'Public transit' }))
|
||||
expect(onPlanTransitLeg).toHaveBeenCalledWith(expect.objectContaining({
|
||||
@@ -3898,7 +3901,7 @@ describe('DayPlanSidebar', () => {
|
||||
],
|
||||
}
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days, assignments, selectedDayId: 11, routeShown: true, onPlanTransitLeg })} />)
|
||||
await user.click(await screen.findByTitle('Change travel mode'))
|
||||
await user.click(await screen.findByLabelText('Change travel mode'))
|
||||
await user.click(contextMenu().getByRole('button', { name: 'Public transit' }))
|
||||
// Day 11's own 16:30, not day 10's 09:00 (a trip-wide coord index would leak it).
|
||||
expect(onPlanTransitLeg).toHaveBeenCalledWith(expect.objectContaining({ dayId: 11, time: '16:30' }))
|
||||
@@ -3918,7 +3921,7 @@ describe('DayPlanSidebar', () => {
|
||||
],
|
||||
}
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], assignments, selectedDayId: 10, routeShown: true })} />)
|
||||
await user.click(await screen.findByTitle('Change travel mode'))
|
||||
await user.click(await screen.findByLabelText('Change travel mode'))
|
||||
await user.click(contextMenu().getByRole('button', { name: 'Driving' }))
|
||||
await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('nope'))
|
||||
expect(refreshDays).toHaveBeenCalledWith(1)
|
||||
@@ -3942,7 +3945,7 @@ describe('DayPlanSidebar', () => {
|
||||
})} />)
|
||||
await user.click(screen.getByRole('button', { name: 'EV eco' }))
|
||||
expect(onSetRouteProfile).toHaveBeenCalledWith('plugin:ev/eco')
|
||||
await user.click(await screen.findByTitle('Change travel mode'))
|
||||
await user.click(await screen.findByLabelText('Change travel mode'))
|
||||
expect(contextMenu().getByRole('button', { name: 'EV eco' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -4025,7 +4028,7 @@ describe('DayPlanSidebar', () => {
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const onAddTransport = vi.fn()
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onAddTransport })} />)
|
||||
await user.click(screen.getByTitle('Add transport'))
|
||||
await user.click(screen.getByLabelText('Add transport'))
|
||||
expect(onAddTransport).toHaveBeenCalledWith(10)
|
||||
})
|
||||
|
||||
@@ -4244,7 +4247,7 @@ describe('DayPlanSidebar', () => {
|
||||
fireEvent.mouseEnter(expand)
|
||||
fireEvent.mouseLeave(expand)
|
||||
await user.click(expand)
|
||||
expect(screen.getByLabelText('Collapse')).toBeInTheDocument()
|
||||
expect(screen.getAllByLabelText('Collapse').some(el => !el.closest('.dp-day-actions'))).toBe(true)
|
||||
await user.click(cardRow(screen.getByText('U2 to Zoo')))
|
||||
expect(await screen.findByText('Alexanderplatz')).toBeInTheDocument()
|
||||
})
|
||||
@@ -4490,10 +4493,13 @@ describe('the day route-tools row', () => {
|
||||
it('keeps the optimize action reachable by name without printing it', () => {
|
||||
render(<DayPlanSidebar {...makeDefaultProps(dayWithTwoStops())} />)
|
||||
const btn = screen.getByRole('button', { name: /optimize/i })
|
||||
// The assertion that pins the change: named, but no visible label.
|
||||
// The assertion that pins the change: named, but no visible label. The name
|
||||
// is `aria-label` rather than `title` — the hover text is TREK's own tooltip
|
||||
// now, and a browser tooltip is not an accessible name anyway.
|
||||
expect(btn).toBeInTheDocument()
|
||||
expect(btn.textContent?.trim()).toBe('')
|
||||
expect(btn.getAttribute('title')).toBeTruthy()
|
||||
expect(btn.getAttribute('aria-label')).toBeTruthy()
|
||||
expect(btn.getAttribute('title')).toBeNull()
|
||||
})
|
||||
|
||||
/* The click itself is already pinned by FE-PLANNER-DAYPLAN-038 above, which
|
||||
|
||||
@@ -29,6 +29,7 @@ import { usePluginStore } from '../../store/pluginStore'
|
||||
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'
|
||||
import { placeToSaveTarget } from '../Collections/saveTarget'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { isDayInAccommodationRange, getAccommodationAnchors, getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder'
|
||||
import {
|
||||
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getDisplayTimeForDay, getTransportRouteEndpoints,
|
||||
@@ -1189,8 +1190,34 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Below this the plan sidebar has no room for a word on the route button.
|
||||
*
|
||||
* The word goes early on purpose: it shares its row with two export buttons and
|
||||
* an optimise button, and a "Route" squeezed to four letters and an ellipsis is
|
||||
* worse than the icon that means the same thing. The icon keeps the accessible
|
||||
* name, so nothing is lost but the letters.
|
||||
*/
|
||||
const NARROW_PLAN_PX = 320
|
||||
|
||||
const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const S = useDayPlanSidebar(props)
|
||||
// The plan sidebar is resizable. Below this the route button's own word is the
|
||||
// first thing that stops fitting beside the two export buttons — the icon says
|
||||
// the same thing, and the accessible name still spells it out.
|
||||
//
|
||||
// A callback ref rather than `useRef`, so the measurement happens when the
|
||||
// element exists rather than on a mount that may render nothing yet.
|
||||
const [panel, setPanel] = useState<HTMLElement | null>(null)
|
||||
const [narrowPanel, setNarrowPanel] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!panel || typeof ResizeObserver === 'undefined') return
|
||||
const measure = (): void => setNarrowPanel(panel.clientWidth < NARROW_PLAN_PX)
|
||||
measure()
|
||||
const ro = new ResizeObserver(measure)
|
||||
ro.observe(panel)
|
||||
return () => ro.disconnect()
|
||||
}, [panel])
|
||||
// A stable key for the current selection. A multi-day place renders one row per
|
||||
// day (same place_id, different assignment ids); selecting it by place_id alone
|
||||
// (e.g. clicking an accommodation) marks every one of those rows selected, so a
|
||||
@@ -1504,7 +1531,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-touch-drag={dragDisabled ? undefined : ''} style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "var(--font-system)" }}>
|
||||
<div ref={setPanel} data-touch-drag={dragDisabled ? undefined : ''} style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "var(--font-system)" }}>
|
||||
{/* Toolbar */}
|
||||
<DayPlanSidebarToolbar
|
||||
tripId={tripId}
|
||||
@@ -1790,19 +1817,23 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
{/* Public transit search (#1065) — replaced the rename pencil,
|
||||
which moved next to the day name in the day detail view. */}
|
||||
{onPlanTransit ? (
|
||||
<button type="button" onClick={e => { e.stopPropagation(); onPlanTransit(day.id) }} title={t('transit.title')} aria-label={t('transit.title')} style={{ ...cell, border: 'none', borderRight: div, borderBottom: div }}>
|
||||
<TramFront size={14} strokeWidth={1.8} />
|
||||
</button>
|
||||
<Tooltip label={t('transit.title')} placement="top">
|
||||
<button type="button" onClick={e => { e.stopPropagation(); onPlanTransit(day.id) }} aria-label={t('transit.title')} style={{ ...cell, border: 'none', borderRight: div, borderBottom: div }}>
|
||||
<TramFront size={14} strokeWidth={1.8} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : <div style={{ borderRight: div, borderBottom: div }} />}
|
||||
{onAddTransport ? (
|
||||
<button type="button" onClick={e => { e.stopPropagation(); onAddTransport(day.id) }} title={t('transport.addTransport')} style={{ ...cell, border: 'none', borderBottom: div }}>
|
||||
<Plus size={14} strokeWidth={1.8} />
|
||||
</button>
|
||||
<Tooltip label={t('transport.addTransport')} placement="top">
|
||||
<button type="button" onClick={e => { e.stopPropagation(); onAddTransport(day.id) }} style={{ ...cell, border: 'none', borderBottom: div }} aria-label={t('transport.addTransport')}>
|
||||
<Plus size={14} strokeWidth={1.8} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : <div style={{ borderBottom: div }} />}
|
||||
<button type="button" onClick={e => openAddNote(day.id, e)} aria-label={t('dayplan.addNote')} style={{ ...cell, border: 'none', borderRight: div }}>
|
||||
<FileText size={14} strokeWidth={1.8} />
|
||||
</button>
|
||||
<button type="button" onClick={e => toggleDay(day.id, e)} title={isExpanded ? t('common.collapse') : t('common.expand')} style={{ ...cell, border: 'none' }}>
|
||||
<button type="button" onClick={e => toggleDay(day.id, e)} style={{ ...cell, border: 'none' }} aria-label={isExpanded ? t('common.collapse') : t('common.expand')}>
|
||||
{isExpanded ? <ChevronDown size={15} strokeWidth={1.8} /> : <ChevronRight size={15} strokeWidth={1.8} />}
|
||||
</button>
|
||||
</div>
|
||||
@@ -1885,9 +1916,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
const targetId = hotelLegs[day.id]?.top?.targetId
|
||||
const connector = <HotelRouteConnector seg={hotelLegs[day.id]!.top!.seg} name={hotelLegs[day.id]!.top!.name} profile={routeProfile} placement="top" />
|
||||
return canEditDays && targetId != null ? (
|
||||
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openIncomingLegModeMenu(e, targetId, day.id, hotelLegs[day.id]!.top!.seg)} onKeyDown={e => openLegMenuByKey(e, m => openIncomingLegModeMenu(m, targetId, day.id, hotelLegs[day.id]!.top!.seg))} style={{ cursor: 'pointer' }}>
|
||||
{connector}
|
||||
</div>
|
||||
<Tooltip label={t('dayplan.transportMode.change')} placement="top">
|
||||
<div role="button" tabIndex={0} onClick={e => openIncomingLegModeMenu(e, targetId, day.id, hotelLegs[day.id]!.top!.seg)} onKeyDown={e => openLegMenuByKey(e, m => openIncomingLegModeMenu(m, targetId, day.id, hotelLegs[day.id]!.top!.seg))} style={{ cursor: 'pointer' }} aria-label={t('dayplan.transportMode.change')}>
|
||||
{connector}
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : connector
|
||||
})()}
|
||||
{daySchedule.byPosition[day.id]?.start.map(si => <PluginDayScheduleRow key={`${si.pluginId}:${si.id}`} item={si} />)}
|
||||
@@ -2221,34 +2254,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
{timeLabel && <span className={tint} style={{ ...chip, fontWeight: 500 }}>{timeLabel}</span>}
|
||||
{carrierLabel && <span className={tint} style={{ ...chip, fontWeight: 500 }}>{carrierLabel}</span>}
|
||||
{hasEndpoints && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); onToggleConnection!(res.id) }}
|
||||
title={t(active ? 'map.hideConnections' : 'map.showConnections')}
|
||||
className={active ? 'bg-[#3b82f6] text-[#fff]' : 'bg-transparent text-content-faint'}
|
||||
style={{
|
||||
flexShrink: 0, appearance: 'none',
|
||||
width: 20, height: 20, borderRadius: 4,
|
||||
display: 'grid', placeItems: 'center', cursor: 'pointer',
|
||||
border: 'none',
|
||||
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
>
|
||||
<RouteIcon size={11} />
|
||||
</button>
|
||||
)}
|
||||
{canEditDays && (() => {
|
||||
const isTransport = TRANSPORT_TYPES.has(res.type)
|
||||
const handler = isTransport ? onEditTransport : onEditReservation
|
||||
if (!handler) return null
|
||||
return (
|
||||
<button
|
||||
<Tooltip label={t(active ? 'map.hideConnections' : 'map.showConnections')} placement="top">
|
||||
<button aria-label={t(active ? 'map.hideConnections' : 'map.showConnections')}
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); handler(res) }}
|
||||
title={t('common.edit')}
|
||||
className="bg-transparent text-content-faint"
|
||||
onClick={e => { e.stopPropagation(); onToggleConnection!(res.id) }}
|
||||
className={active ? 'bg-[#3b82f6] text-[#fff]' : 'bg-transparent text-content-faint'}
|
||||
style={{
|
||||
flexShrink: 0, appearance: 'none',
|
||||
width: 20, height: 20, borderRadius: 4,
|
||||
@@ -2256,11 +2266,36 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
border: 'none',
|
||||
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
>
|
||||
<Pencil size={11} />
|
||||
<RouteIcon size={11} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canEditDays && (() => {
|
||||
const isTransport = TRANSPORT_TYPES.has(res.type)
|
||||
const handler = isTransport ? onEditTransport : onEditReservation
|
||||
if (!handler) return null
|
||||
return (
|
||||
<Tooltip label={t('common.edit')} placement="top">
|
||||
<button aria-label={t('common.edit')}
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); handler(res) }}
|
||||
className="bg-transparent text-content-faint"
|
||||
style={{
|
||||
flexShrink: 0, appearance: 'none',
|
||||
width: 20, height: 20, borderRadius: 4,
|
||||
display: 'grid', placeItems: 'center', cursor: 'pointer',
|
||||
border: 'none',
|
||||
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
@@ -2296,37 +2331,40 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
</button>
|
||||
</div>}
|
||||
{canEditDays && onAddBookingToAssignment && hoveredAssignmentId === assignment.id && (
|
||||
<button type="button"
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onAddBookingToAssignment(day.id, assignment.id)
|
||||
}}
|
||||
title={t('reservations.addBooking')}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
background: 'none',
|
||||
border: '1px solid var(--border-primary)',
|
||||
borderRadius: 5,
|
||||
padding: '2px 6px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 3,
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 500,
|
||||
color: 'var(--text-muted)',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Plus size={11} strokeWidth={2} />
|
||||
</button>
|
||||
<Tooltip label={t('reservations.addBooking')} placement="top">
|
||||
<button type="button" aria-label={t('reservations.addBooking')}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onAddBookingToAssignment(day.id, assignment.id)
|
||||
}}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
background: 'none',
|
||||
border: '1px solid var(--border-primary)',
|
||||
borderRadius: 5,
|
||||
padding: '2px 6px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 3,
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 500,
|
||||
color: 'var(--text-muted)',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Plus size={11} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{daySchedule.byAssignment[day.id]?.[assignment.id]?.map(si => <PluginDayScheduleRow key={`${si.pluginId}:${si.id}`} item={si} />)}
|
||||
{routeLegs[day.id]?.[assignment.id] && (canEditDays ? (
|
||||
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openLegModeMenu(e, assignment.id, day.id, routeLegs[day.id]![assignment.id])} onKeyDown={e => openLegMenuByKey(e, m => openLegModeMenu(m, assignment.id, day.id, routeLegs[day.id]![assignment.id]))} style={{ cursor: 'pointer' }}>
|
||||
<RouteConnector seg={routeLegs[day.id]![assignment.id]} profile={routeProfile} />
|
||||
</div>
|
||||
<Tooltip label={t('dayplan.transportMode.change')} placement="top">
|
||||
<div role="button" tabIndex={0} onClick={e => openLegModeMenu(e, assignment.id, day.id, routeLegs[day.id]![assignment.id])} onKeyDown={e => openLegMenuByKey(e, m => openLegModeMenu(m, assignment.id, day.id, routeLegs[day.id]![assignment.id]))} style={{ cursor: 'pointer' }} aria-label={t('dayplan.transportMode.change')}>
|
||||
<RouteConnector seg={routeLegs[day.id]![assignment.id]} profile={routeProfile} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<RouteConnector seg={routeLegs[day.id]![assignment.id]} profile={routeProfile} />
|
||||
))}
|
||||
@@ -2532,7 +2570,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
return next
|
||||
})
|
||||
}}
|
||||
title={t(expanded ? 'common.collapse' : 'common.expand')}
|
||||
aria-label={t(expanded ? 'common.collapse' : 'common.expand')}
|
||||
style={{
|
||||
flexShrink: 0, appearance: 'none', width: 26, height: 26, borderRadius: 6,
|
||||
@@ -2549,24 +2586,25 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
{!transitMeta && onToggleConnection && (!res.__leg || res.__leg.index === 0) && (res.endpoints || []).length >= 2 && (() => {
|
||||
const active = visibleConnectionIds.includes(res.id)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); onToggleConnection(res.id) }}
|
||||
title={t(active ? 'map.hideConnections' : 'map.showConnections')}
|
||||
style={{
|
||||
flexShrink: 0, appearance: 'none',
|
||||
width: 26, height: 26, borderRadius: 6,
|
||||
display: 'grid', placeItems: 'center', cursor: 'pointer',
|
||||
border: 'none',
|
||||
background: active ? color : 'transparent',
|
||||
color: active ? '#fff' : 'var(--text-faint)',
|
||||
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
>
|
||||
<RouteIcon size={13} />
|
||||
</button>
|
||||
<Tooltip label={t(active ? 'map.hideConnections' : 'map.showConnections')} placement="top">
|
||||
<button aria-label={t(active ? 'map.hideConnections' : 'map.showConnections')}
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); onToggleConnection(res.id) }}
|
||||
style={{
|
||||
flexShrink: 0, appearance: 'none',
|
||||
width: 26, height: 26, borderRadius: 6,
|
||||
display: 'grid', placeItems: 'center', cursor: 'pointer',
|
||||
border: 'none',
|
||||
background: active ? color : 'transparent',
|
||||
color: active ? '#fff' : 'var(--text-faint)',
|
||||
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
>
|
||||
<RouteIcon size={13} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
@@ -2580,9 +2618,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
const nextPlaceId = merged.slice(idx + 1).find(i => i.type === 'place' && i.data.place?.lat && i.data.place?.lng)?.data.id
|
||||
const connector = <RouteConnector seg={routeLegs[day.id]![res.id]} profile={routeProfile} />
|
||||
return canEditDays && nextPlaceId != null ? (
|
||||
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openIncomingLegModeMenu(e, Number(nextPlaceId), day.id, routeLegs[day.id]![res.id])} onKeyDown={e => openLegMenuByKey(e, m => openIncomingLegModeMenu(m, Number(nextPlaceId), day.id, routeLegs[day.id]![res.id]))} style={{ cursor: 'pointer' }}>
|
||||
{connector}
|
||||
</div>
|
||||
<Tooltip label={t('dayplan.transportMode.change')} placement="top">
|
||||
<div role="button" tabIndex={0} onClick={e => openIncomingLegModeMenu(e, Number(nextPlaceId), day.id, routeLegs[day.id]![res.id])} onKeyDown={e => openLegMenuByKey(e, m => openIncomingLegModeMenu(m, Number(nextPlaceId), day.id, routeLegs[day.id]![res.id]))} style={{ cursor: 'pointer' }} aria-label={t('dayplan.transportMode.change')}>
|
||||
{connector}
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : connector
|
||||
})()}
|
||||
</React.Fragment>
|
||||
@@ -2717,9 +2757,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
const targetId = hotelLegs[day.id]?.bottom?.targetId
|
||||
const connector = <HotelRouteConnector seg={hotelLegs[day.id]!.bottom!.seg} name={hotelLegs[day.id]!.bottom!.name} profile={routeProfile} placement="bottom" />
|
||||
return canEditDays && targetId != null ? (
|
||||
<div role="button" tabIndex={0} title={t('dayplan.transportMode.change')} onClick={e => openLegModeMenu(e, targetId, day.id, hotelLegs[day.id]!.bottom!.seg)} onKeyDown={e => openLegMenuByKey(e, m => openLegModeMenu(m, targetId, day.id, hotelLegs[day.id]!.bottom!.seg))} style={{ cursor: 'pointer' }}>
|
||||
{connector}
|
||||
</div>
|
||||
<Tooltip label={t('dayplan.transportMode.change')} placement="top">
|
||||
<div role="button" tabIndex={0} onClick={e => openLegModeMenu(e, targetId, day.id, hotelLegs[day.id]!.bottom!.seg)} onKeyDown={e => openLegMenuByKey(e, m => openLegModeMenu(m, targetId, day.id, hotelLegs[day.id]!.bottom!.seg))} style={{ cursor: 'pointer' }} aria-label={t('dayplan.transportMode.change')}>
|
||||
{connector}
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : connector
|
||||
})()}
|
||||
{/* Drop-Zone am Listenende — immer vorhanden als Drop-Target */}
|
||||
@@ -2786,6 +2828,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
onToggleRoute?.()
|
||||
}
|
||||
}}
|
||||
aria-label={t('dayplan.route')}
|
||||
className={routeActive ? 'bg-accent text-accent-text' : 'bg-transparent text-content-secondary'}
|
||||
style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
@@ -2795,81 +2838,85 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
}}
|
||||
>
|
||||
<RouteIcon size={12} strokeWidth={2} />
|
||||
{t('dayplan.route')}
|
||||
{!narrowPanel && t('dayplan.route')}
|
||||
</button>
|
||||
{/* Open the day's stops as a route in Google Maps (planned order). #1255 */}
|
||||
<button type="button"
|
||||
onClick={() => {
|
||||
const url = generateGoogleMapsUrl(dayExportStops())
|
||||
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}}
|
||||
aria-label={t('planner.openGoogleMaps')}
|
||||
title={t('planner.openGoogleMaps')}
|
||||
className="bg-transparent text-content-secondary"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-faint)',
|
||||
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<GoogleMapsIcon size={14} />
|
||||
</button>
|
||||
<Tooltip label={t('planner.openGoogleMaps')} placement="top">
|
||||
<button type="button"
|
||||
onClick={() => {
|
||||
const url = generateGoogleMapsUrl(dayExportStops())
|
||||
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}}
|
||||
aria-label={t('planner.openGoogleMaps')}
|
||||
className="bg-transparent text-content-secondary"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-faint)',
|
||||
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<GoogleMapsIcon size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/* The same day, handed to CoMaps for offline navigation (#1904). The
|
||||
day's own travel mode rides along, so the route it builds walks
|
||||
when the plan walks. */}
|
||||
<button type="button"
|
||||
onClick={() => {
|
||||
const url = generateCoMapsUrl(dayExportStops(), day.default_transport_mode ?? routeProfile)
|
||||
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}}
|
||||
aria-label={t('planner.openCoMaps')}
|
||||
title={t('planner.openCoMaps')}
|
||||
className="bg-transparent text-content-secondary"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-faint)',
|
||||
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Compass size={14} strokeWidth={2} />
|
||||
</button>
|
||||
<Tooltip label={t('planner.openCoMaps')} placement="top">
|
||||
<button type="button"
|
||||
onClick={() => {
|
||||
const url = generateCoMapsUrl(dayExportStops(), day.default_transport_mode ?? routeProfile)
|
||||
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}}
|
||||
aria-label={t('planner.openCoMaps')}
|
||||
className="bg-transparent text-content-secondary"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-faint)',
|
||||
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Compass size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/* Icon-only, like the two map hand-offs beside it (#1981). It
|
||||
was the one button here carrying a label with no room for
|
||||
it: `flex: 1` alongside `padding: '6px 0'` meant the text
|
||||
sat against both edges as soon as the row filled up, and
|
||||
the row gained a button when CoMaps arrived. The label
|
||||
stays as the accessible name and the tooltip. */}
|
||||
<button type="button"
|
||||
onClick={() => handleOptimize(day.id)}
|
||||
aria-label={t('dayplan.optimize')}
|
||||
title={t('dayplan.optimize')}
|
||||
className="bg-surface-hover text-content-secondary"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '6px 10px', borderRadius: 8, border: 'none',
|
||||
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={14} strokeWidth={2} />
|
||||
</button>
|
||||
<Tooltip label={t('dayplan.optimize')} placement="top">
|
||||
<button type="button"
|
||||
onClick={() => handleOptimize(day.id)}
|
||||
aria-label={t('dayplan.optimize')}
|
||||
className="bg-surface-hover text-content-secondary"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '6px 10px', borderRadius: 8, border: 'none',
|
||||
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<div style={{ display: 'flex', borderRadius: 8, overflow: 'hidden', border: '1px solid var(--border-faint)', flexShrink: 0 }}>
|
||||
{routeProfileOptions.map(p => {
|
||||
const ModeIcon = p.key === 'driving' ? Car : p.key === 'walking' ? Footprints : Zap
|
||||
const active = (day.default_transport_mode ?? routeProfile) === p.key
|
||||
return (
|
||||
<button type="button"
|
||||
key={p.key}
|
||||
onClick={() => setDayDefaultMode(day.id, p.key)}
|
||||
aria-label={p.label}
|
||||
title={p.label}
|
||||
className={active ? 'bg-accent text-accent-text' : 'bg-transparent text-content-secondary'}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '6px 10px', border: 'none', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<ModeIcon size={13} strokeWidth={2} />
|
||||
</button>
|
||||
<Tooltip label={p.label} placement="top">
|
||||
<button type="button"
|
||||
key={p.key}
|
||||
onClick={() => setDayDefaultMode(day.id, p.key)}
|
||||
aria-label={p.label}
|
||||
className={active ? 'bg-accent text-accent-text' : 'bg-transparent text-content-secondary'}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '6px 10px', border: 'none', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<ModeIcon size={13} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,8 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation, translateApiError } from '../../i18n'
|
||||
import { usePluginStore } from '../../store/pluginStore'
|
||||
@@ -796,6 +798,17 @@ function PlaceInspectorHeader({ openNow, place, category, t, editingName, nameIn
|
||||
style={{ fontWeight: 600, fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', lineHeight: '1.3', cursor: onUpdatePlace ? 'text' : 'default' }}
|
||||
>{place.name}</span>
|
||||
)}
|
||||
{/* Where the place came from, when it did not come from somebody
|
||||
typing it: a stay accepted out of their own recordings. The
|
||||
mark alone — the name beside it is already the place's name,
|
||||
and a word here would only repeat the tooltip. */}
|
||||
{place.source === 'dawarich' && (
|
||||
<Tooltip label={t('dawarich.place.fromDawarich')} placement="top">
|
||||
<span style={{ display: 'inline-flex', flexShrink: 0, overflow: 'hidden', borderRadius: 5 }}>
|
||||
<DawarichIcon size={16} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{category && (() => {
|
||||
const CatIcon = getCategoryIcon(category.icon)
|
||||
return (
|
||||
|
||||
@@ -10,6 +10,10 @@ import { MobileDayPickerSheet } from './PlacesSidebarMobileDayPicker'
|
||||
import { ListImportModal } from './PlacesSidebarListImportModal'
|
||||
import { PlacesBulkCategoryModal } from './PlacesBulkCategoryModal'
|
||||
import SaveTripPlacesToListModal from '../Collections/SaveTripPlacesToListModal'
|
||||
import DawarichSuggestionsPanel from '../Dawarich/DawarichSuggestionsPanel'
|
||||
import { formatDayOption } from '../Dawarich/dawarichSuggestionModel'
|
||||
import { refreshTripAfterAccept } from '../Dawarich/dawarichTripRefresh'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProps) {
|
||||
const S = usePlacesSidebar(props)
|
||||
@@ -19,8 +23,10 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
|
||||
fileImportOpen, setFileImportOpen, sidebarDropFile, setSidebarDropFile, tripId, pushUndo,
|
||||
ctxMenu, isMobile, pendingDeleteIds, setPendingDeleteIds, onBulkDeleteConfirm,
|
||||
categories, selectedIds, exitSelectMode, onBulkChangeCategory, categoryPickerOpen, setCategoryPickerOpen,
|
||||
collectionsEnabled, saveToListOpen, setSaveToListOpen,
|
||||
collectionsEnabled, saveToListOpen, setSaveToListOpen, days,
|
||||
} = S
|
||||
// The sidebar hook carries `t` but not the locale; day labels need both.
|
||||
const { locale } = useTranslation()
|
||||
// Below lg the places sit in their own tab with no plan beside them to drag
|
||||
// into. A coarse pointer no longer disables the drag on its own — tablets
|
||||
// reach it through a long press (#1616).
|
||||
@@ -59,6 +65,26 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stays Dawarich recorded on these dates, waiting to be reviewed (#2279).
|
||||
Above the list because that is what they become, and the panel renders
|
||||
nothing at all when there is nothing pending — an integration that is
|
||||
connected and quiet should be invisible rather than a permanent empty
|
||||
card. */}
|
||||
<div style={{ padding: '0 12px 8px', flexShrink: 0 }}>
|
||||
<DawarichSuggestionsPanel
|
||||
tripId={tripId}
|
||||
trips={[{ id: tripId, label: t('dawarich.accept.thisTrip') }]}
|
||||
daysForTrip={() => days.map(day => ({
|
||||
id: day.id,
|
||||
...formatDayOption(day.day_number, day.date, locale, t),
|
||||
}))}
|
||||
// The place it just created belongs on the map and in the list
|
||||
// now, not after a reload.
|
||||
onAccepted={() => { void refreshTripAfterAccept(tripId) }}
|
||||
initiallyCollapsed
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Liste */}
|
||||
<PlacesList {...S} />
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Plane, Save } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { airtrailApi } from '../../api/client'
|
||||
import AirTrailIcon from '../shared/AirTrailIcon'
|
||||
import Section from './Section'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
|
||||
@@ -78,7 +79,7 @@ export default function AirTrailConnectionSection(): React.ReactElement {
|
||||
const canSave = !!url.trim() && (connected || !!apiKey.trim())
|
||||
|
||||
return (
|
||||
<Section title={t('settings.airtrail.title')} icon={Plane}>
|
||||
<Section title={t('settings.airtrail.title')} icon={AirTrailIcon}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.airtrail.url')}</label>
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import React from 'react'
|
||||
import { Save, Plug, RefreshCw, Unplug } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useDawarichConnection } from '../../hooks/useDawarichConnection'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import Section from './Section'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
|
||||
/**
|
||||
* Settings → Integrations → Dawarich.
|
||||
*
|
||||
* All of the behaviour is in `useDawarichConnection`, shared with the phone
|
||||
* twin; this file is markup. The layout follows the AirTrail and LLM sections
|
||||
* so the three read as one shelf, but the classes are the semantic tokens
|
||||
* rather than the raw `slate-*` those two still carry — a new surface has to
|
||||
* follow the user's accent and colour scheme.
|
||||
*
|
||||
* The status block below the fields is the part that earns its space: a
|
||||
* connection to somebody else's server fails in ways they can act on — a wrong
|
||||
* key, a certificate, an instance that is simply off — and "not connected" with
|
||||
* no reason is the version of this card that generates support questions.
|
||||
*/
|
||||
export default function DawarichConnectionSection(): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const S = useDawarichConnection()
|
||||
|
||||
return (
|
||||
<Section title={t('dawarich.title')} icon={DawarichIcon}>
|
||||
<div className="space-y-3">
|
||||
<p className="text-caption text-content-secondary">{t('dawarich.intro')}</p>
|
||||
|
||||
<div>
|
||||
<label htmlFor="dawarich-url" className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.url')}
|
||||
</label>
|
||||
<input
|
||||
id="dawarich-url"
|
||||
type="url"
|
||||
value={S.url}
|
||||
onChange={e => S.setUrl(e.target.value)}
|
||||
placeholder="https://dawarich.example.com"
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-body focus:outline-none focus:ring-2 ring-accent border-edge bg-surface-input text-content"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="dawarich-key" className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.apiKey')}
|
||||
</label>
|
||||
<input
|
||||
id="dawarich-key"
|
||||
type="password"
|
||||
value={S.apiKey}
|
||||
onChange={e => S.setApiKey(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={S.connected && !S.apiKey ? '••••••••' : t('dawarich.apiKeyPlaceholder')}
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-body focus:outline-none focus:ring-2 ring-accent border-edge bg-surface-input text-content"
|
||||
/>
|
||||
<p className="mt-1 text-caption text-content-muted">{t('dawarich.apiKeyHint')}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ToggleSwitch on={S.syncEnabled} onToggle={S.toggleSync} label={t('dawarich.syncEnabled')} />
|
||||
<span className="text-body font-medium text-content-secondary">{t('dawarich.syncEnabled')}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-caption text-content-muted">{t('dawarich.syncEnabledHint')}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ToggleSwitch on={S.allowInsecureTls} onToggle={S.toggleInsecureTls} label={t('dawarich.allowInsecureTls')} />
|
||||
<span className="text-body font-medium text-content-secondary">{t('dawarich.allowInsecureTls')}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-caption text-content-muted">{t('dawarich.allowInsecureTlsHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={S.save}
|
||||
disabled={S.saving || S.loading || !S.canSave}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-body font-medium bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" /> {t('common.save')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={S.test}
|
||||
disabled={S.testing || S.loading || !S.url.trim()}
|
||||
className="flex items-center gap-2 px-4 py-2 border rounded-lg text-body border-edge text-content-secondary hover:bg-surface-hover disabled:opacity-50"
|
||||
>
|
||||
{S.testing
|
||||
? <span className="w-4 h-4 border-2 rounded-full animate-spin border-edge border-t-transparent" />
|
||||
: <Plug className="w-4 h-4" />}
|
||||
{t('dawarich.test.button')}
|
||||
</button>
|
||||
|
||||
{S.connected && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={S.syncNow}
|
||||
disabled={S.syncing}
|
||||
className="flex items-center gap-2 px-4 py-2 border rounded-lg text-body border-edge text-content-secondary hover:bg-surface-hover disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${S.syncing ? 'animate-spin' : ''}`} />
|
||||
{t('dawarich.syncNow')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<span className="basis-full sm:basis-auto text-caption font-medium flex items-center gap-1.5">
|
||||
<span className={`w-2 h-2 rounded-full ${S.connected ? 'bg-success' : 'bg-surface-tertiary'}`} />
|
||||
<span className={S.connected ? 'text-success' : 'text-content-muted'}>
|
||||
{S.connected ? t('dawarich.connected') : t('dawarich.notConnected')}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{S.connected && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={S.disconnect}
|
||||
disabled={S.saving}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-caption text-danger hover:bg-danger-soft disabled:opacity-50"
|
||||
>
|
||||
<Unplug className="w-3.5 h-3.5" /> {t('dawarich.disconnect')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DawarichConnectionStatus state={S} locale={locale} />
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the connection stands, in the place where it helps someone decide what
|
||||
* to do next: what the last sync did, and which parts of their instance TREK
|
||||
* could actually reach.
|
||||
*
|
||||
* Its own component because the phone twin renders the same three facts in a
|
||||
* different frame, and because it is the bit that keeps growing.
|
||||
*/
|
||||
function DawarichConnectionStatus({
|
||||
state,
|
||||
locale,
|
||||
}: {
|
||||
state: ReturnType<typeof useDawarichConnection>
|
||||
locale: string
|
||||
}): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
if (!state.connected && !state.probeMessage) return null
|
||||
|
||||
const missing: string[] = []
|
||||
if (state.capabilities) {
|
||||
if (!state.capabilities.visits) missing.push(t('dawarich.capability.visits'))
|
||||
if (!state.capabilities.tracks && !state.capabilities.points) missing.push(t('dawarich.capability.track'))
|
||||
if (!state.capabilities.locations) missing.push(t('dawarich.capability.locations'))
|
||||
if (!state.capabilities.visitedCities) missing.push(t('dawarich.capability.visitedCities'))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border p-3 space-y-1.5 border-edge bg-surface-secondary">
|
||||
{state.probeMessage && <p className="text-caption text-content">{state.probeMessage}</p>}
|
||||
|
||||
{state.connected && (
|
||||
<p className="text-caption text-content-secondary">
|
||||
{state.lastSyncAt
|
||||
? t('dawarich.lastSync', { when: new Date(state.lastSyncAt).toLocaleString(locale) })
|
||||
: t('dawarich.neverSynced')}
|
||||
{state.lastSyncState === 'partial' && ` · ${t('dawarich.syncPartial')}`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{state.lastSyncError && (
|
||||
<p className="text-caption text-danger">{state.lastSyncError}</p>
|
||||
)}
|
||||
|
||||
{state.capabilities?.serverVersion && (
|
||||
<p className="text-caption text-content-muted">
|
||||
{t('dawarich.serverVersion', { version: state.capabilities.serverVersion })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{missing.length > 0 && (
|
||||
<p className="text-caption text-content-muted">
|
||||
{t('dawarich.capability.missing', { features: missing.join(', ') })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { authApi, oauthApi } from '../../api/client'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import PhotoProvidersSection from './PhotoProvidersSection'
|
||||
import AirTrailConnectionSection from './AirTrailConnectionSection'
|
||||
import DawarichConnectionSection from './DawarichConnectionSection'
|
||||
import LlmConnectionSection from './LlmConnectionSection'
|
||||
import ApiKeysSection from './ApiKeysSection'
|
||||
import { PRESET_SCOPES_DEFAULT, PRESET_SCOPES_READONLY } from '../../api/oauthScopes'
|
||||
@@ -106,6 +107,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
these would ask for an address that cannot be reached from here. */}
|
||||
{!managed && <PhotoProvidersSection />}
|
||||
{S.airtrailEnabled && !managed && <AirTrailConnectionSection />}
|
||||
{S.dawarichEnabled && !managed && <DawarichConnectionSection />}
|
||||
{/* Which model reads a booking, and what that costs, comes with the instance on
|
||||
a managed install. The per-user fallback exists for people who supply their
|
||||
own key, and there nobody does. */}
|
||||
@@ -127,6 +129,7 @@ function useIntegrations() {
|
||||
const mcpEnabled = addonEnabled('mcp')
|
||||
const airtrailEnabled = addonEnabled('airtrail')
|
||||
const llmEnabled = addonEnabled('llm_parsing')
|
||||
const dawarichEnabled = addonEnabled('dawarich')
|
||||
|
||||
useEffect(() => {
|
||||
loadAddons()
|
||||
@@ -307,7 +310,7 @@ function useIntegrations() {
|
||||
|
||||
|
||||
return {
|
||||
t, locale, toast, mcpEnabled, airtrailEnabled, llmEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
|
||||
t, locale, toast, mcpEnabled, airtrailEnabled, llmEnabled, dawarichEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@ import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
interface SectionProps {
|
||||
title: string
|
||||
icon: LucideIcon
|
||||
/**
|
||||
* A lucide icon, or anything that takes the same `className` — a brand mark
|
||||
* standing in for a glyph gets sized by the same utilities.
|
||||
*/
|
||||
icon: LucideIcon | React.ComponentType<{ className?: string }>
|
||||
badge?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
import BrandIcon, { type BrandIconProps } from './BrandIcon'
|
||||
|
||||
/**
|
||||
* AirTrail's own mark, unmodified: the control tower in its blue.
|
||||
*
|
||||
* The file is `static/favicon.svg` from johanohly/AirTrail, served from
|
||||
* `public/brands/`. Rendered as a file rather than as lucide's `TowerControl`
|
||||
* recoloured: the two happen to be the same shape today, and the day AirTrail
|
||||
* changes its logo TREK should follow the logo, not the coincidence.
|
||||
*/
|
||||
export default function AirTrailIcon(props: BrandIconProps): React.ReactElement {
|
||||
return <BrandIcon src="/brands/airtrail.svg" {...props} />
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from 'react'
|
||||
|
||||
export interface BrandIconProps {
|
||||
size?: number
|
||||
/**
|
||||
* Fill the box it sits in, corner to corner.
|
||||
*
|
||||
* A brand mark that is itself a badge should BE the framed slot rather than
|
||||
* float in the middle of one — which is what the lucide line icons beside it
|
||||
* need, and it does not.
|
||||
*/
|
||||
fill?: boolean
|
||||
/**
|
||||
* Drain the colour, for a switched-off addon. Every other tile greys its glyph
|
||||
* out; a full-colour badge among them reads as the one thing that is on.
|
||||
*/
|
||||
muted?: boolean
|
||||
/**
|
||||
* Sizing classes, so the mark can stand in wherever a lucide icon is expected
|
||||
* (`w-5 h-5 …`). When set it wins over `size`.
|
||||
*/
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A third party's own logo, served as a file and rendered unmodified.
|
||||
*
|
||||
* The files under `public/brands/` are the marks as each project ships them.
|
||||
* They are deliberately NOT recoloured to `currentColor` the way the Immich and
|
||||
* Synology glyphs in the addon manager are: those are single-colour shapes that
|
||||
* happen to be brands, while these are the pictures people recognise, and
|
||||
* redrawing one in the user's accent makes it a different logo.
|
||||
*
|
||||
* Served rather than inlined because one of them is 56 paths — inline it would
|
||||
* be a kilobyte of markup at every call site, and this way the browser caches it.
|
||||
*/
|
||||
export default function BrandIcon({
|
||||
src,
|
||||
size = 20,
|
||||
fill = false,
|
||||
muted = false,
|
||||
className,
|
||||
}: BrandIconProps & { src: string }): React.ReactElement {
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
// The files carry their own width/height attributes; the explicit style is
|
||||
// what keeps them at icon size next to the lucide glyphs.
|
||||
style={{
|
||||
...(fill
|
||||
? { width: '100%', height: '100%', objectFit: 'contain' as const }
|
||||
: className
|
||||
? { flexShrink: 0 }
|
||||
: { width: size, height: size, flexShrink: 0 }),
|
||||
display: 'block',
|
||||
...(muted ? { filter: 'grayscale(1)', opacity: 0.55 } : {}),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
import BrandIcon, { type BrandIconProps } from './BrandIcon'
|
||||
|
||||
/**
|
||||
* Dawarich's own mark, unmodified: green and grey map blocks behind the blue
|
||||
* road that loops back as a D, with the white roundabout at the junction.
|
||||
*
|
||||
* The file is `app/assets/images/logo.svg` from Freika/dawarich, served from
|
||||
* `public/brands/`. See `BrandIcon` for why it is a file rather than inline
|
||||
* markup, and why it keeps its own colours.
|
||||
*/
|
||||
export default function DawarichIcon(props: BrandIconProps): React.ReactElement {
|
||||
return <BrandIcon src="/brands/dawarich.svg" {...props} />
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { DawarichCapabilities, DawarichErrorCode, DawarichSyncState } from '@trek/shared'
|
||||
import { dawarichApi } from '../api/dawarich'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
|
||||
/**
|
||||
* Everything the Dawarich connection card does, in one hook.
|
||||
*
|
||||
* Shared by the desktop section and its phone twin on purpose. The two shells
|
||||
* render very different markup and identical logic, and SonarCloud's 3%
|
||||
* duplication budget on new code is not big enough for a second copy of this —
|
||||
* `useInstanceSettings` under `components/Admin/` is the same pattern for the
|
||||
* same reason.
|
||||
*/
|
||||
export interface DawarichConnectionState {
|
||||
url: string
|
||||
setUrl: (value: string) => void
|
||||
apiKey: string
|
||||
setApiKey: (value: string) => void
|
||||
allowInsecureTls: boolean
|
||||
toggleInsecureTls: () => void
|
||||
syncEnabled: boolean
|
||||
toggleSync: () => void
|
||||
|
||||
connected: boolean
|
||||
loading: boolean
|
||||
saving: boolean
|
||||
testing: boolean
|
||||
syncing: boolean
|
||||
|
||||
lastSyncAt: string | null
|
||||
lastSyncState: DawarichSyncState
|
||||
/** Already translated; null when the last sync was clean. */
|
||||
lastSyncError: string | null
|
||||
capabilities: DawarichCapabilities | null
|
||||
/** Set after a test run, so the card can show what the probe found. */
|
||||
probeMessage: string | null
|
||||
|
||||
canSave: boolean
|
||||
save: () => Promise<void>
|
||||
test: () => Promise<void>
|
||||
syncNow: () => Promise<void>
|
||||
disconnect: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useDawarichConnection(): DawarichConnectionState {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
|
||||
const [url, setUrl] = useState('')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [allowInsecureTls, setAllowInsecureTls] = useState(false)
|
||||
const [syncEnabled, setSyncEnabled] = useState(true)
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [lastSyncAt, setLastSyncAt] = useState<string | null>(null)
|
||||
const [lastSyncState, setLastSyncState] = useState<DawarichSyncState>('never')
|
||||
const [lastSyncErrorCode, setLastSyncErrorCode] = useState<string | null>(null)
|
||||
const [capabilities, setCapabilities] = useState<DawarichCapabilities | null>(null)
|
||||
const [probeMessage, setProbeMessage] = useState<string | null>(null)
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
|
||||
/**
|
||||
* Reason codes rather than the upstream sentence: Dawarich answers in English
|
||||
* and occasionally quotes the URL back, and neither belongs on a German
|
||||
* install. An unknown code still gets a sentence rather than nothing.
|
||||
*/
|
||||
const translateError = useCallback(
|
||||
(code: string | null): string | null => {
|
||||
if (!code) return null
|
||||
const key = `dawarich.error.${code}`
|
||||
const text = t(key)
|
||||
return text === key ? t('dawarich.error.unknown') : text
|
||||
},
|
||||
[t],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
dawarichApi
|
||||
.getSettings()
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
setUrl(data.url || '')
|
||||
setAllowInsecureTls(!!data.allowInsecureTls)
|
||||
setSyncEnabled(data.syncEnabled !== false)
|
||||
setConnected(!!data.connected)
|
||||
setLastSyncAt(data.lastSyncAt)
|
||||
setLastSyncState(data.lastSyncState)
|
||||
setLastSyncErrorCode(data.lastSyncError)
|
||||
setCapabilities(data.capabilities)
|
||||
})
|
||||
.catch(() => {
|
||||
// A failed read of our own settings is a TREK problem, not a Dawarich
|
||||
// one — the card stays in its empty state rather than claiming the
|
||||
// instance is unreachable.
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** The key field is never prefilled, so blank means "keep the stored one". */
|
||||
const keyPayload = useCallback((): { apiKey?: string } => {
|
||||
const typed = apiKey.trim()
|
||||
return typed ? { apiKey: typed } : {}
|
||||
}, [apiKey])
|
||||
|
||||
const save = useCallback(async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const result = await dawarichApi.saveSettings({
|
||||
url: url.trim(),
|
||||
allowInsecureTls,
|
||||
syncEnabled,
|
||||
...keyPayload(),
|
||||
})
|
||||
const fresh = await dawarichApi.getSettings().catch(() => null)
|
||||
if (fresh) {
|
||||
setConnected(!!fresh.connected)
|
||||
setCapabilities(fresh.capabilities)
|
||||
setLastSyncState(fresh.lastSyncState)
|
||||
setLastSyncErrorCode(fresh.lastSyncError)
|
||||
}
|
||||
setApiKey('')
|
||||
// The warning the server attaches when the address resolves to a private
|
||||
// IP. Rendered from its code so it arrives in the reader's language; the
|
||||
// server's own sentence is the fallback for a code this build predates.
|
||||
if (result?.warning) {
|
||||
const key = result.warningCode ? `dawarich.warning.${result.warningCode}` : ''
|
||||
const translated = key ? t(key, { ip: result.warningIp ?? '' }) : ''
|
||||
toast.warning(translated && translated !== key ? translated : result.warning)
|
||||
}
|
||||
else toast.success(t('dawarich.toast.saved'))
|
||||
} catch (err) {
|
||||
toast.error(errorText(err, t) || t('dawarich.toast.saveError'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [url, allowInsecureTls, syncEnabled, keyPayload, toast, t])
|
||||
|
||||
const test = useCallback(async () => {
|
||||
setTesting(true)
|
||||
setProbeMessage(null)
|
||||
try {
|
||||
const result = await dawarichApi.test({ url: url.trim(), allowInsecureTls, ...keyPayload() })
|
||||
setConnected(!!result.connected)
|
||||
if (result.capabilities) setCapabilities(result.capabilities)
|
||||
if (result.connected) {
|
||||
setProbeMessage(t('dawarich.test.success', { count: result.visitCount ?? 0 }))
|
||||
toast.success(t('dawarich.test.success', { count: result.visitCount ?? 0 }))
|
||||
} else {
|
||||
const message = translateError(result.error ?? null) ?? t('dawarich.test.failed')
|
||||
setProbeMessage(message)
|
||||
toast.error(message)
|
||||
}
|
||||
} catch (err) {
|
||||
const message = errorText(err, t) || t('dawarich.test.failed')
|
||||
setProbeMessage(message)
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}, [url, allowInsecureTls, keyPayload, toast, t, translateError])
|
||||
|
||||
const syncNow = useCallback(async () => {
|
||||
setSyncing(true)
|
||||
try {
|
||||
const result = await dawarichApi.syncNow()
|
||||
if (result.alreadyRunning) {
|
||||
// Somebody (or the cron) is mid-sync. Saying "0 new stays" here would be
|
||||
// a result, and there is no result yet.
|
||||
toast.info(t('dawarich.toast.syncRunning'))
|
||||
return
|
||||
}
|
||||
setLastSyncAt(new Date().toISOString())
|
||||
setLastSyncState((result.state as DawarichSyncState) ?? 'ok')
|
||||
setLastSyncErrorCode(null)
|
||||
// Said as a count rather than "done": a sync that found nothing and one
|
||||
// that failed silently look identical otherwise.
|
||||
toast.success(t('dawarich.toast.synced', { count: result.created ?? 0 }))
|
||||
} catch (err) {
|
||||
setLastSyncState('failed')
|
||||
toast.error(errorText(err, t) || t('dawarich.toast.syncError'))
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}, [toast, t])
|
||||
|
||||
const disconnect = useCallback(async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await dawarichApi.disconnect()
|
||||
setUrl('')
|
||||
setApiKey('')
|
||||
setConnected(false)
|
||||
setCapabilities(null)
|
||||
setLastSyncAt(null)
|
||||
setLastSyncState('never')
|
||||
setLastSyncErrorCode(null)
|
||||
setProbeMessage(null)
|
||||
toast.success(t('dawarich.toast.disconnected'))
|
||||
} catch (err) {
|
||||
toast.error(errorText(err, t) || t('dawarich.toast.saveError'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [toast, t])
|
||||
|
||||
return {
|
||||
url,
|
||||
setUrl,
|
||||
apiKey,
|
||||
setApiKey,
|
||||
allowInsecureTls,
|
||||
toggleInsecureTls: () => setAllowInsecureTls(v => !v),
|
||||
syncEnabled,
|
||||
toggleSync: () => setSyncEnabled(v => !v),
|
||||
connected,
|
||||
loading,
|
||||
saving,
|
||||
testing,
|
||||
syncing,
|
||||
lastSyncAt,
|
||||
lastSyncState,
|
||||
lastSyncError: translateError(lastSyncErrorCode),
|
||||
capabilities,
|
||||
probeMessage,
|
||||
// An address alone is not a connection, and the key field is blank once a
|
||||
// key is stored — so saving needs either an existing connection or a typed key.
|
||||
canSave: !!url.trim() && (connected || !!apiKey.trim()),
|
||||
save,
|
||||
test,
|
||||
syncNow,
|
||||
disconnect,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's `{ code, error }` envelope, as a sentence the reader can read.
|
||||
*
|
||||
* The code comes first and the prose is the fallback: the server's own sentence
|
||||
* is written in English, and a German install should not be told what went
|
||||
* wrong in a language it does not speak. A code this version has no string for
|
||||
* falls back to the sentence rather than to nothing.
|
||||
*/
|
||||
function errorText(err: unknown, t?: (key: string) => string): string | null {
|
||||
const data = (err as { response?: { data?: { error?: unknown; code?: unknown } } })?.response?.data
|
||||
if (t && typeof data?.code === 'string') {
|
||||
const key = `dawarich.error.${data.code}`
|
||||
const text = t(key)
|
||||
if (text !== key) return text
|
||||
}
|
||||
return typeof data?.error === 'string' ? data.error : null
|
||||
}
|
||||
|
||||
/** Exported for the error-code type so consumers do not re-declare it. */
|
||||
export type { DawarichErrorCode }
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { JourneyTrack } from '@trek/shared'
|
||||
import { dawarichRepo } from '../repo/dawarichRepo'
|
||||
import { useAddonStore } from '../store/addonStore'
|
||||
import { trailSegments } from '../components/Map/dawarichTrail'
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
|
||||
/**
|
||||
* The recorded route for a journal, shaped as the journey map's own tracks.
|
||||
*
|
||||
* The journey map already draws `JourneyTrack[]` — several separate polylines,
|
||||
* each with its own colour and a white casing, described in that file as
|
||||
* reading "as a recorded route rather than as the dashed line that merely
|
||||
* connects entries in time order". That is exactly what this is, so it rides
|
||||
* the existing layer instead of adding a third one.
|
||||
*
|
||||
* It is deliberately NOT the map's `trail` prop: that draws a single polyline,
|
||||
* and a day's recording is several disconnected pieces — joining them would
|
||||
* paint straight lines across gaps the traveller never travelled.
|
||||
*
|
||||
* The synthetic `place_id` is negative so it can never collide with a real
|
||||
* place's, which is what the map keys its lines on.
|
||||
*/
|
||||
export function useDawarichJournalTrail(
|
||||
/** Dates the journal covers, from its trips. */
|
||||
dates: Set<string>,
|
||||
enabled: boolean,
|
||||
): { tracks: JourneyTrack[]; fetchedAt: string | null; status: 'idle' | 'loading' | 'ready' | 'unavailable' | 'offline' } {
|
||||
const addonEnabled = useAddonStore(s => s.isEnabled)
|
||||
const active = enabled && addonEnabled('dawarich') && dates.size > 0
|
||||
|
||||
const [tracks, setTracks] = useState<JourneyTrack[]>(EMPTY)
|
||||
const [fetchedAt, setFetchedAt] = useState<string | null>(null)
|
||||
const [status, setStatus] = useState<'idle' | 'loading' | 'ready' | 'unavailable' | 'offline'>('idle')
|
||||
|
||||
// The window is the journal's own span. A Set has no order, so it is sorted
|
||||
// here rather than trusting insertion order.
|
||||
const window = useMemo(() => {
|
||||
if (dates.size === 0) return null
|
||||
const sorted = [...dates].sort()
|
||||
return { from: `${sorted[0]}T00:00:00Z`, to: `${sorted[sorted.length - 1]}T23:59:59Z` }
|
||||
}, [dates])
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !window) {
|
||||
setTracks(EMPTY)
|
||||
setStatus('idle')
|
||||
return
|
||||
}
|
||||
if (isEffectivelyOffline()) {
|
||||
setTracks(EMPTY)
|
||||
setStatus('offline')
|
||||
return
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
let cancelled = false
|
||||
setStatus('loading')
|
||||
|
||||
dawarichRepo
|
||||
.windowTrack(window.from, window.to, controller.signal)
|
||||
.then(track => {
|
||||
if (cancelled) return
|
||||
const segments = trailSegments(track)
|
||||
setTracks(
|
||||
segments.map((segment, index): JourneyTrack => ({
|
||||
place_id: -(index + 1),
|
||||
trip_id: 0,
|
||||
name: segment.date,
|
||||
color: segment.color,
|
||||
points: segment.points,
|
||||
})),
|
||||
)
|
||||
setFetchedAt(track.fetchedAt)
|
||||
setStatus('ready')
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setTracks(EMPTY)
|
||||
setStatus(isEffectivelyOffline() ? 'offline' : 'unavailable')
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
controller.abort()
|
||||
}
|
||||
}, [active, window])
|
||||
|
||||
return { tracks: active ? tracks : EMPTY, fetchedAt, status }
|
||||
}
|
||||
|
||||
/** Stable empty identity: a fresh literal would re-render the map on every pass. */
|
||||
const EMPTY: JourneyTrack[] = []
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { DawarichAccept, DawarichSuggestion, DawarichSyncState } from '@trek/shared'
|
||||
import { dawarichApi } from '../api/dawarich'
|
||||
import { dawarichRepo, DawarichOfflineError } from '../repo/dawarichRepo'
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
import { useAddonStore } from '../store/addonStore'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
|
||||
export type DawarichSuggestionsStatus =
|
||||
| 'idle'
|
||||
| 'loading'
|
||||
| 'ready'
|
||||
| 'disconnected'
|
||||
| 'offline'
|
||||
| 'unavailable'
|
||||
|
||||
export interface DawarichSuggestionsState {
|
||||
suggestions: DawarichSuggestion[]
|
||||
status: DawarichSuggestionsStatus
|
||||
lastSyncAt: string | null
|
||||
lastSyncState: DawarichSyncState
|
||||
/** Already translated; null when the last sync was clean. */
|
||||
lastSyncError: string | null
|
||||
busyId: number | null
|
||||
reload: () => void
|
||||
accept: (id: number, body: DawarichAccept) => Promise<boolean>
|
||||
dismiss: (id: number) => Promise<void>
|
||||
restore: (id: number) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* The stays waiting for review, and what a user can do with them.
|
||||
*
|
||||
* Shared by the journal and the trip planner so the two never disagree about
|
||||
* what is still pending — and so the review logic exists once rather than in
|
||||
* each shell.
|
||||
*
|
||||
* Every mutation updates the row in place rather than refetching the list. A
|
||||
* refetch would reorder under the cursor, and someone working down a backlog
|
||||
* would lose their place every time they pressed something.
|
||||
*/
|
||||
export function useDawarichSuggestions(filter?: {
|
||||
tripId?: number
|
||||
state?: 'new' | 'accepted' | 'dismissed'
|
||||
}): DawarichSuggestionsState {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const addonEnabled = useAddonStore(s => s.isEnabled)
|
||||
const active = addonEnabled('dawarich')
|
||||
|
||||
const [suggestions, setSuggestions] = useState<DawarichSuggestion[]>([])
|
||||
const [status, setStatus] = useState<DawarichSuggestionsStatus>('idle')
|
||||
const [lastSyncAt, setLastSyncAt] = useState<string | null>(null)
|
||||
const [lastSyncState, setLastSyncState] = useState<DawarichSyncState>('never')
|
||||
const [lastSyncErrorCode, setLastSyncErrorCode] = useState<string | null>(null)
|
||||
const [busyId, setBusyId] = useState<number | null>(null)
|
||||
const [reloadToken, setReloadToken] = useState(0)
|
||||
|
||||
const tripId = filter?.tripId
|
||||
const stateFilter = filter?.state
|
||||
|
||||
const reload = useCallback(() => setReloadToken(token => token + 1), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
setStatus('idle')
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setStatus('loading')
|
||||
dawarichRepo
|
||||
.suggestions({ tripId, state: stateFilter })
|
||||
.then(result => {
|
||||
if (cancelled) return
|
||||
setSuggestions(result.suggestions)
|
||||
setLastSyncAt(result.lastSyncAt)
|
||||
setLastSyncState(result.lastSyncState)
|
||||
setLastSyncErrorCode(result.lastSyncError)
|
||||
setStatus(result.connected ? 'ready' : 'disconnected')
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return
|
||||
setStatus(err instanceof DawarichOfflineError ? 'offline' : 'unavailable')
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [active, tripId, stateFilter, reloadToken])
|
||||
|
||||
const replace = useCallback((updated: DawarichSuggestion) => {
|
||||
setSuggestions(prev => prev.map(item => (item.id === updated.id ? updated : item)))
|
||||
}, [])
|
||||
|
||||
const accept = useCallback(
|
||||
async (id: number, body: DawarichAccept): Promise<boolean> => {
|
||||
// Accepting writes into a trip or a journal, which needs the server. The
|
||||
// reads go through the repo, which is offline-aware; this one says so
|
||||
// rather than hanging until the request times out.
|
||||
if (isEffectivelyOffline()) {
|
||||
toast.error(t('dawarich.error.offline'))
|
||||
return false
|
||||
}
|
||||
setBusyId(id)
|
||||
try {
|
||||
const result = await dawarichApi.accept(id, body)
|
||||
replace(result.suggestion)
|
||||
toast.success(t(`dawarich.toast.accepted.${body.target}`))
|
||||
return true
|
||||
} catch (err) {
|
||||
toast.error(serverError(err, t) ?? t('dawarich.toast.acceptError'))
|
||||
return false
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
},
|
||||
[replace, toast, t],
|
||||
)
|
||||
|
||||
const setRowState = useCallback(
|
||||
async (id: number, next: 'new' | 'dismissed') => {
|
||||
if (isEffectivelyOffline()) {
|
||||
toast.error(t('dawarich.error.offline'))
|
||||
return
|
||||
}
|
||||
setBusyId(id)
|
||||
try {
|
||||
replace(await dawarichApi.setState(id, next))
|
||||
} catch (err) {
|
||||
toast.error(serverError(err, t) ?? t('dawarich.toast.updateError'))
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
},
|
||||
[replace, toast, t],
|
||||
)
|
||||
|
||||
return {
|
||||
suggestions,
|
||||
status,
|
||||
lastSyncAt,
|
||||
lastSyncState,
|
||||
lastSyncError: translateCode(lastSyncErrorCode, t),
|
||||
busyId,
|
||||
reload,
|
||||
accept,
|
||||
dismiss: (id: number) => setRowState(id, 'dismissed'),
|
||||
restore: (id: number) => setRowState(id, 'new'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's `{ code, error }` envelope, as a sentence the reader can read.
|
||||
*
|
||||
* The controller ships a code beside every message precisely so nothing reaches
|
||||
* a non-English install in English; the prose is the fallback for a code this
|
||||
* build has no string for.
|
||||
*/
|
||||
function serverError(err: unknown, t: (key: string) => string): string | null {
|
||||
const data = (err as { response?: { data?: { error?: unknown; code?: unknown } } })?.response?.data
|
||||
if (typeof data?.code === 'string') {
|
||||
const key = `dawarich.error.${data.code}`
|
||||
const text = t(key)
|
||||
if (text !== key) return text
|
||||
}
|
||||
return typeof data?.error === 'string' ? data.error : null
|
||||
}
|
||||
|
||||
/** A reason code turned into the reader's language, with a fallback for new codes. */
|
||||
function translateCode(code: string | null, t: (key: string) => string): string | null {
|
||||
if (!code) return null
|
||||
const key = `dawarich.error.${code}`
|
||||
const text = t(key)
|
||||
return text === key ? t('dawarich.error.unknown') : text
|
||||
}
|
||||
@@ -8,11 +8,14 @@ import {
|
||||
Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen,
|
||||
MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Check, Loader2,
|
||||
} from 'lucide-react'
|
||||
import DawarichIcon from '../../../components/shared/DawarichIcon'
|
||||
import AirTrailIcon from '../../../components/shared/AirTrailIcon'
|
||||
import MToggle from '../../components/MToggle'
|
||||
import { MAdminButton, MAdminCard, MAdminField, MAdminInput, MAdminSecretInput } from './MAdminUi'
|
||||
|
||||
const ICON_MAP = {
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark,
|
||||
Dawarich: DawarichIcon,
|
||||
}
|
||||
|
||||
function ImmichIcon({ size = 14 }: { size?: number }) {
|
||||
@@ -57,9 +60,16 @@ interface ProviderOption {
|
||||
interface AddonIconProps {
|
||||
name: string
|
||||
size?: number
|
||||
/** A switched-off addon greys its icon out, brand marks included. */
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
function AddonIcon({ name, size = 18 }: AddonIconProps) {
|
||||
function AddonIcon({ name, size = 18, enabled = true }: AddonIconProps) {
|
||||
if (name === 'Dawarich') return <DawarichIcon fill muted={!enabled} />
|
||||
// 'Plane' is the icon string airtrail was seeded with, and INSERT OR IGNORE
|
||||
// means every existing install still carries it — so the brand is keyed on
|
||||
// that rather than on a new name no row would ever have.
|
||||
if (name === 'Plane') return <AirTrailIcon fill muted={!enabled} />
|
||||
const Icon = ICON_MAP[name] || Puzzle
|
||||
return <Icon size={size} />
|
||||
}
|
||||
@@ -304,8 +314,9 @@ function MAddonRow({ addon, onToggle, t, first }: MAddonRowProps) {
|
||||
addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')
|
||||
return (
|
||||
<div className={`flex items-center gap-3 py-[11px] ${first ? '' : 'border-t border-[color:var(--m-rowbr)]'}`}>
|
||||
<span className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-[11px] bg-[color:var(--m-ic)] text-m-ink">
|
||||
<AddonIcon name={addon.icon} size={18} />
|
||||
{/* overflow-hidden so a brand mark that fills the slot keeps its rounded corners. */}
|
||||
<span className="flex h-[38px] w-[38px] flex-none items-center justify-center overflow-hidden rounded-[11px] bg-[color:var(--m-ic)] text-m-ink">
|
||||
<AddonIcon name={addon.icon} size={18} enabled={addon.enabled} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -10,6 +10,9 @@ import MAtlasSearch from './MAtlasSearch'
|
||||
import MAtlasCountryPopup from './MAtlasCountryPopup'
|
||||
import MAtlasBucketSheet from './MAtlasBucketSheet'
|
||||
import MToggle from '../../components/MToggle'
|
||||
import DawarichIcon from '../../../components/shared/DawarichIcon'
|
||||
import DawarichAtlasDialog from '../../../components/Dawarich/DawarichAtlasDialog'
|
||||
import { useAddonStore } from '../../../store/addonStore'
|
||||
import { countryStatus } from '../../../pages/atlas/atlasModel'
|
||||
|
||||
const removeBtnCls = 'mt-4 w-full rounded-full bg-[rgba(214,39,59,.12)] py-[11px] text-center text-[0.8125rem] font-bold text-[color:var(--m-st-danger)]' // theme-lint-disable — fixed status-danger tint
|
||||
@@ -44,6 +47,8 @@ export default function MAtlas() {
|
||||
const plannedCount = stats.totalCountriesPlanned || 0
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [bucketOpen, setBucketOpen] = useState(false)
|
||||
const [dawarichOpen, setDawarichOpen] = useState(false)
|
||||
const dawarichEnabled = useAddonStore(state => state.isEnabled)('dawarich')
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
|
||||
// The dock FAB is the search button on this screen (demo Z. 1099) and hands
|
||||
@@ -115,10 +120,27 @@ export default function MAtlas() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBucketOpen(true)}
|
||||
className="flex h-[38px] flex-1 items-center justify-center rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] px-4 text-[0.78125rem] font-bold text-m-ink shadow-[0_5px_12px_-8px_rgba(0,0,0,.18)]"
|
||||
className="flex h-[38px] min-w-0 flex-1 items-center justify-center rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] px-4 text-[0.78125rem] font-bold text-m-ink shadow-[0_5px_12px_-8px_rgba(0,0,0,.18)]"
|
||||
>
|
||||
{t('atlas.bucketTab')}
|
||||
</button>
|
||||
{/* Dawarich answers two questions about this map, so it opens from the
|
||||
map — not from inside the wishlist sheet, where it was one card deep
|
||||
and looked like part of the list it is only asking about. Renders
|
||||
nothing when the addon is off. */}
|
||||
{dawarichEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDawarichOpen(true)}
|
||||
aria-label={t('dawarich.title')}
|
||||
className="flex h-[38px] min-w-0 flex-1 items-center justify-center gap-2 rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] px-3 shadow-[0_5px_12px_-8px_rgba(0,0,0,.18)]"
|
||||
>
|
||||
<span className="flex h-[26px] w-[26px] flex-none overflow-hidden rounded-full">
|
||||
<DawarichIcon size={26} />
|
||||
</span>
|
||||
<span className="truncate text-[0.78125rem] font-bold text-m-ink">{t('dawarich.title')}</span>
|
||||
</button>
|
||||
)}
|
||||
{/* Only worth the space once there is something planned to reveal. */}
|
||||
{plannedCount > 0 && (
|
||||
<div className="flex h-[38px] shrink-0 items-center gap-2 rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] px-3 shadow-[0_5px_12px_-8px_rgba(0,0,0,.18)]">
|
||||
@@ -128,6 +150,14 @@ export default function MAtlas() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{dawarichOpen && (
|
||||
<DawarichAtlasDialog
|
||||
isOpen
|
||||
onClose={() => setDawarichOpen(false)}
|
||||
onChanged={atlas.reloadAfterDawarich}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MAtlasStatsCard stats={stats} />
|
||||
|
||||
<MAtlasSearch
|
||||
|
||||
@@ -236,6 +236,7 @@ export default function MAtlasBucketSheet({ atlas, open, onClose }: MAtlasBucket
|
||||
{t('atlas.addPoi')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</MSheet>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronLeft, MapPin, Grid3x3, Upload, MoreHorizontal, Play, Image, Camera, EyeOff, Settings2 } from 'lucide-react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
import { ChevronLeft, MapPin, Grid3x3, MoreHorizontal, Play, Image, Camera, EyeOff, Settings2 } from 'lucide-react'
|
||||
import JourneyMap from '../../../components/Journey/JourneyMapAuto'
|
||||
import type { JourneyMapAutoHandle } from '../../../components/Journey/JourneyMapAuto'
|
||||
import PhotoLightbox from '../../../components/Journey/PhotoLightbox'
|
||||
@@ -9,6 +10,10 @@ import { ProviderPicker } from '../../../components/Journey/JourneyDetailPagePro
|
||||
import { photoUrl } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { useJourneyDetail } from '../../../pages/journeyDetail/useJourneyDetail'
|
||||
import { useJourneyStore } from '../../../store/journeyStore'
|
||||
import { useAddonStore } from '../../../store/addonStore'
|
||||
import DawarichIcon from '../../../components/shared/DawarichIcon'
|
||||
import DawarichSuggestionsPanel from '../../../components/Dawarich/DawarichSuggestionsPanel'
|
||||
import { FormSheetHeader } from '../trip/sheets/PlSheetChrome'
|
||||
import type { JourneyEntry, GalleryPhoto } from '../../../store/journeyStore'
|
||||
import { useAuthStore } from '../../../store/authStore'
|
||||
import { journeyApi, addonsApi, memoriesApi } from '../../../api/client'
|
||||
@@ -43,6 +48,15 @@ export default function MJourneyDetail() {
|
||||
loadJourney, updateEntry, deleteEntry, uploadPhotos,
|
||||
} = useJourneyDetail()
|
||||
|
||||
// The dock's FAB is a sibling of this screen: on the Gallery it becomes the
|
||||
// upload button, so it has to know which tab is open — and it stops knowing
|
||||
// when this screen goes away.
|
||||
const setMobileGalleryOpen = useJourneyStore(state => state.setMobileGalleryOpen)
|
||||
useEffect(() => {
|
||||
setMobileGalleryOpen(view === 'gallery')
|
||||
return () => setMobileGalleryOpen(false)
|
||||
}, [view, setMobileGalleryOpen])
|
||||
|
||||
const mapRef = useRef<JourneyMapAutoHandle>(null)
|
||||
const carouselRef = useRef<HTMLDivElement>(null)
|
||||
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
|
||||
@@ -129,11 +143,45 @@ export default function MJourneyDetail() {
|
||||
// Gallery upload — device files plus the connected photo providers (Immich/Synology).
|
||||
const galleryFileRef = useRef<HTMLInputElement>(null)
|
||||
const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([])
|
||||
// Whether the probe below has finished — not whether it found anything.
|
||||
const [providersReady, setProvidersReady] = useState(false)
|
||||
const [showUploadMenu, setShowUploadMenu] = useState(false)
|
||||
const [showActionMenu, setShowActionMenu] = useState(false)
|
||||
const [pickerProvider, setPickerProvider] = useState<string | null>(null)
|
||||
const dawarichEnabled = useAddonStore(state => state.isEnabled)('dawarich')
|
||||
const [dawarichOpen, setDawarichOpen] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
const openUpload = useCallback(() => {
|
||||
if (availableProviders.length > 0) setShowUploadMenu(true)
|
||||
else galleryFileRef.current?.click()
|
||||
}, [availableProviders.length])
|
||||
|
||||
// The dock's FAB asks through the URL, the way every other "+" in the shell
|
||||
// does. The parameter is cleared straight away so going back does not reopen
|
||||
// the picker; the intent is held in state until the provider probe has
|
||||
// answered, because "device or Immich?" cannot be asked before we know
|
||||
// whether there is an Immich.
|
||||
const [params, setParams] = useSearchParams()
|
||||
const [pendingUpload, setPendingUpload] = useState(false)
|
||||
useEffect(() => {
|
||||
if (params.get('create') !== 'photo') return
|
||||
setParams(prev => {
|
||||
const next = new URLSearchParams(prev)
|
||||
next.delete('create')
|
||||
return next
|
||||
}, { replace: true })
|
||||
if (!canEditEntries) return
|
||||
setView('gallery')
|
||||
setPendingUpload(true)
|
||||
}, [params, setParams, canEditEntries, setView])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingUpload || !providersReady) return
|
||||
setPendingUpload(false)
|
||||
openUpload()
|
||||
}, [pendingUpload, providersReady, openUpload])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
;(async () => {
|
||||
@@ -155,6 +203,7 @@ export default function MJourneyDetail() {
|
||||
}
|
||||
if (active) setAvailableProviders(connected)
|
||||
} catch { /* no providers */ }
|
||||
finally { if (active) setProvidersReady(true) }
|
||||
})()
|
||||
return () => { active = false }
|
||||
}, [])
|
||||
@@ -314,20 +363,18 @@ export default function MJourneyDetail() {
|
||||
</button>
|
||||
</div>
|
||||
<span className="ml-auto flex flex-none items-center gap-2">
|
||||
{view === 'gallery' && canEditEntries && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (availableProviders.length > 0 ? setShowUploadMenu(true) : galleryFileRef.current?.click())}
|
||||
disabled={uploading}
|
||||
{/* Uploading lives on the dock's FAB while the Gallery is open — the
|
||||
one big action on the screen. A second button up here would be the
|
||||
same thing twice, so it only appears while an upload is running,
|
||||
as its progress. */}
|
||||
{view === 'gallery' && canEditEntries && uploading && (
|
||||
<span
|
||||
role="status"
|
||||
aria-label={t('common.upload')}
|
||||
className="flex h-[38px] w-[38px] items-center justify-center rounded-full bg-m-act text-m-actfg shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)] disabled:opacity-60"
|
||||
className="flex h-[38px] w-[38px] items-center justify-center rounded-full bg-m-act text-m-actfg shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]"
|
||||
>
|
||||
{uploading ? (
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
) : (
|
||||
<Upload size={16} strokeWidth={2.2} />
|
||||
)}
|
||||
</button>
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
@@ -340,6 +387,47 @@ export default function MJourneyDetail() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* The stays Dawarich recorded for these dates, as candidate entries. Over
|
||||
the map rather than in the header: it belongs to what is on the map,
|
||||
and the header is already three controls wide on a phone. Sits above
|
||||
the card rail so it never covers the card somebody is reading. */}
|
||||
{dawarichEnabled && canEditEntries && view === 'timeline' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDawarichOpen(true)}
|
||||
aria-label={t('dawarich.suggestions.title')}
|
||||
className="absolute right-4 z-[9] flex h-[46px] w-[46px] items-center justify-center overflow-hidden rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] shadow-[0_6px_18px_-8px_rgba(0,0,0,.35)] bottom-[calc(var(--bottom-nav-h,84px)+16px+128px)]"
|
||||
>
|
||||
<DawarichIcon size={46} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{dawarichOpen && current && (
|
||||
<MSheet open onClose={() => setDawarichOpen(false)} variant="bottom" material="glass" ariaLabel={t('dawarich.suggestions.title')}>
|
||||
<div className="flex max-h-[82dvh] min-h-0 flex-col">
|
||||
<FormSheetHeader
|
||||
leading={
|
||||
<span className="flex h-10 w-10 flex-none overflow-hidden rounded-[13px]">
|
||||
<DawarichIcon size={40} />
|
||||
</span>
|
||||
}
|
||||
title={t('dawarich.suggestions.title')}
|
||||
onClose={() => setDawarichOpen(false)}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[14px] pb-4">
|
||||
{/* Journal entries and nothing else: a journey is for writing, and
|
||||
the trip planner already offers the same stay as a place. */}
|
||||
<DawarichSuggestionsPanel
|
||||
bare
|
||||
journals={[{ id: current.id, label: current.title }]}
|
||||
onAccepted={() => { void loadJourney(current.id) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</MSheet>
|
||||
)}
|
||||
|
||||
{/* Horizontal card timeline */}
|
||||
{view === 'timeline' && entries.length > 0 && (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import React from 'react'
|
||||
import { Plug, Save, RefreshCw, Unplug } from 'lucide-react'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import { useDawarichConnection } from '../../../hooks/useDawarichConnection'
|
||||
import { MSetCard, MSetRow, MSetInput, MSetButton, MSetHint } from './MSettingsUi'
|
||||
import MToggle from '../../components/MToggle'
|
||||
|
||||
/**
|
||||
* Phone twin of components/Settings/DawarichConnectionSection.
|
||||
*
|
||||
* Presentation only: every piece of behaviour lives in `useDawarichConnection`,
|
||||
* which both shells share. That is not tidiness — SonarCloud allows 3%
|
||||
* duplicated lines on a PR's new code, and a second copy of the connect / test /
|
||||
* sync logic would spend the whole budget on its own.
|
||||
*/
|
||||
export default function MDawarichConnectionSection(): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const S = useDawarichConnection()
|
||||
|
||||
return (
|
||||
<MSetCard title={t('dawarich.title')} icon={Plug} className="mt-3">
|
||||
<MSetHint className="mt-0">{t('dawarich.intro')}</MSetHint>
|
||||
|
||||
<div className="mt-3 space-y-3">
|
||||
<div>
|
||||
<MSetInput
|
||||
type="url"
|
||||
inputMode="url"
|
||||
value={S.url}
|
||||
onChange={e => S.setUrl(e.target.value)}
|
||||
placeholder="https://dawarich.example.com"
|
||||
aria-label={t('dawarich.url')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<MSetInput
|
||||
type="password"
|
||||
value={S.apiKey}
|
||||
onChange={e => S.setApiKey(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={S.connected && !S.apiKey ? '••••••••' : t('dawarich.apiKeyPlaceholder')}
|
||||
aria-label={t('dawarich.apiKey')}
|
||||
/>
|
||||
<MSetHint>{t('dawarich.apiKeyHint')}</MSetHint>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-1">
|
||||
<MSetRow
|
||||
first
|
||||
label={t('dawarich.syncEnabled')}
|
||||
sub={t('dawarich.syncEnabledHint')}
|
||||
trailing={
|
||||
<MToggle checked={S.syncEnabled} onChange={S.toggleSync} ariaLabel={t('dawarich.syncEnabled')} />
|
||||
}
|
||||
/>
|
||||
<MSetRow
|
||||
label={t('dawarich.allowInsecureTls')}
|
||||
sub={t('dawarich.allowInsecureTlsHint')}
|
||||
trailing={
|
||||
<MToggle
|
||||
checked={S.allowInsecureTls}
|
||||
onChange={S.toggleInsecureTls}
|
||||
ariaLabel={t('dawarich.allowInsecureTls')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<MSetButton onClick={S.save} disabled={S.saving || S.loading || !S.canSave}>
|
||||
<Save size={14} /> {t('common.save')}
|
||||
</MSetButton>
|
||||
<MSetButton variant="ghost" onClick={S.test} disabled={S.testing || S.loading || !S.url.trim()}>
|
||||
<Plug size={14} /> {t('dawarich.test.button')}
|
||||
</MSetButton>
|
||||
{S.connected && (
|
||||
<MSetButton variant="ghost" onClick={S.syncNow} disabled={S.syncing}>
|
||||
<RefreshCw size={14} className={S.syncing ? 'animate-spin' : ''} /> {t('dawarich.syncNow')}
|
||||
</MSetButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-[4px]">
|
||||
<p className="font-geist text-[0.6875rem] font-semibold text-m-ink">
|
||||
{S.connected ? t('dawarich.connected') : t('dawarich.notConnected')}
|
||||
</p>
|
||||
{S.probeMessage && (
|
||||
<p className="font-geist text-[0.625rem] leading-relaxed text-m-muted">{S.probeMessage}</p>
|
||||
)}
|
||||
{S.connected && (
|
||||
<p className="font-geist text-[0.625rem] leading-relaxed text-m-muted">
|
||||
{S.lastSyncAt
|
||||
? t('dawarich.lastSync', { when: new Date(S.lastSyncAt).toLocaleString(locale) })
|
||||
: t('dawarich.neverSynced')}
|
||||
</p>
|
||||
)}
|
||||
{S.lastSyncError && (
|
||||
<p className="font-geist text-[0.625rem] leading-relaxed text-[color:var(--m-st-danger)]">
|
||||
{S.lastSyncError}
|
||||
</p>
|
||||
)}
|
||||
{S.capabilities?.serverVersion && (
|
||||
<p className="font-geist text-[0.625rem] leading-relaxed text-m-faint">
|
||||
{t('dawarich.serverVersion', { version: S.capabilities.serverVersion })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{S.connected && (
|
||||
<div className="mt-3">
|
||||
<MSetButton variant="danger" onClick={S.disconnect} disabled={S.saving}>
|
||||
<Unplug size={14} /> {t('dawarich.disconnect')}
|
||||
</MSetButton>
|
||||
</div>
|
||||
)}
|
||||
</MSetCard>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useEffect } from 'react'
|
||||
import { useAddonStore } from '../../../store/addonStore'
|
||||
import MPhotoProvidersSection from './MPhotoProvidersSection'
|
||||
import MAirTrailConnectionSection from './MAirTrailConnectionSection'
|
||||
import MDawarichConnectionSection from './MDawarichConnectionSection'
|
||||
import MLlmConnectionSection from './MLlmConnectionSection'
|
||||
import MSettingsMcp from './MSettingsMcp'
|
||||
import { useAuthStore } from '../../../store/authStore'
|
||||
@@ -17,6 +18,7 @@ export default function MSettingsIntegrations() {
|
||||
const mcpEnabled = addonEnabled('mcp')
|
||||
const airtrailEnabled = addonEnabled('airtrail')
|
||||
const llmEnabled = addonEnabled('llm_parsing')
|
||||
const dawarichEnabled = addonEnabled('dawarich')
|
||||
const managed = useAuthStore((s) => s.managed)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -27,6 +29,9 @@ export default function MSettingsIntegrations() {
|
||||
<>
|
||||
<MPhotoProvidersSection />
|
||||
{airtrailEnabled && <MAirTrailConnectionSection />}
|
||||
{/* Reaches a server the reader runs themselves, which a managed install has
|
||||
no route to — the same gate the desktop shell puts on AirTrail. */}
|
||||
{dawarichEnabled && !managed && <MDawarichConnectionSection />}
|
||||
{/* Which model reads a booking, and what that costs, comes with the instance on
|
||||
a managed install. The per-user fallback exists for people who supply their
|
||||
own key, and there nobody does. */}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { MapViewAuto } from '../../../../components/Map/MapViewAuto'
|
||||
import { MapCompassPill, type CompassMap } from '../../../../components/Map/MapCompassPill'
|
||||
import { TripRouteOverviewPill, TripRouteOverviewPanel } from '../../../../components/Map/TripRouteOverview'
|
||||
import { DawarichTrailPill } from '../../../../components/Map/DawarichTrailPill'
|
||||
import PoiCategoryPill from '../../../../components/Map/PoiCategoryPill'
|
||||
import { usePoiExplore } from '../../../../components/Map/usePoiExplore'
|
||||
import { useSettingsStore } from '../../../../store/settingsStore'
|
||||
@@ -44,6 +45,7 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
|
||||
<div className="absolute inset-0 isolate overflow-hidden bg-[color:var(--m-mapb)] [--bottom-nav-h:calc(env(safe-area-inset-bottom,0px)+74px)]">
|
||||
<MapViewAuto
|
||||
tripId={planner.tripId}
|
||||
dawarichTrack={planner.dawarichTrail.track}
|
||||
places={planner.mapPlaces}
|
||||
dayPlaces={planner.dayPlaces}
|
||||
route={planner.overviewActive ? planner.tripOverview.lines : planner.route}
|
||||
@@ -115,9 +117,9 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
|
||||
Tailwind rather than inline because the compass band is identified by being
|
||||
the one element with an inline --bottom-nav-h, and a second would make that
|
||||
ambiguous. */}
|
||||
{mapActive && !planner.roadtripActive && (
|
||||
{mapActive && (!planner.roadtripActive || planner.dawarichEnabled) && (
|
||||
<div className="pointer-events-none absolute left-3 right-3 z-[25] flex flex-col items-end gap-2 bottom-[calc(var(--bottom-nav-h,84px)+58px)]">
|
||||
{planner.overviewActive && (
|
||||
{!planner.roadtripActive && planner.overviewActive && (
|
||||
<TripRouteOverviewPanel
|
||||
overview={planner.tripOverview}
|
||||
unit={distanceUnit}
|
||||
@@ -128,7 +130,18 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
|
||||
maxWidth={240}
|
||||
/>
|
||||
)}
|
||||
<TripRouteOverviewPill active={planner.overviewShown} onToggle={planner.toggleOverview} />
|
||||
{!planner.roadtripActive && (
|
||||
<TripRouteOverviewPill active={planner.overviewShown} onToggle={planner.toggleOverview} />
|
||||
)}
|
||||
{/* Kept in road-trip mode: the recorded route beside the planned one is
|
||||
exactly the comparison a drive invites. */}
|
||||
{planner.dawarichEnabled && (
|
||||
<DawarichTrailPill
|
||||
active={planner.dawarichTrailShown}
|
||||
status={planner.dawarichTrail.status}
|
||||
onToggle={planner.toggleDawarichTrail}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,10 @@ import { getCategoryIcon } from '../../../../components/shared/categoryIcons'
|
||||
import { resolveTrackColor } from '../../../../components/Map/trackColors'
|
||||
import MConfirmSheet from '../../settings/MConfirmSheet'
|
||||
import type { MPlacesBrowserProps } from '../MTripShell'
|
||||
import DawarichSuggestionsPanel from '../../../../components/Dawarich/DawarichSuggestionsPanel'
|
||||
import { formatDayOption } from '../../../../components/Dawarich/dawarichSuggestionModel'
|
||||
import { refreshTripAfterAccept } from '../../../../components/Dawarich/dawarichTripRefresh'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import type { Place } from '../../../../types'
|
||||
import MPlacesBulkCategorySheet from './MPlacesBulkCategorySheet'
|
||||
import MPlacesSaveToCollectionSheet from './MPlacesSaveToCollectionSheet'
|
||||
@@ -31,6 +35,8 @@ import { filterPool, firstPlannedDayNumbers, plannedPlaceIds } from './placesBro
|
||||
*/
|
||||
export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps) {
|
||||
const { t, places, categories, assignments, days, trip } = planner
|
||||
// The planner hook carries `t` but not the locale; day labels need both.
|
||||
const { locale } = useTranslation()
|
||||
const canEditPlaces = planner.can('place_edit', trip)
|
||||
const collectionsEnabled = useAddonStore(s => s.isEnabled('collections'))
|
||||
|
||||
@@ -231,6 +237,24 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stays Dawarich recorded on these dates (#2279). Same component as the
|
||||
desktop rail — the rows are the same rows, and the panel renders
|
||||
nothing when there is nothing pending. */}
|
||||
<div className="mt-3">
|
||||
<DawarichSuggestionsPanel
|
||||
tripId={planner.tripId}
|
||||
trips={[{ id: planner.tripId, label: t('dawarich.accept.thisTrip') }]}
|
||||
daysForTrip={() => days.map(day => ({
|
||||
id: day.id,
|
||||
...formatDayOption(day.day_number, day.date, locale, t),
|
||||
}))}
|
||||
// The place it just created belongs on the map and in the list
|
||||
// now, not after a reload.
|
||||
onAccepted={() => { void refreshTripAfterAccept(planner.tripId) }}
|
||||
initiallyCollapsed
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Selection toolbar ── */}
|
||||
{selectMode && (
|
||||
<div className="mt-2 flex items-center gap-2 rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] py-[6px] pl-[14px] pr-[6px] backdrop-blur-[20px]">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import DawarichIcon from '../../../../components/shared/DawarichIcon'
|
||||
import {
|
||||
Bookmark, Camera, ChevronRight, ExternalLink, Loader2, Map as MapIcon, Navigation, Paperclip,
|
||||
Pencil, Phone, Plus, Route, Trash2, Upload, X,
|
||||
@@ -301,7 +302,16 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[1rem] font-bold leading-snug">{place.name}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="min-w-0 truncate text-[1rem] font-bold leading-snug">{place.name}</span>
|
||||
{/* Accepted out of the traveller's own recordings — the same
|
||||
mark the desktop inspector carries. */}
|
||||
{place.source === 'dawarich' && (
|
||||
<span className="flex-none overflow-hidden rounded-[5px]">
|
||||
<DawarichIcon size={15} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{place.address && (
|
||||
<div className="mt-[2px] font-geist text-[0.6875rem] leading-[1.4] text-m-muted">{place.address}</div>
|
||||
)}
|
||||
|
||||
@@ -5,8 +5,10 @@ import Navbar from '../components/Layout/Navbar'
|
||||
import apiClient from '../api/client'
|
||||
import CustomSelect from '../components/shared/CustomSelect'
|
||||
import EmptyState from '../components/shared/EmptyState'
|
||||
import { Globe, MapPin, Briefcase, Calendar, Flag, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search } from 'lucide-react'
|
||||
import { Globe, MapPin, Briefcase, Calendar, Flag, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search, Check } from 'lucide-react'
|
||||
import type { TranslationFn } from '../types'
|
||||
import { Tooltip } from '../components/shared/Tooltip'
|
||||
import DawarichAtlasSidePanel from '../components/Dawarich/DawarichAtlasSidePanel'
|
||||
import { A2_TO_A3, countryCodeToFlag, findBucketDuplicate, isBucketDuplicateError, withCountryMarkedVisited, type AtlasCountry, type AtlasStats, type AtlasData, type CountryDetail } from './atlas/atlasModel'
|
||||
import { continentForCountry } from '@trek/shared'
|
||||
import { useAtlas } from './atlas/useAtlas'
|
||||
@@ -41,10 +43,10 @@ function AtlasPageDesktop(): React.ReactElement {
|
||||
bucketMonth, setBucketMonth, bucketYear, setBucketYear,
|
||||
bucketList, setBucketList, bucketTab, setBucketTab,
|
||||
showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm,
|
||||
handleAddBucketItem, handleDeleteBucketItem, handleBucketPoiSearch, handleSelectBucketPoi,
|
||||
handleAddBucketItem, handleDeleteBucketItem, handleClearBucketVisit, handleBucketPoiSearch, handleSelectBucketPoi,
|
||||
bucketSearchResults, setBucketSearchResults,
|
||||
bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear,
|
||||
bucketSearching, bucketSearch, setBucketSearch,
|
||||
bucketSearching, bucketSearch, setBucketSearch, reloadAfterDawarich,
|
||||
} = useAtlas()
|
||||
const toast = useToast()
|
||||
// Solid surfaces when the user disabled transparency (read at render — the
|
||||
@@ -62,6 +64,17 @@ function AtlasPageDesktop(): React.ReactElement {
|
||||
)
|
||||
}
|
||||
|
||||
// The glass both bottom panels are cut from. One object, because two panels
|
||||
// side by side that differ by a blur radius look like a mistake.
|
||||
const glassPanel: React.CSSProperties = {
|
||||
background: noTransparency ? (dark ? '#15151c' : '#ffffff') : (dark ? 'rgba(10,10,15,0.55)' : 'rgba(255,255,255,0.2)'),
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid ' + (dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'),
|
||||
borderRadius: 20,
|
||||
boxShadow: dark ? '0 8px 32px rgba(0,0,0,0.3)' : '0 8px 32px rgba(0,0,0,0.08)',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen overflow-hidden bg-surface">
|
||||
<Navbar />
|
||||
@@ -121,26 +134,36 @@ function AtlasPageDesktop(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Panel — bottom center, glass effect */}
|
||||
{/* Desktop Panels — bottom center, glass effect.
|
||||
Three columns so the Atlas panel stays exactly centred over the map
|
||||
however wide its neighbour is: Dawarich sits in the left column,
|
||||
right-aligned against the middle one, and stretches to its height. */}
|
||||
<div
|
||||
className="hidden md:grid absolute z-10 items-stretch"
|
||||
style={{
|
||||
bottom: 16,
|
||||
left: 20,
|
||||
right: 20,
|
||||
gridTemplateColumns: '1fr auto 1fr',
|
||||
alignItems: 'stretch',
|
||||
gap: 12,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-end" style={{ pointerEvents: 'auto', minWidth: 0 }}>
|
||||
<DawarichAtlasSidePanel style={glassPanel} dark={dark} onChanged={reloadAfterDawarich} />
|
||||
</div>
|
||||
<div
|
||||
ref={panelRef}
|
||||
onMouseMove={handlePanelMouseMove}
|
||||
onMouseLeave={handlePanelMouseLeave}
|
||||
className="hidden md:flex flex-col absolute z-10 overflow-hidden transition-[width,height,transform,box-shadow] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
className="hidden md:flex flex-col overflow-hidden transition-[width,height,transform,box-shadow] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{
|
||||
bottom: 16,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
...glassPanel,
|
||||
width: 'fit-content',
|
||||
maxWidth: 'calc(100vw - 40px)',
|
||||
background: noTransparency ? (dark ? '#15151c' : '#ffffff') : (dark ? 'rgba(10,10,15,0.55)' : 'rgba(255,255,255,0.2)'),
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid ' + (dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'),
|
||||
borderRadius: 20,
|
||||
boxShadow: dark
|
||||
? '0 8px 32px rgba(0,0,0,0.3)'
|
||||
: '0 8px 32px rgba(0,0,0,0.08)',
|
||||
maxWidth: '100%',
|
||||
pointerEvents: 'auto',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Liquid glass glare effect */}
|
||||
@@ -158,6 +181,7 @@ function AtlasPageDesktop(): React.ReactElement {
|
||||
showBucketAdd={showBucketAdd} setShowBucketAdd={setShowBucketAdd}
|
||||
bucketForm={bucketForm} setBucketForm={setBucketForm}
|
||||
onAddBucket={handleAddBucketItem} onDeleteBucket={handleDeleteBucketItem}
|
||||
onClearBucketVisit={handleClearBucketVisit}
|
||||
onSearchBucket={handleBucketPoiSearch} onSelectBucketPoi={handleSelectBucketPoi}
|
||||
bucketSearchResults={bucketSearchResults} setBucketSearchResults={setBucketSearchResults} bucketPoiMonth={bucketPoiMonth} setBucketPoiMonth={setBucketPoiMonth}
|
||||
bucketPoiYear={bucketPoiYear} setBucketPoiYear={setBucketPoiYear} bucketSearching={bucketSearching}
|
||||
@@ -165,6 +189,8 @@ function AtlasPageDesktop(): React.ReactElement {
|
||||
t={t} dark={dark}
|
||||
/>
|
||||
</div>
|
||||
<div aria-hidden />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -463,6 +489,7 @@ interface SidebarContentProps {
|
||||
setBucketForm: (f: { name: string; notes: string; lat: string; lng: string; target_date: string }) => void
|
||||
onAddBucket: () => Promise<void>
|
||||
onDeleteBucket: (id: number) => Promise<void>
|
||||
onClearBucketVisit: (id: number) => void | Promise<void>
|
||||
onSearchBucket: () => Promise<void>
|
||||
onSelectBucketPoi: (result: any) => void
|
||||
bucketSearchResults: any[]
|
||||
@@ -478,7 +505,7 @@ interface SidebarContentProps {
|
||||
dark: boolean
|
||||
}
|
||||
|
||||
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onSearchBucket, onSelectBucketPoi, bucketSearchResults, setBucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement {
|
||||
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onClearBucketVisit, onSearchBucket, onSelectBucketPoi, bucketSearchResults, setBucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement {
|
||||
const { language } = useTranslation()
|
||||
const statsContentRef = useRef<HTMLDivElement>(null)
|
||||
const bucketSearchRowRef = useRef<HTMLDivElement>(null)
|
||||
@@ -552,6 +579,41 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
|
||||
return <span className="text-[9px] mt-0.5 text-center" style={{ color: tf }}>{label}</span>
|
||||
})()}
|
||||
{!item.target_date && item.notes && <span className="text-[9px] mt-0.5 text-center" style={{ color: tf, maxWidth: 90, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.notes}</span>}
|
||||
{/* Reached (#2279). A tick rather than removing the entry: a wish that
|
||||
came true is the part of the list worth keeping, and the source is
|
||||
named so a recording-derived tick is not mistaken for a manual one. */}
|
||||
{item.visited_at && (
|
||||
<Tooltip
|
||||
label={item.visited_source === 'dawarich'
|
||||
? `${t('dawarich.bucket.visitedFrom')} — ${t('dawarich.bucket.clearVisit')}`
|
||||
: ''}
|
||||
placement="top"
|
||||
>
|
||||
{/* A tick that came from a recording can be taken back here: the
|
||||
wishlist is curated by hand, and a suggestion nobody can undo
|
||||
is not a suggestion. A tick somebody set themselves is text. */}
|
||||
{item.visited_source === 'dawarich' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void onClearBucketVisit(item.id) }}
|
||||
aria-label={t('dawarich.bucket.clearVisit')}
|
||||
className="text-success mt-0.5 flex items-center gap-1 hover:opacity-70"
|
||||
style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', background: 'none', border: 'none', padding: 0, cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
<Check size={9} />
|
||||
{new Date(item.visited_at).toLocaleDateString(language)}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="text-success mt-0.5 flex items-center gap-1"
|
||||
style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))' }}
|
||||
>
|
||||
<Check size={9} />
|
||||
{new Date(item.visited_at).toLocaleDateString(language)}
|
||||
</span>
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
<button type="button" onClick={() => onDeleteBucket(item.id)}
|
||||
className="opacity-0 group-hover:opacity-100"
|
||||
style={{ position: 'absolute', top: 4, right: 4, background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: tf, display: 'flex', transition: 'opacity 0.15s' }}>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Plus, ChevronUp, ChevronDown, Eye, EyeOff, BookOpen, Image,
|
||||
} from 'lucide-react'
|
||||
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
|
||||
import DawarichSuggestionsPanel from '../components/Dawarich/DawarichSuggestionsPanel'
|
||||
import MobileEntryView from '../components/Journey/MobileEntryView'
|
||||
import { useJourneyStore } from '../store/journeyStore'
|
||||
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
|
||||
@@ -379,6 +380,19 @@ function JourneyDetailPageDesktop() {
|
||||
{/* Timeline (desktop only — mobile uses fullscreen combined view above) */}
|
||||
{!isMobile && (
|
||||
<div className={`flex flex-col gap-6 pb-24 md:pb-6${view === 'timeline' ? '' : ' hidden'}`}>
|
||||
{/* Stays Dawarich recorded over this journal's dates, waiting to
|
||||
be turned into entries (#2279). Above the timeline because
|
||||
that is what they become; renders nothing when there is
|
||||
nothing pending. */}
|
||||
{canEditEntries && (
|
||||
<DawarichSuggestionsPanel
|
||||
journals={[{ id: current.id, label: current.title }]}
|
||||
// The entry the acceptance created belongs on the timeline
|
||||
// immediately; re-reading the journey is how it gets there.
|
||||
onAccepted={() => { void loadJourney(current.id) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sortedDates.length === 0 && (
|
||||
<EmptyState scene="journey" title={t('journey.detail.noEntries')} />
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useSettingsStore } from '../store/settingsStore'
|
||||
import { MapViewAuto as MapView } from '../components/Map/MapViewAuto'
|
||||
import { MapCompassPill, type CompassMap } from '../components/Map/MapCompassPill'
|
||||
import { TripRouteOverviewPill, TripRouteOverviewPanel } from '../components/Map/TripRouteOverview'
|
||||
import { DawarichTrailPill } from '../components/Map/DawarichTrailPill'
|
||||
import { getCached, fetchPhoto } from '../services/photoService'
|
||||
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
|
||||
import RoadtripModeSwitch from '../components/Roadtrip/RoadtripModeSwitch'
|
||||
@@ -255,6 +256,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
|
||||
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
|
||||
overviewActive, tripOverview, toggleOverview, overviewShown,
|
||||
dawarichEnabled, dawarichTrailShown, toggleDawarichTrail, dawarichTrail,
|
||||
followTrack, roadtripViaCounts,
|
||||
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
|
||||
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
|
||||
@@ -406,6 +408,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
<MapView
|
||||
tripId={tripId}
|
||||
dawarichTrack={dawarichTrail.track}
|
||||
places={roadtripActive ? roadtripMapPlaces : mapPlaces}
|
||||
dayPlaces={dayPlaces}
|
||||
route={roadtripActive ? roadtripMapLines : overviewActive ? tripOverview.lines : route}
|
||||
@@ -484,12 +487,12 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
bottom-left corner, where Leaflet's base-layer switcher sits at
|
||||
z-index 1000 and would cover this. The right corner is free on both
|
||||
renderers — the locate button that lives there is phone-only. */}
|
||||
{!roadtripActive && (
|
||||
{(!roadtripActive || dawarichEnabled) && (
|
||||
<div className="hidden md:flex" style={{
|
||||
position: 'absolute', bottom: 18, right: mapInsetRight + 14, zIndex: 26,
|
||||
pointerEvents: 'none', flexDirection: 'column', alignItems: 'flex-end', gap: 8,
|
||||
}}>
|
||||
{overviewActive && (
|
||||
{!roadtripActive && overviewActive && (
|
||||
<TripRouteOverviewPanel
|
||||
overview={tripOverview}
|
||||
unit={distanceUnit}
|
||||
@@ -497,7 +500,20 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
onSelectDay={handleSelectDay}
|
||||
/>
|
||||
)}
|
||||
<TripRouteOverviewPill active={overviewShown} onToggle={toggleOverview} />
|
||||
{!roadtripActive && (
|
||||
<TripRouteOverviewPill active={overviewShown} onToggle={toggleOverview} />
|
||||
)}
|
||||
{/* Stays in road-trip mode, unlike the overview: the route that was
|
||||
actually driven is the thing you most want beside the planned
|
||||
one. It is drawn, never applied — correcting the plan from the
|
||||
recording is a different feature and deliberately not this one. */}
|
||||
{dawarichEnabled && (
|
||||
<DawarichTrailPill
|
||||
active={dawarichTrailShown}
|
||||
status={dawarichTrail.status}
|
||||
onToggle={toggleDawarichTrail}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -154,6 +154,13 @@ function baseState(): HookState {
|
||||
askRefuel: vi.fn(),
|
||||
acceptRefuel: vi.fn(),
|
||||
followTrack: { dayId: null, open: vi.fn(), close: vi.fn(), tracks: [], busy: false, round: 0, error: null, outcome: null, apply: vi.fn(), clear: vi.fn(), viaCount: 0, available: false },
|
||||
// The recorded-route overlay (#2279). Off and empty: the page reads
|
||||
// `dawarichTrail.track` unconditionally, so the fixture has to carry the
|
||||
// shape the hook returns even when the addon is not in play.
|
||||
dawarichEnabled: false,
|
||||
dawarichTrailShown: false,
|
||||
toggleDawarichTrail: vi.fn(),
|
||||
dawarichTrail: { track: null, status: 'idle', reload: vi.fn() },
|
||||
roadtripViaCounts: {},
|
||||
stopDraft: null,
|
||||
setStopDraft: vi.fn(),
|
||||
|
||||
@@ -105,6 +105,10 @@ export interface BucketItem {
|
||||
country_code: string | null
|
||||
notes: string | null
|
||||
target_date: string | null
|
||||
/** ISO-8601 of the stay that fulfilled this wish, or null while it is still a wish. */
|
||||
visited_at?: string | null
|
||||
/** Who decided it was reached: 'manual' or 'dawarich' (#2279). */
|
||||
visited_source?: string | null
|
||||
}
|
||||
|
||||
// Normalize a region name for matching: strip diacritics (the geocoder and the
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState, useRef } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { getIntlLanguage, getLocaleForLanguage, useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -11,6 +11,8 @@ import L from 'leaflet'
|
||||
import type { GeoJsonFeatureCollection } from '../../types'
|
||||
import { A2_TO_A3, countryStatus, findBucketDuplicate, isBucketDuplicateError, isCountryVisible, normalizeRegionName, regionCacheEvictions, withCountryMarkedVisited, wishlistA3Codes, countryColor, REGION_CACHE_MAX, bucketTooltipWidth, bucketTooltipPlacement, bucketTooltipNeedsScroll, type AtlasData, type AtlasPlaceHit, type CountryDetail, type BucketItem } from './atlasModel'
|
||||
import { continentForCountry, escapeHtml, type VisitStatus } from '@trek/shared'
|
||||
import { useGlassGlare } from '../../components/Atlas/useGlassGlare'
|
||||
import { dawarichApi } from '../../api/dawarich'
|
||||
import { useToast } from '../../components/shared/Toast'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
|
||||
@@ -105,29 +107,11 @@ export function useAtlas() {
|
||||
// kept redrawing itself on every pan for the rest of the session (#1950).
|
||||
const countryRendererRef = useRef<L.Canvas | null>(null)
|
||||
const regionRendererRef = useRef<L.SVG | null>(null)
|
||||
const glareRef = useRef<HTMLDivElement>(null)
|
||||
const borderGlareRef = useRef<HTMLDivElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
// The panel's hover light, shared with the Dawarich panel beside it.
|
||||
const { panelRef, glareRef, borderGlareRef, onMouseMove: handlePanelMouseMove, onMouseLeave: handlePanelMouseLeave } =
|
||||
useGlassGlare(dark)
|
||||
const country_layer_by_a2_ref = useRef<Record<string, any>>({})
|
||||
|
||||
const handlePanelMouseMove = (e: React.MouseEvent<HTMLDivElement>): void => {
|
||||
if (!panelRef.current || !glareRef.current || !borderGlareRef.current) return
|
||||
const rect = panelRef.current.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const y = e.clientY - rect.top
|
||||
// Subtle inner glow
|
||||
glareRef.current.style.background = `radial-gradient(circle 300px at ${x}px ${y}px, ${dark ? 'rgba(255,255,255,0.025)' : 'rgba(255,255,255,0.25)'} 0%, transparent 70%)`
|
||||
glareRef.current.style.opacity = '1'
|
||||
// Border glow that follows cursor
|
||||
borderGlareRef.current.style.opacity = '1'
|
||||
borderGlareRef.current.style.maskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
|
||||
borderGlareRef.current.style.webkitMaskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
|
||||
}
|
||||
const handlePanelMouseLeave = () => {
|
||||
if (glareRef.current) glareRef.current.style.opacity = '0'
|
||||
if (borderGlareRef.current) borderGlareRef.current.style.opacity = '0'
|
||||
}
|
||||
|
||||
const [data, setData] = useState<AtlasData | null>(null)
|
||||
const [loading, setLoading] = useState<boolean>(true)
|
||||
const [sidebarOpen, setSidebarOpen] = useState<boolean>(true)
|
||||
@@ -224,6 +208,30 @@ export function useAtlas() {
|
||||
}, [geoData, resolveName])
|
||||
|
||||
// Load atlas data + bucket list
|
||||
//
|
||||
// Re-run on `atlasEpoch` so a confirmation made elsewhere on the page — the
|
||||
// Dawarich card ticking wishes off or marking countries (#2279) — lands in the
|
||||
// same numbers the map is drawing, rather than only after a reload.
|
||||
const [atlasEpoch, setAtlasEpoch] = useState(0)
|
||||
const reloadAfterDawarich = useCallback(() => setAtlasEpoch(epoch => epoch + 1), [])
|
||||
|
||||
/**
|
||||
* Undo a wish that a recording ticked off.
|
||||
*
|
||||
* A suggestion that cannot be taken back is not a suggestion, and this one
|
||||
* writes into the wishlist — the one list on this page somebody curates by
|
||||
* hand. The row is kept; only the visit is cleared.
|
||||
*/
|
||||
const handleClearBucketVisit = useCallback(async (itemId: number) => {
|
||||
try {
|
||||
await dawarichApi.clearBucketVisit(itemId)
|
||||
setBucketList(prev => prev.map(item => (
|
||||
item.id === itemId ? { ...item, visited_at: null, visited_source: null } : item
|
||||
)))
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
}, [t, toast])
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
apiClient.get('/addons/atlas/stats'),
|
||||
@@ -233,7 +241,7 @@ export function useAtlas() {
|
||||
setBucketList(bucketRes.data.items || [])
|
||||
setLoading(false)
|
||||
}).catch(() => setLoading(false))
|
||||
}, [])
|
||||
}, [atlasEpoch])
|
||||
|
||||
// Load country-border GeoJSON from our API (geoBoundaries, served server-side —
|
||||
// no third-party fetch from the browser). Even gzipped the payload is a few MB, so
|
||||
@@ -1176,6 +1184,6 @@ export function useAtlas() {
|
||||
handleAddBucketItem, handleDeleteBucketItem, handleBucketPoiSearch, handleSelectBucketPoi,
|
||||
bucketSearchResults, setBucketSearchResults,
|
||||
bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear,
|
||||
bucketSearching, bucketSearch, setBucketSearch,
|
||||
bucketSearching, bucketSearch, setBucketSearch, reloadAfterDawarich, handleClearBucketVisit,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import { lockBodyScroll } from '../../utils/bodyScrollLock'
|
||||
import type { JourneyEntry } from '../../store/journeyStore'
|
||||
import { createDraftJourneyEntry } from './JourneyDetailPage.helpers'
|
||||
|
||||
import { useDawarichJournalTrail } from '../../hooks/useDawarichJournalTrail'
|
||||
|
||||
/** Stable identity for "this journey draws no trip tracks" (#2194). */
|
||||
const NO_TRACKS: JourneyTrack[] = []
|
||||
|
||||
@@ -371,6 +373,17 @@ export function useJourneyDetail() {
|
||||
return dates
|
||||
}, [current?.trips])
|
||||
|
||||
// The route actually recorded over the journal's own dates (#2279), drawn on
|
||||
// the same layer as the GPX tracks because it is the same kind of thing: a
|
||||
// recording, not a line connecting entries. Gated on `show_trip_tracks` for
|
||||
// the same reason that switch exists — it gates the REQUEST, and a map nobody
|
||||
// asked for should not reach across the network for it.
|
||||
const dawarichTrail = useDawarichJournalTrail(tripDates, showTripTracks)
|
||||
const mapTracks = useMemo(
|
||||
() => (dawarichTrail.tracks.length > 0 ? [...tracks, ...dawarichTrail.tracks] : tracks),
|
||||
[tracks, dawarichTrail.tracks],
|
||||
)
|
||||
|
||||
/** Studio's margin to the window on all four sides — see `.st-root` in studio.css. */
|
||||
const STUDIO_INSET = 16
|
||||
|
||||
@@ -412,7 +425,7 @@ export function useJourneyDetail() {
|
||||
hideSkeletons, setHideSkeletons,
|
||||
mapRef, fullMapRef, galleryUploadRef, galleryProviders, setGalleryProviders, galleryBrowseRef,
|
||||
activeLocationId, handleMarkerClick, handleLocationClick,
|
||||
mapEntries, sidebarMapItems, tripDates, isMobile, tracks,
|
||||
mapEntries, sidebarMapItems, tripDates, isMobile, tracks: mapTracks, dawarichTrail,
|
||||
feedEdge, scrollFeedTo,
|
||||
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto,
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ export function useSettings() {
|
||||
const mcpEnabled = addonEnabled('mcp')
|
||||
const airtrailEnabled = addonEnabled('airtrail')
|
||||
const llmEnabled = addonEnabled('llm_parsing')
|
||||
const hasIntegrations = memoriesEnabled || mcpEnabled || airtrailEnabled || llmEnabled
|
||||
const dawarichEnabled = addonEnabled('dawarich')
|
||||
const hasIntegrations = memoriesEnabled || mcpEnabled || airtrailEnabled || llmEnabled || dawarichEnabled
|
||||
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState('display')
|
||||
|
||||
@@ -31,6 +31,7 @@ import { useDayBoundaries } from '../../components/Roadtrip/useDayBoundaries'
|
||||
import type { DayBoundaryControls } from '../../components/Map/dayBoundaryDrag'
|
||||
import { dayWindow, roadtripInsertion } from '../../components/Roadtrip/dayWindow'
|
||||
import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview'
|
||||
import { useDawarichTrail } from '../../components/Map/useDawarichTrail'
|
||||
import { useRoadtripCorridor } from '../../components/Roadtrip/useRoadtripCorridor'
|
||||
import { useRoadtripVias } from '../../components/Roadtrip/useRoadtripVias'
|
||||
import { useRefuelSearch } from '../../components/Roadtrip/useRefuelSearch'
|
||||
@@ -117,7 +118,7 @@ export function useTripPlanner() {
|
||||
toast.info(t('undo.done', { action: label ?? '' }))
|
||||
}, [undo, lastActionLabel, toast])
|
||||
|
||||
const [enabledAddons, setEnabledAddons] = useState<Record<string, boolean>>({ packing: true, budget: true, documents: true, collab: false, roadtrip: false })
|
||||
const [enabledAddons, setEnabledAddons] = useState<Record<string, boolean>>({ packing: true, budget: true, documents: true, collab: false, roadtrip: false, dawarich: false })
|
||||
// The values above are an optimistic guess until the addon feed answers. The
|
||||
// tab guard below waits for this before evicting anything, so a tab we were
|
||||
// asked to open ('collab' in particular, guessed off) survives the gap.
|
||||
@@ -164,7 +165,7 @@ export function useTripPlanner() {
|
||||
addonsApi.enabled().then(data => {
|
||||
const map: Record<string, boolean> = {}
|
||||
data.addons.forEach(a => { map[a.id] = true })
|
||||
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab, roadtrip: !!map.roadtrip })
|
||||
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab, roadtrip: !!map.roadtrip, dawarich: !!map.dawarich })
|
||||
if (data.collabFeatures) setCollabFeatures(data.collabFeatures)
|
||||
}).catch(() => {}).finally(() => setAddonsLoaded(true))
|
||||
authApi.getAppConfig().then(config => {
|
||||
@@ -391,6 +392,23 @@ export function useTripPlanner() {
|
||||
return next
|
||||
})
|
||||
}, [tripId])
|
||||
// The recorded route from Dawarich (#2279), per trip and per session for the
|
||||
// same reason as the overview above: it answers "what actually happened on
|
||||
// this trip", which is a question about one trip rather than a preference.
|
||||
const [dawarichTrailShown, setDawarichTrailShown] = useState<boolean>(
|
||||
() => sessionStorage.getItem(`trip-dawarich-${tripId}`) === '1',
|
||||
)
|
||||
const toggleDawarichTrail = useCallback(() => {
|
||||
setDawarichTrailShown(prev => {
|
||||
const next = !prev
|
||||
sessionStorage.setItem(`trip-dawarich-${tripId}`, next ? '1' : '0')
|
||||
return next
|
||||
})
|
||||
}, [tripId])
|
||||
// Fetched here rather than in MapViewAuto so the desktop and the phone share
|
||||
// one request, and so the pill that toggles it can show why there is no line.
|
||||
const dawarichTrail = useDawarichTrail(tripId, dawarichTrailShown)
|
||||
|
||||
const [fitKey, setFitKey] = useState<number>(0)
|
||||
const initialFitTripId = useRef<number | null>(null)
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
|
||||
@@ -2177,6 +2195,7 @@ export function useTripPlanner() {
|
||||
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
|
||||
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
|
||||
overviewShown, toggleOverview, overviewActive, tripOverview,
|
||||
dawarichTrailShown, toggleDawarichTrail, dawarichTrail, dawarichEnabled: !!enabledAddons.dawarich,
|
||||
followTrack, roadtripViaCounts,
|
||||
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
|
||||
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
dawarichAtlasSuggestionsSchema,
|
||||
dawarichBucketScanSchema,
|
||||
dawarichSuggestionListSchema,
|
||||
dawarichTrackSchema,
|
||||
} from '@trek/shared'
|
||||
import { dawarichApi } from '../api/dawarich'
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
|
||||
/**
|
||||
* Reads from the Dawarich integration.
|
||||
*
|
||||
* **Deliberately online-only, and no Dexie table.** Everything here is a live
|
||||
* read of somebody's location history on a server TREK does not own: a cached
|
||||
* recording is a stale one, and a cached copy of a GPS archive is the thing
|
||||
* this integration exists not to keep. The precedent in this directory is
|
||||
* `roadtripHazardsRepo` / `chargingRepo` / `schoolHolidayCatalogRepo` — same
|
||||
* shape, same reason.
|
||||
*
|
||||
* The offline check is `isEffectivelyOffline()` rather than `navigator.onLine`,
|
||||
* so the manual offline switch counts: someone who turned it on before a flight
|
||||
* has said they do not want requests fired, and firing them anyway would block
|
||||
* the UI on a timeout.
|
||||
*
|
||||
* Responses are parsed against the shared schema rather than cast. A server
|
||||
* that answered something unexpected should fail here, loudly, not three
|
||||
* components later where the cause is invisible.
|
||||
*/
|
||||
|
||||
/** Thrown before a request is made when the device is offline by choice or by circumstance. */
|
||||
export class DawarichOfflineError extends Error {
|
||||
constructor() {
|
||||
super('Dawarich needs a connection')
|
||||
this.name = 'DawarichOfflineError'
|
||||
}
|
||||
}
|
||||
|
||||
function requireOnline(): void {
|
||||
if (isEffectivelyOffline()) throw new DawarichOfflineError()
|
||||
}
|
||||
|
||||
export const dawarichRepo = {
|
||||
async suggestions(params?: { tripId?: number; state?: string }) {
|
||||
requireOnline()
|
||||
return dawarichSuggestionListSchema.parse(await dawarichApi.listSuggestions(params))
|
||||
},
|
||||
|
||||
async tripTrack(tripId: number, range?: { from?: string; to?: string }, signal?: AbortSignal) {
|
||||
requireOnline()
|
||||
return dawarichTrackSchema.parse(await dawarichApi.tripTrack(tripId, range, signal))
|
||||
},
|
||||
|
||||
async windowTrack(from: string, to: string, signal?: AbortSignal) {
|
||||
requireOnline()
|
||||
return dawarichTrackSchema.parse(await dawarichApi.windowTrack(from, to, signal))
|
||||
},
|
||||
|
||||
async bucketScan() {
|
||||
requireOnline()
|
||||
return dawarichBucketScanSchema.parse(await dawarichApi.scanBucketList())
|
||||
},
|
||||
|
||||
async atlasSuggestions(from: string, to: string) {
|
||||
requireOnline()
|
||||
return dawarichAtlasSuggestionsSchema.parse(await dawarichApi.atlasSuggestions(from, to))
|
||||
},
|
||||
}
|
||||
@@ -193,6 +193,13 @@ export function buildPlanner(overrides: Partial<TripPlanner> = {}): TripPlanner
|
||||
expandedDayIds: new Set<number>(),
|
||||
setExpandedDayIds: vi.fn(),
|
||||
mapPlaces: [],
|
||||
// The recorded-route overlay (#2279): off and empty. MMapArea reads
|
||||
// `dawarichTrail.track` unconditionally, so the shape has to be here even
|
||||
// when the addon is not in play.
|
||||
dawarichEnabled: false,
|
||||
dawarichTrailShown: false,
|
||||
toggleDawarichTrail: vi.fn(),
|
||||
dawarichTrail: { track: null, status: 'idle', reload: vi.fn() },
|
||||
|
||||
route: null,
|
||||
routeSegments: [],
|
||||
|
||||
@@ -180,12 +180,19 @@ function buildHook(over: Record<string, unknown> = {}): Record<string, unknown>
|
||||
};
|
||||
}
|
||||
|
||||
function setup(over: Record<string, unknown> = {}) {
|
||||
function setup(over: Record<string, unknown> = {}, entries?: string[]) {
|
||||
mocks.detail = buildHook(over);
|
||||
const view = render(<MJourneyDetail />);
|
||||
const view = render(<MJourneyDetail />, entries ? { initialEntries: entries } : undefined);
|
||||
return { ...view, hook: mocks.detail };
|
||||
}
|
||||
|
||||
/**
|
||||
* Opening the upload the way the shell does since the Gallery took over the
|
||||
* dock's FAB: the button asks through `?create=photo`, the screen answers. The
|
||||
* header no longer carries an upload button of its own — one screen, one upload.
|
||||
*/
|
||||
const UPLOAD_URL = ['/journey/1?create=photo'];
|
||||
|
||||
const journeyStoreInitial = useJourneyStore.getState();
|
||||
let uploadGalleryPhotos: ReturnType<typeof vi.fn>;
|
||||
let createEntry: ReturnType<typeof vi.fn>;
|
||||
@@ -355,9 +362,8 @@ describe('MJourneyDetail', () => {
|
||||
],
|
||||
});
|
||||
vi.mocked(memoriesApi.status).mockResolvedValue({ connected: true });
|
||||
setup({ view: 'gallery' });
|
||||
setup({ view: 'gallery' }, UPLOAD_URL);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'common.upload' }));
|
||||
expect(await screen.findByText('mobileJourney.uploadFromDevice')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('mobileJourney.browseProvider'));
|
||||
expect(await screen.findByTestId('provider-picker')).toBeInTheDocument();
|
||||
@@ -370,10 +376,9 @@ describe('MJourneyDetail', () => {
|
||||
vi.mocked(memoriesApi.status).mockResolvedValue({ connected: true });
|
||||
const toGallery = vi.spyOn(journeyApi, 'addProviderPhotosToGallery').mockResolvedValue({ added: 3 });
|
||||
const toEntry = vi.spyOn(journeyApi, 'addProviderPhotos').mockResolvedValue({ added: 1 });
|
||||
const { hook } = setup({ view: 'gallery' });
|
||||
const { hook } = setup({ view: 'gallery' }, UPLOAD_URL);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'common.upload' }));
|
||||
fireEvent.click(screen.getByText('mobileJourney.browseProvider'));
|
||||
fireEvent.click(await screen.findByText('mobileJourney.browseProvider'));
|
||||
await screen.findByTestId('provider-picker');
|
||||
|
||||
const onAdd = mocks.captured.picker.onAdd as (g: { assetIds: string[] }[], entryId?: number) => Promise<void>;
|
||||
@@ -392,10 +397,9 @@ describe('MJourneyDetail', () => {
|
||||
});
|
||||
vi.mocked(memoriesApi.status).mockResolvedValue({ connected: true });
|
||||
vi.spyOn(journeyApi, 'addProviderPhotosToGallery').mockRejectedValue(new Error('boom'));
|
||||
setup({ view: 'gallery' });
|
||||
setup({ view: 'gallery' }, UPLOAD_URL);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'common.upload' }));
|
||||
fireEvent.click(screen.getByText('mobileJourney.browseProvider'));
|
||||
fireEvent.click(await screen.findByText('mobileJourney.browseProvider'));
|
||||
await screen.findByTestId('provider-picker');
|
||||
|
||||
const onAdd = mocks.captured.picker.onAdd as (g: { assetIds: string[] }[], entryId?: number) => Promise<void>;
|
||||
@@ -509,11 +513,11 @@ describe('MJourneyDetail', () => {
|
||||
addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', enabled: true }],
|
||||
});
|
||||
vi.mocked(memoriesApi.status).mockResolvedValue({ connected: true });
|
||||
const { container } = setup({ view: 'gallery' });
|
||||
const input = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const click = vi.spyOn(input, 'click').mockImplementation(() => {});
|
||||
// The upload opens on mount now (the dock asks through the URL), so the spy
|
||||
// goes on the prototype rather than on an input that does not exist yet.
|
||||
const click = vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => {});
|
||||
setup({ view: 'gallery' }, UPLOAD_URL);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'common.upload' }));
|
||||
fireEvent.click(await screen.findByText('mobileJourney.uploadFromDevice'));
|
||||
expect(click).toHaveBeenCalled();
|
||||
await waitFor(() => expect(screen.queryByText('mobileJourney.uploadFromDevice')).not.toBeInTheDocument());
|
||||
@@ -524,12 +528,10 @@ describe('MJourneyDetail', () => {
|
||||
addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', enabled: true }],
|
||||
});
|
||||
vi.mocked(memoriesApi.status).mockResolvedValue({ connected: false });
|
||||
const { container } = setup({ view: 'gallery' });
|
||||
const input = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const click = vi.spyOn(input, 'click').mockImplementation(() => {});
|
||||
const click = vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => {});
|
||||
setup({ view: 'gallery' }, UPLOAD_URL);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'common.upload' }));
|
||||
expect(click).toHaveBeenCalled();
|
||||
await waitFor(() => expect(click).toHaveBeenCalled());
|
||||
expect(screen.queryByText('mobileJourney.uploadFromDevice')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -545,7 +547,7 @@ describe('MJourneyDetail', () => {
|
||||
editingEntry: buildEntry({ id: 4 }),
|
||||
showSettings: true,
|
||||
showInvite: true,
|
||||
});
|
||||
}, UPLOAD_URL);
|
||||
|
||||
(mocks.captured.entrySheet.onClose as () => void)();
|
||||
expect(hook.setEditingEntry).toHaveBeenCalledWith(null);
|
||||
@@ -554,8 +556,7 @@ describe('MJourneyDetail', () => {
|
||||
(mocks.captured.invite.onClose as () => void)();
|
||||
expect(hook.setShowInvite).toHaveBeenCalledWith(false);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'common.upload' }));
|
||||
fireEvent.click(screen.getByText('mobileJourney.browseProvider'));
|
||||
fireEvent.click(await screen.findByText('mobileJourney.browseProvider'));
|
||||
await screen.findByTestId('provider-picker');
|
||||
expect([...(mocks.captured.picker.existingAssetIds as Set<string>)]).toEqual(['asset-1']);
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const dawarich: TranslationStrings = {
|
||||
// ── Connection ─────────────────────────────────────────────────────────────
|
||||
'dawarich.title': 'Dawarich',
|
||||
'dawarich.intro':
|
||||
'اربط خادم Dawarich الخاص بك لترى أين ذهبت فعلاً. يقرأه TREK ويقترح مدوّنات وأماكن ودولاً — لا يُضاف شيء قبل أن تؤكده، ولا يُكتب أي شيء في Dawarich.',
|
||||
'dawarich.url': 'عنوان الخادم',
|
||||
'dawarich.apiKey': 'مفتاح API',
|
||||
'dawarich.apiKeyPlaceholder': 'الصق مفتاح API الخاص بـ Dawarich',
|
||||
'dawarich.apiKeyHint': 'تجده في Dawarich ضمن الحساب ← مفتاح API. يُخزَّن مشفَّراً ولا يُعرض مرة أخرى.',
|
||||
'dawarich.allowInsecureTls': 'السماح بشهادة موقّعة ذاتياً',
|
||||
'dawarich.allowInsecureTlsHint': 'لا يلزم إلا إذا كان خادمك يستخدم شهادة لا يثق بها خادمك.',
|
||||
'dawarich.syncEnabled': 'التحقق من الإقامات الجديدة تلقائياً',
|
||||
'dawarich.syncEnabledHint': 'عند الإيقاف، لا يقرأ TREK من Dawarich إلا حين تطلب ذلك.',
|
||||
'dawarich.test.button': 'اختبار الاتصال',
|
||||
'dawarich.test.success': 'تم الاتصال. تم العثور على {count} إقامة خلال آخر 30 يوماً.',
|
||||
'dawarich.test.failed': 'تعذّر الوصول إلى Dawarich.',
|
||||
'dawarich.syncNow': 'تحقق الآن',
|
||||
'dawarich.connected': 'متصل',
|
||||
'dawarich.notConnected': 'غير متصل',
|
||||
'dawarich.disconnect': 'قطع الاتصال',
|
||||
'dawarich.lastSync': 'آخر تحقق {when}',
|
||||
'dawarich.neverSynced': 'لم يتم التحقق بعد',
|
||||
'dawarich.syncPartial': 'تعذّرت قراءة بعض الرحلات',
|
||||
'dawarich.serverVersion': 'Dawarich {version}',
|
||||
|
||||
'dawarich.toast.saved': 'تم حفظ اتصال Dawarich',
|
||||
'dawarich.toast.saveError': 'تعذّر حفظ الاتصال',
|
||||
'dawarich.toast.disconnected': 'تم قطع الاتصال بـ Dawarich',
|
||||
'dawarich.toast.synced': 'تم العثور على {count} إقامة جديدة',
|
||||
'dawarich.toast.syncError': 'تعذّرت القراءة من Dawarich',
|
||||
'dawarich.toast.syncRunning': 'هناك فحص قيد التشغيل بالفعل',
|
||||
'dawarich.toast.acceptError': 'تعذّرت إضافة هذا',
|
||||
'dawarich.toast.updateError': 'تعذّر تحديث هذا الاقتراح',
|
||||
'dawarich.toast.accepted.place': 'تمت الإضافة إلى الرحلة',
|
||||
'dawarich.toast.accepted.journal': 'تمت الإضافة إلى المجلة',
|
||||
'dawarich.toast.accepted.bucket_list': 'تم شطبها من قائمة أمنياتك',
|
||||
|
||||
// ── What the connected instance can do ─────────────────────────────────────
|
||||
'dawarich.capability.visits': 'الإقامات',
|
||||
'dawarich.capability.track': 'المسار المسجَّل',
|
||||
'dawarich.capability.locations': 'مطابقة قائمة الأمنيات',
|
||||
'dawarich.capability.visitedCities': 'الدول والمدن',
|
||||
'dawarich.capability.missing': 'هذه النسخة من Dawarich لا توفّر: {features}.',
|
||||
|
||||
// ── Failure reasons, as sentences the reader can act on ────────────────────
|
||||
'dawarich.error.unreachable': 'تعذّر على TREK الوصول إلى هذا العنوان.',
|
||||
'dawarich.error.unauthorized': 'رفض Dawarich مفتاح API.',
|
||||
'dawarich.error.forbidden': 'هذا المفتاح غير مسموح له بقراءة هذا.',
|
||||
'dawarich.error.not_found': 'هذه النسخة من Dawarich لا تحتوي على هذه الواجهة.',
|
||||
'dawarich.error.rate_limited': 'طلب Dawarich من TREK التمهّل. حاول مرة أخرى بعد قليل.',
|
||||
'dawarich.error.server_error': 'ردّ Dawarich بخطأ.',
|
||||
'dawarich.error.invalid_response': 'ردّ هذا العنوان بشيء ليس Dawarich.',
|
||||
'dawarich.error.too_large': 'أرسل Dawarich بيانات أكثر مما يقرأه TREK دفعة واحدة.',
|
||||
'dawarich.error.not_connected': 'لا يوجد خادم Dawarich متصل بعد.',
|
||||
'dawarich.error.addon_disabled': 'إضافة Dawarich معطّلة على هذا الخادم.',
|
||||
'dawarich.error.offline': 'يحتاج هذا إلى اتصال — TREK غير متصل حالياً.',
|
||||
'dawarich.error.invalid_url': 'لا يمكن لـ TREK استخدام هذا العنوان.',
|
||||
'dawarich.warning.private_ip': 'هذا العنوان يشير إلى عنوان IP خاص ({ip}). تأكد أن هذا ما تقصده — قد يحتاج الخادم إلى ALLOW_INTERNAL_NETWORK=true للوصول إليه.',
|
||||
'dawarich.error.unknown': 'حدث خطأ ما أثناء التواصل مع Dawarich.',
|
||||
|
||||
// ── The recorded route on the map ──────────────────────────────────────────
|
||||
'dawarich.trail.show': 'إظهار المسار المسجَّل',
|
||||
'dawarich.trail.hide': 'إخفاء المسار المسجَّل',
|
||||
'dawarich.trail.loading': 'جارٍ تحميل المسار المسجَّل…',
|
||||
'dawarich.trail.empty': 'لم يُسجَّل شيء في هذه التواريخ',
|
||||
'dawarich.trail.offline': 'يحتاج المسار المسجَّل إلى اتصال',
|
||||
'dawarich.trail.unavailable': 'تعذّر تحميل المسار المسجَّل',
|
||||
|
||||
// ── Suggestions ────────────────────────────────────────────────────────────
|
||||
'dawarich.duration.minutes': '{minutes} د',
|
||||
'dawarich.duration.hours': '{hours} س',
|
||||
'dawarich.duration.hoursMinutes': '{hours} س {minutes} د',
|
||||
'dawarich.checkedAgo': 'تم التحقق {ago}',
|
||||
|
||||
'dawarich.badge.lowConfidence': 'غير مؤكد',
|
||||
'dawarich.badge.sourceChanged': 'تغيّر في Dawarich',
|
||||
'dawarich.badge.sourceMissing': 'اختفى من Dawarich',
|
||||
|
||||
'dawarich.suggestions.title': 'من Dawarich',
|
||||
'dawarich.suggestions.pending': '{count} في انتظارك',
|
||||
'dawarich.suggestions.loading': 'جارٍ القراءة من Dawarich…',
|
||||
'dawarich.suggestions.notConnected': 'اربط Dawarich من الإعدادات لترى إقاماتك هنا.',
|
||||
'dawarich.suggestions.unavailable': 'تعذّرت القراءة من Dawarich.',
|
||||
'dawarich.suggestions.allHandled': 'تمت معالجة كل ما سُجِّل هنا.',
|
||||
'dawarich.suggestions.asJournal': 'كتابة مدوّنة',
|
||||
'dawarich.suggestions.asPlace': 'إضافة كمكان',
|
||||
'dawarich.suggestions.dismiss': 'ليس مكاناً زرته',
|
||||
'dawarich.suggestions.dismissed': 'تم التجاهل',
|
||||
'dawarich.suggestions.restore': 'إعادته',
|
||||
'dawarich.suggestions.showHandled': 'إظهار {count} تمت معالجتها',
|
||||
'dawarich.suggestions.hideHandled': 'إخفاء ما تمت معالجته',
|
||||
'dawarich.suggestions.matchesWish': 'في قائمة أمنياتك: {name}',
|
||||
'dawarich.suggestions.acceptedAs.place': 'تمت الإضافة كمكان',
|
||||
'dawarich.suggestions.acceptedAs.journal': 'في المجلة',
|
||||
'dawarich.suggestions.acceptedAs.bucket_list': 'تم شطب الأمنية',
|
||||
'dawarich.suggestions.sourceChanged':
|
||||
'تغيّرت هذه الإقامة في Dawarich منذ أن استخدمتها. ما كتبته في TREK لم يُمَس.',
|
||||
'dawarich.suggestions.sourceMissing':
|
||||
'لم تعد هذه الإقامة موجودة في Dawarich. ما كتبته في TREK لم يُمَس.',
|
||||
'dawarich.sourceStatus.suggested': 'مُكتشَفة، غير مؤكدة',
|
||||
'dawarich.confidence.high': 'اكتشاف موثوق',
|
||||
'dawarich.confidence.medium': 'اكتشاف موثوق إلى حدٍّ ما',
|
||||
'dawarich.confidence.low': 'اكتشاف غير مؤكد',
|
||||
|
||||
// ── The review step ────────────────────────────────────────────────────────
|
||||
'dawarich.accept.title.place': 'إضافة هذه الإقامة كمكان',
|
||||
'dawarich.accept.title.journal': 'كتابة مدوّنة',
|
||||
'dawarich.accept.title.bucket_list': 'شطب أمنية',
|
||||
'dawarich.accept.confirm.place': 'إضافة المكان',
|
||||
'dawarich.accept.confirm.journal': 'إضافة المدوّنة',
|
||||
'dawarich.accept.confirm.bucket_list': 'اشطبها',
|
||||
'dawarich.accept.recorded': 'مسجَّلة من {from} حتى {to}',
|
||||
'dawarich.accept.duration': '{minutes} دقيقة',
|
||||
'dawarich.accept.name': 'الاسم',
|
||||
'dawarich.accept.date': 'التاريخ',
|
||||
'dawarich.accept.from': 'الوصول',
|
||||
'dawarich.accept.to': 'المغادرة',
|
||||
'dawarich.accept.trip': 'الرحلة',
|
||||
'dawarich.accept.thisTrip': 'هذه الرحلة',
|
||||
'dawarich.accept.pickTrip': 'اختر رحلة',
|
||||
'dawarich.accept.day': 'اليوم',
|
||||
'dawarich.accept.noDay': 'ليست ضمن يوم بعد',
|
||||
'dawarich.accept.journal': 'المجلة',
|
||||
'dawarich.accept.pickJournal': 'اختر مجلة',
|
||||
'dawarich.accept.notes': 'ملاحظات',
|
||||
'dawarich.accept.story': 'قصتك',
|
||||
'dawarich.accept.storyPlaceholder': 'ماذا حدث هنا؟',
|
||||
'dawarich.accept.photosHint': 'أضف الصور إلى المدوّنة بعد إنشائها.',
|
||||
|
||||
// ── A place that came out of a recording ──────────────────────────────────
|
||||
'dawarich.place.fromDawarich': 'أُضيف من تسجيلات Dawarich الخاصة بك',
|
||||
|
||||
// ── Wishlist ───────────────────────────────────────────────────────────────
|
||||
'dawarich.bucket.title': 'قارن قائمة أمنياتك بـ Dawarich',
|
||||
'dawarich.bucket.description':
|
||||
'يبحث في تسجيلاتك عن الأماكن التي أردت الوصول إليها. الزيارة تحتاج قرباً ووقتاً معاً — المرور بالسيارة لا يُحتسب.',
|
||||
'dawarich.bucket.scan': 'فحص قائمة الأمنيات',
|
||||
'dawarich.bucket.scanning': 'جارٍ الفحص…',
|
||||
'dawarich.bucket.noMatches': 'لم يظهر أي شيء من قائمة أمنياتك في تسجيلاتك.',
|
||||
'dawarich.bucket.alreadyVisited': 'مشطوبة مسبقاً',
|
||||
'dawarich.bucket.confirm': 'اشطب {count}',
|
||||
'dawarich.bucket.confirmed': 'تم شطب {count} أمنية',
|
||||
'dawarich.bucket.skipped': '{count} مُدخلاً بلا إحداثيات وتعذّر فحصها.',
|
||||
'dawarich.bucket.truncated': 'تم فحص المُدخلات الأولى فقط. شغّل الفحص مجدداً للبقية.',
|
||||
'dawarich.bucket.visitedFrom': 'شُطبت اعتماداً على تسجيلات Dawarich',
|
||||
'dawarich.bucket.clearVisit': 'تراجع',
|
||||
|
||||
// ── Atlas ──────────────────────────────────────────────────────────────────
|
||||
'dawarich.atlas.title': 'الدول من Dawarich',
|
||||
'dawarich.atlas.description':
|
||||
'الدول التي تقول تسجيلاتك إنك كنت فيها. أكّد ما تريد إضافته إلى أطلسك — لا يُضاف شيء من تلقاء نفسه، وما وضعته بيدك يبقى لك.',
|
||||
'dawarich.atlas.load': 'ابحث عن الدول',
|
||||
'dawarich.atlas.loading': 'جارٍ قراءة تسجيلاتك…',
|
||||
'dawarich.atlas.empty': 'لا تُظهر تسجيلاتك أي دول ليست لدى TREK أصلاً.',
|
||||
'dawarich.atlas.cities': '{count} مدينة',
|
||||
'dawarich.atlas.citiesOne': 'مدينة واحدة',
|
||||
'dawarich.atlas.accept': 'إضافة {count} دولة',
|
||||
'dawarich.atlas.accepted': 'تمت إضافة {count} دولة',
|
||||
'dawarich.atlas.unresolved': 'تعذّر على TREK مطابقة هذه بدولة: {names}.',
|
||||
'dawarich.atlas.source': 'من Dawarich',
|
||||
'dawarich.atlas.range': 'تم الاطلاع على الفترة من {from} إلى {to}',
|
||||
|
||||
'dawarich.atlas.trigger': 'الأمنيات والبلدان من تسجيلاتك',
|
||||
'dawarich.atlas.dialogSubtitle': 'ما تقوله تسجيلاتك عن أطلسك',
|
||||
'dawarich.atlas.tab.wishes': 'قائمة الأمنيات',
|
||||
'dawarich.atlas.tab.countries': 'البلدان',
|
||||
'dawarich.atlas.window': 'تم فحص آخر 12 شهرًا.',
|
||||
'dawarich.selected': 'تم تحديد {count}',
|
||||
'dawarich.again': 'تحقق مرة أخرى',
|
||||
'dawarich.bucket.metersAway': 'على بُعد {meters} م',
|
||||
'dawarich.bucket.kilometersAway': 'على بُعد {km} كم',
|
||||
'dawarich.bucket.rule': 'تُحتسب الأمنية محققة ضمن {meters} م وبعد {minutes} دقيقة في المكان.',
|
||||
|
||||
};
|
||||
|
||||
export default dawarich;
|
||||
@@ -1,4 +1,5 @@
|
||||
import admin from './admin';
|
||||
import dawarich from './dawarich';
|
||||
import airport from './airport';
|
||||
import atlas from './atlas';
|
||||
import backup from './backup';
|
||||
@@ -106,5 +107,6 @@ const locale = {
|
||||
...mobileSettings,
|
||||
...mobileCollections,
|
||||
...storage,
|
||||
...dawarich,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const dawarich: TranslationStrings = {
|
||||
// ── Connection ─────────────────────────────────────────────────────────────
|
||||
'dawarich.title': 'Dawarich',
|
||||
'dawarich.intro':
|
||||
'Conecte sua própria instância do Dawarich para ver onde você realmente esteve. O TREK lê os dados e sugere entradas de diário, lugares e países — nada é adicionado antes de você confirmar, e nada é gravado de volta no Dawarich.',
|
||||
'dawarich.url': 'Endereço da instância',
|
||||
'dawarich.apiKey': 'Chave de API',
|
||||
'dawarich.apiKeyPlaceholder': 'Cole sua chave de API do Dawarich',
|
||||
'dawarich.apiKeyHint':
|
||||
'Encontrada no Dawarich em Conta → Chave de API. Guardada criptografada e nunca exibida de novo.',
|
||||
'dawarich.allowInsecureTls': 'Permitir certificado autoassinado',
|
||||
'dawarich.allowInsecureTlsHint':
|
||||
'Só é necessário se sua instância usa um certificado em que seu servidor não confia.',
|
||||
'dawarich.syncEnabled': 'Procurar novas estadias automaticamente',
|
||||
'dawarich.syncEnabledHint': 'Desligado significa que o TREK só lê o Dawarich quando você pedir.',
|
||||
'dawarich.test.button': 'Testar conexão',
|
||||
'dawarich.test.success': 'Conectado. {count} estadias encontradas nos últimos 30 dias.',
|
||||
'dawarich.test.failed': 'Não foi possível acessar o Dawarich.',
|
||||
'dawarich.syncNow': 'Procurar agora',
|
||||
'dawarich.connected': 'Conectado',
|
||||
'dawarich.notConnected': 'Não conectado',
|
||||
'dawarich.disconnect': 'Desconectar',
|
||||
'dawarich.lastSync': 'Última verificação {when}',
|
||||
'dawarich.neverSynced': 'Ainda não verificado',
|
||||
'dawarich.syncPartial': 'algumas viagens não puderam ser lidas',
|
||||
'dawarich.serverVersion': 'Dawarich {version}',
|
||||
|
||||
'dawarich.toast.saved': 'Conexão com o Dawarich salva',
|
||||
'dawarich.toast.saveError': 'Não foi possível salvar a conexão',
|
||||
'dawarich.toast.disconnected': 'Dawarich desconectado',
|
||||
'dawarich.toast.synced': '{count} novas estadias encontradas',
|
||||
'dawarich.toast.syncError': 'Não foi possível ler o Dawarich',
|
||||
'dawarich.toast.syncRunning': 'Já há uma verificação em andamento',
|
||||
'dawarich.toast.acceptError': 'Não foi possível adicionar isso',
|
||||
'dawarich.toast.updateError': 'Não foi possível atualizar esta sugestão',
|
||||
'dawarich.toast.accepted.place': 'Adicionado à viagem',
|
||||
'dawarich.toast.accepted.journal': 'Adicionado ao diário',
|
||||
'dawarich.toast.accepted.bucket_list': 'Riscado da sua lista de desejos',
|
||||
|
||||
// ── What the connected instance can do ─────────────────────────────────────
|
||||
'dawarich.capability.visits': 'estadias',
|
||||
'dawarich.capability.track': 'rota gravada',
|
||||
'dawarich.capability.locations': 'correspondência com a lista de desejos',
|
||||
'dawarich.capability.visitedCities': 'países e cidades',
|
||||
'dawarich.capability.missing': 'Esta versão do Dawarich não oferece: {features}.',
|
||||
|
||||
// ── Failure reasons, as sentences the reader can act on ────────────────────
|
||||
'dawarich.error.unreachable': 'O TREK não conseguiu acessar esse endereço.',
|
||||
'dawarich.error.unauthorized': 'O Dawarich recusou a chave de API.',
|
||||
'dawarich.error.forbidden': 'Essa chave de API não tem permissão para ler isto.',
|
||||
'dawarich.error.not_found': 'Esta versão do Dawarich não tem esse endpoint.',
|
||||
'dawarich.error.rate_limited': 'O Dawarich pediu que o TREK diminuísse o ritmo. Tente de novo em instantes.',
|
||||
'dawarich.error.server_error': 'O Dawarich respondeu com um erro.',
|
||||
'dawarich.error.invalid_response': 'Esse endereço respondeu com algo que não é o Dawarich.',
|
||||
'dawarich.error.too_large': 'O Dawarich enviou mais dados do que o TREK lê de uma vez.',
|
||||
'dawarich.error.not_connected': 'Nenhuma instância do Dawarich está conectada ainda.',
|
||||
'dawarich.error.addon_disabled': 'O complemento Dawarich está desativado nesta instância.',
|
||||
'dawarich.error.offline': 'Isso precisa de conexão — o TREK está offline agora.',
|
||||
'dawarich.error.invalid_url': 'O TREK não pode usar esse endereço.',
|
||||
'dawarich.warning.private_ip': 'Esse endereço aponta para um IP privado ({ip}). Confirme se é isso mesmo — o servidor pode precisar de ALLOW_INTERNAL_NETWORK=true para alcançá-lo.',
|
||||
'dawarich.error.unknown': 'Algo deu errado na conversa com o Dawarich.',
|
||||
|
||||
// ── The recorded route on the map ──────────────────────────────────────────
|
||||
'dawarich.trail.show': 'Mostrar rota gravada',
|
||||
'dawarich.trail.hide': 'Ocultar rota gravada',
|
||||
'dawarich.trail.loading': 'Carregando a rota gravada…',
|
||||
'dawarich.trail.empty': 'Nada foi gravado nessas datas',
|
||||
'dawarich.trail.offline': 'A rota gravada precisa de conexão',
|
||||
'dawarich.trail.unavailable': 'Não foi possível carregar a rota gravada',
|
||||
|
||||
// ── Suggestions ────────────────────────────────────────────────────────────
|
||||
'dawarich.duration.minutes': '{minutes} min',
|
||||
'dawarich.duration.hours': '{hours} h',
|
||||
'dawarich.duration.hoursMinutes': '{hours} h {minutes} min',
|
||||
'dawarich.checkedAgo': 'verificado {ago}',
|
||||
|
||||
'dawarich.badge.lowConfidence': 'Incerto',
|
||||
'dawarich.badge.sourceChanged': 'Alterado no Dawarich',
|
||||
'dawarich.badge.sourceMissing': 'Sumiu do Dawarich',
|
||||
|
||||
'dawarich.suggestions.title': 'Do Dawarich',
|
||||
'dawarich.suggestions.pending': '{count} esperando por você',
|
||||
'dawarich.suggestions.loading': 'Lendo o Dawarich…',
|
||||
'dawarich.suggestions.notConnected': 'Conecte o Dawarich nas Configurações para ver suas estadias aqui.',
|
||||
'dawarich.suggestions.unavailable': 'Não foi possível ler o Dawarich.',
|
||||
'dawarich.suggestions.allHandled': 'Tudo o que foi gravado aqui já foi resolvido.',
|
||||
'dawarich.suggestions.asJournal': 'Escrever uma entrada no diário',
|
||||
'dawarich.suggestions.asPlace': 'Adicionar como lugar',
|
||||
'dawarich.suggestions.dismiss': 'Não é um lugar que visitei',
|
||||
'dawarich.suggestions.dismissed': 'Dispensado',
|
||||
'dawarich.suggestions.restore': 'Colocar de volta',
|
||||
'dawarich.suggestions.showHandled': 'Mostrar {count} já resolvidas',
|
||||
'dawarich.suggestions.hideHandled': 'Ocultar as que já foram resolvidas',
|
||||
'dawarich.suggestions.matchesWish': 'Na sua lista de desejos: {name}',
|
||||
'dawarich.suggestions.acceptedAs.place': 'Adicionado como lugar',
|
||||
'dawarich.suggestions.acceptedAs.journal': 'No diário',
|
||||
'dawarich.suggestions.acceptedAs.bucket_list': 'Desejo riscado',
|
||||
'dawarich.suggestions.sourceChanged':
|
||||
'Esta estadia mudou no Dawarich depois que você a usou. O que você escreveu no TREK continua intacto.',
|
||||
'dawarich.suggestions.sourceMissing':
|
||||
'Esta estadia não existe mais no Dawarich. O que você escreveu no TREK continua intacto.',
|
||||
'dawarich.sourceStatus.suggested': 'Detectada, não confirmada',
|
||||
'dawarich.confidence.high': 'Detecção confiável',
|
||||
'dawarich.confidence.medium': 'Detecção razoavelmente confiável',
|
||||
'dawarich.confidence.low': 'Detecção incerta',
|
||||
|
||||
// ── The review step ────────────────────────────────────────────────────────
|
||||
'dawarich.accept.title.place': 'Adicionar esta estadia como lugar',
|
||||
'dawarich.accept.title.journal': 'Escrever uma entrada no diário',
|
||||
'dawarich.accept.title.bucket_list': 'Riscar um desejo',
|
||||
'dawarich.accept.confirm.place': 'Adicionar lugar',
|
||||
'dawarich.accept.confirm.journal': 'Adicionar entrada',
|
||||
'dawarich.accept.confirm.bucket_list': 'Riscar',
|
||||
'dawarich.accept.recorded': 'Gravado de {from} até {to}',
|
||||
'dawarich.accept.duration': '{minutes} min',
|
||||
'dawarich.accept.name': 'Nome',
|
||||
'dawarich.accept.date': 'Data',
|
||||
'dawarich.accept.from': 'Chegada',
|
||||
'dawarich.accept.to': 'Saída',
|
||||
'dawarich.accept.trip': 'Viagem',
|
||||
'dawarich.accept.thisTrip': 'Esta viagem',
|
||||
'dawarich.accept.pickTrip': 'Escolha uma viagem',
|
||||
'dawarich.accept.day': 'Dia',
|
||||
'dawarich.accept.noDay': 'Ainda sem dia definido',
|
||||
'dawarich.accept.journal': 'Diário',
|
||||
'dawarich.accept.pickJournal': 'Escolha um diário',
|
||||
'dawarich.accept.notes': 'Notas',
|
||||
'dawarich.accept.story': 'Sua história',
|
||||
'dawarich.accept.storyPlaceholder': 'O que aconteceu aqui?',
|
||||
'dawarich.accept.photosHint': 'Adicione fotos à entrada depois que ela for criada.',
|
||||
|
||||
// ── A place that came out of a recording ──────────────────────────────────
|
||||
'dawarich.place.fromDawarich': 'Adicionado a partir dos seus registros do Dawarich',
|
||||
|
||||
// ── Wishlist ───────────────────────────────────────────────────────────────
|
||||
'dawarich.bucket.title': 'Conferir sua lista de desejos com o Dawarich',
|
||||
'dawarich.bucket.description':
|
||||
'Procura nas suas gravações os lugares que você queria conhecer. Uma visita precisa de proximidade e de tempo parado — passar de carro não conta.',
|
||||
'dawarich.bucket.scan': 'Conferir lista de desejos',
|
||||
'dawarich.bucket.scanning': 'Conferindo…',
|
||||
'dawarich.bucket.noMatches': 'Nada da sua lista de desejos apareceu nas suas gravações.',
|
||||
'dawarich.bucket.alreadyVisited': 'Já riscado',
|
||||
'dawarich.bucket.confirm': 'Riscar {count}',
|
||||
'dawarich.bucket.confirmed': '{count} desejos riscados',
|
||||
'dawarich.bucket.skipped': '{count} entradas não têm coordenadas e não puderam ser conferidas.',
|
||||
'dawarich.bucket.truncated': 'Só as primeiras entradas foram conferidas. Rode de novo para as demais.',
|
||||
'dawarich.bucket.visitedFrom': 'Riscado a partir das suas gravações do Dawarich',
|
||||
'dawarich.bucket.clearVisit': 'Desfazer',
|
||||
|
||||
// ── Atlas ──────────────────────────────────────────────────────────────────
|
||||
'dawarich.atlas.title': 'Países do Dawarich',
|
||||
'dawarich.atlas.description':
|
||||
'Países em que suas gravações dizem que você esteve. Confirme os que quiser no seu Atlas — nada é adicionado por conta própria, e o que você marcou à mão continua seu.',
|
||||
'dawarich.atlas.load': 'Procurar países',
|
||||
'dawarich.atlas.loading': 'Lendo suas gravações…',
|
||||
'dawarich.atlas.empty': 'Suas gravações não mostram países que o TREK ainda não tenha.',
|
||||
'dawarich.atlas.cities': '{count} cidades',
|
||||
'dawarich.atlas.citiesOne': '1 cidade',
|
||||
'dawarich.atlas.accept': 'Adicionar {count} países',
|
||||
'dawarich.atlas.accepted': '{count} países adicionados',
|
||||
'dawarich.atlas.unresolved': 'O TREK não conseguiu associar estes a um país: {names}.',
|
||||
'dawarich.atlas.source': 'Do Dawarich',
|
||||
'dawarich.atlas.range': 'Analisado de {from} a {to}',
|
||||
|
||||
'dawarich.atlas.trigger': 'Desejos e países dos seus registros',
|
||||
'dawarich.atlas.dialogSubtitle': 'O que seus registros dizem sobre o seu Atlas',
|
||||
'dawarich.atlas.tab.wishes': 'Lista de desejos',
|
||||
'dawarich.atlas.tab.countries': 'Países',
|
||||
'dawarich.atlas.window': 'Foram analisados os últimos 12 meses.',
|
||||
'dawarich.selected': '{count} selecionados',
|
||||
'dawarich.again': 'Verificar de novo',
|
||||
'dawarich.bucket.metersAway': 'a {meters} m',
|
||||
'dawarich.bucket.kilometersAway': 'a {km} km',
|
||||
'dawarich.bucket.rule': 'Um desejo conta como alcançado a até {meters} m e após {minutes} minutos no local.',
|
||||
|
||||
};
|
||||
|
||||
export default dawarich;
|
||||
@@ -1,4 +1,5 @@
|
||||
import admin from './admin';
|
||||
import dawarich from './dawarich';
|
||||
import airport from './airport';
|
||||
import atlas from './atlas';
|
||||
import backup from './backup';
|
||||
@@ -106,5 +107,6 @@ const locale = {
|
||||
...mobileSettings,
|
||||
...mobileCollections,
|
||||
...storage,
|
||||
...dawarich,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const dawarich: TranslationStrings = {
|
||||
// ── Connection ─────────────────────────────────────────────────────────────
|
||||
'dawarich.title': 'Dawarich',
|
||||
'dawarich.intro':
|
||||
'Connecta la teva pròpia instància de Dawarich per veure on has estat realment. TREK la llegeix i et proposa entrades de diari, llocs i països — no s’hi afegeix res fins que ho confirmes, i no s’escriu res de tornada a Dawarich.',
|
||||
'dawarich.url': 'Adreça de la instància',
|
||||
'dawarich.apiKey': 'Clau API',
|
||||
'dawarich.apiKeyPlaceholder': 'Enganxa la teva clau API de Dawarich',
|
||||
'dawarich.apiKeyHint': 'La trobaràs a Dawarich sota Compte → Clau API. Es desa xifrada i no es torna a mostrar mai.',
|
||||
'dawarich.allowInsecureTls': 'Permet un certificat autosignat',
|
||||
'dawarich.allowInsecureTlsHint':
|
||||
'Només cal si la teva instància fa servir un certificat en què el teu servidor no confia.',
|
||||
'dawarich.syncEnabled': 'Comprova automàticament si hi ha estades noves',
|
||||
'dawarich.syncEnabledHint': 'Desactivat vol dir que TREK només llegeix Dawarich quan tu l’hi demanes.',
|
||||
'dawarich.test.button': 'Prova la connexió',
|
||||
'dawarich.test.success': 'Connectat. S’han trobat {count} estades en els darrers 30 dies.',
|
||||
'dawarich.test.failed': 'No s’ha pogut contactar amb Dawarich.',
|
||||
'dawarich.syncNow': 'Comprova-ho ara',
|
||||
'dawarich.connected': 'Connectat',
|
||||
'dawarich.notConnected': 'No connectat',
|
||||
'dawarich.disconnect': 'Desconnecta',
|
||||
'dawarich.lastSync': 'Darrera comprovació {when}',
|
||||
'dawarich.neverSynced': 'Encara no s’ha comprovat',
|
||||
'dawarich.syncPartial': 'alguns viatges no s’han pogut llegir',
|
||||
'dawarich.serverVersion': 'Dawarich {version}',
|
||||
|
||||
'dawarich.toast.saved': 'S’ha desat la connexió amb Dawarich',
|
||||
'dawarich.toast.saveError': 'No s’ha pogut desar la connexió',
|
||||
'dawarich.toast.disconnected': 'Dawarich desconnectat',
|
||||
'dawarich.toast.synced': 'S’han trobat {count} estades noves',
|
||||
'dawarich.toast.syncError': 'No s’ha pogut llegir Dawarich',
|
||||
'dawarich.toast.syncRunning': 'Ja hi ha una comprovació en curs',
|
||||
'dawarich.toast.acceptError': 'No s’ha pogut afegir això',
|
||||
'dawarich.toast.updateError': 'No s’ha pogut actualitzar aquest suggeriment',
|
||||
'dawarich.toast.accepted.place': 'Afegit al viatge',
|
||||
'dawarich.toast.accepted.journal': 'Afegit al diari',
|
||||
'dawarich.toast.accepted.bucket_list': 'Marcat a la teva llista de desitjos',
|
||||
|
||||
// ── What the connected instance can do ─────────────────────────────────────
|
||||
'dawarich.capability.visits': 'estades',
|
||||
'dawarich.capability.track': 'recorregut enregistrat',
|
||||
'dawarich.capability.locations': 'coincidències amb la llista de desitjos',
|
||||
'dawarich.capability.visitedCities': 'països i ciutats',
|
||||
'dawarich.capability.missing': 'Aquesta versió de Dawarich no ofereix: {features}.',
|
||||
|
||||
// ── Failure reasons, as sentences the reader can act on ────────────────────
|
||||
'dawarich.error.unreachable': 'TREK no ha pogut contactar amb aquesta adreça.',
|
||||
'dawarich.error.unauthorized': 'Dawarich ha rebutjat la clau API.',
|
||||
'dawarich.error.forbidden': 'Aquesta clau API no té permís per llegir això.',
|
||||
'dawarich.error.not_found': 'Aquesta versió de Dawarich no té aquest punt final.',
|
||||
'dawarich.error.rate_limited': 'Dawarich ha demanat a TREK que vagi més a poc a poc. Torna-ho a provar d’aquí a poc.',
|
||||
'dawarich.error.server_error': 'Dawarich ha respost amb un error.',
|
||||
'dawarich.error.invalid_response': 'Aquesta adreça ha respost amb una cosa que no és Dawarich.',
|
||||
'dawarich.error.too_large': 'Dawarich ha enviat més dades de les que TREK llegeix de cop.',
|
||||
'dawarich.error.not_connected': 'Encara no hi ha cap instància de Dawarich connectada.',
|
||||
'dawarich.error.addon_disabled': 'El complement Dawarich està desactivat en aquesta instància.',
|
||||
'dawarich.error.offline': 'Això necessita connexió: TREK ara és fora de línia.',
|
||||
'dawarich.error.invalid_url': 'TREK no pot fer servir aquesta adreça.',
|
||||
'dawarich.warning.private_ip': 'Aquesta adreça apunta a una IP privada ({ip}). Comprova que és el que volies: el servidor pot necessitar ALLOW_INTERNAL_NETWORK=true per arribar-hi.',
|
||||
'dawarich.error.unknown': 'Alguna cosa ha anat malament en parlar amb Dawarich.',
|
||||
|
||||
// ── The recorded route on the map ──────────────────────────────────────────
|
||||
'dawarich.trail.show': 'Mostra el recorregut enregistrat',
|
||||
'dawarich.trail.hide': 'Amaga el recorregut enregistrat',
|
||||
'dawarich.trail.loading': 'S’està carregant el recorregut enregistrat…',
|
||||
'dawarich.trail.empty': 'No s’ha enregistrat res en aquestes dates',
|
||||
'dawarich.trail.offline': 'El recorregut enregistrat necessita connexió',
|
||||
'dawarich.trail.unavailable': 'No s’ha pogut carregar el recorregut enregistrat',
|
||||
|
||||
// ── Suggestions ────────────────────────────────────────────────────────────
|
||||
'dawarich.duration.minutes': '{minutes} min',
|
||||
'dawarich.duration.hours': '{hours} h',
|
||||
'dawarich.duration.hoursMinutes': '{hours} h {minutes} min',
|
||||
'dawarich.checkedAgo': 'comprovat {ago}',
|
||||
|
||||
'dawarich.badge.lowConfidence': 'Incert',
|
||||
'dawarich.badge.sourceChanged': 'Canviat a Dawarich',
|
||||
'dawarich.badge.sourceMissing': 'Ja no hi és a Dawarich',
|
||||
|
||||
'dawarich.suggestions.title': 'Des de Dawarich',
|
||||
'dawarich.suggestions.pending': '{count} pendents',
|
||||
'dawarich.suggestions.loading': 'S’està llegint Dawarich…',
|
||||
'dawarich.suggestions.notConnected': 'Connecta Dawarich a la configuració per veure aquí les teves estades.',
|
||||
'dawarich.suggestions.unavailable': 'No s’ha pogut llegir Dawarich.',
|
||||
'dawarich.suggestions.allHandled': 'Ja has resolt tot el que s’ha enregistrat aquí.',
|
||||
'dawarich.suggestions.asJournal': 'Escriu una entrada de diari',
|
||||
'dawarich.suggestions.asPlace': 'Afegeix-ho com a lloc',
|
||||
'dawarich.suggestions.dismiss': 'No és un lloc que hagi visitat',
|
||||
'dawarich.suggestions.dismissed': 'Descartat',
|
||||
'dawarich.suggestions.restore': 'Torna-ho a posar',
|
||||
'dawarich.suggestions.showHandled': 'Mostra’n {count} de ja resolts',
|
||||
'dawarich.suggestions.hideHandled': 'Amaga els que ja estan resolts',
|
||||
'dawarich.suggestions.matchesWish': 'A la teva llista de desitjos: {name}',
|
||||
'dawarich.suggestions.acceptedAs.place': 'Afegit com a lloc',
|
||||
'dawarich.suggestions.acceptedAs.journal': 'Al diari',
|
||||
'dawarich.suggestions.acceptedAs.bucket_list': 'Desig marcat',
|
||||
'dawarich.suggestions.sourceChanged':
|
||||
'Aquesta estada ha canviat a Dawarich des que la vas fer servir. El que has escrit a TREK no s’ha tocat.',
|
||||
'dawarich.suggestions.sourceMissing':
|
||||
'Aquesta estada ja no existeix a Dawarich. El que has escrit a TREK no s’ha tocat.',
|
||||
'dawarich.sourceStatus.suggested': 'Detectat, sense confirmar',
|
||||
'dawarich.confidence.high': 'Detecció segura',
|
||||
'dawarich.confidence.medium': 'Detecció força segura',
|
||||
'dawarich.confidence.low': 'Detecció incerta',
|
||||
|
||||
// ── The review step ────────────────────────────────────────────────────────
|
||||
'dawarich.accept.title.place': 'Afegeix aquesta estada com a lloc',
|
||||
'dawarich.accept.title.journal': 'Escriu una entrada de diari',
|
||||
'dawarich.accept.title.bucket_list': 'Marca un desig',
|
||||
'dawarich.accept.confirm.place': 'Afegeix el lloc',
|
||||
'dawarich.accept.confirm.journal': 'Afegeix l’entrada',
|
||||
'dawarich.accept.confirm.bucket_list': 'Marca’l',
|
||||
'dawarich.accept.recorded': 'Enregistrat des de {from} fins a {to}',
|
||||
'dawarich.accept.duration': '{minutes} min',
|
||||
'dawarich.accept.name': 'Nom',
|
||||
'dawarich.accept.date': 'Data',
|
||||
'dawarich.accept.from': 'Arribada',
|
||||
'dawarich.accept.to': 'Sortida',
|
||||
'dawarich.accept.trip': 'Viatge',
|
||||
'dawarich.accept.thisTrip': 'Aquest viatge',
|
||||
'dawarich.accept.pickTrip': 'Tria un viatge',
|
||||
'dawarich.accept.day': 'Dia',
|
||||
'dawarich.accept.noDay': 'Encara no és en cap dia',
|
||||
'dawarich.accept.journal': 'Diari',
|
||||
'dawarich.accept.pickJournal': 'Tria un diari',
|
||||
'dawarich.accept.notes': 'Notes',
|
||||
'dawarich.accept.story': 'La teva història',
|
||||
'dawarich.accept.storyPlaceholder': 'Què va passar aquí?',
|
||||
'dawarich.accept.photosHint': 'Afegeix fotos a l’entrada un cop creada.',
|
||||
|
||||
// ── A place that came out of a recording ──────────────────────────────────
|
||||
'dawarich.place.fromDawarich': 'Afegit des dels teus registres de Dawarich',
|
||||
|
||||
// ── Wishlist ───────────────────────────────────────────────────────────────
|
||||
'dawarich.bucket.title': 'Comprova la teva llista de desitjos amb Dawarich',
|
||||
'dawarich.bucket.description':
|
||||
'Busca als teus enregistraments els llocs on volies arribar. Una visita necessita tant proximitat com temps d’estada — passar-hi de llarg no compta.',
|
||||
'dawarich.bucket.scan': 'Comprova la llista de desitjos',
|
||||
'dawarich.bucket.scanning': 'S’està comprovant…',
|
||||
'dawarich.bucket.noMatches': 'No ha aparegut res de la teva llista de desitjos als teus enregistraments.',
|
||||
'dawarich.bucket.alreadyVisited': 'Ja marcat',
|
||||
'dawarich.bucket.confirm': 'Marca’n {count}',
|
||||
'dawarich.bucket.confirmed': '{count} desitjos marcats',
|
||||
'dawarich.bucket.skipped': '{count} entrades no tenen coordenades i no s’han pogut comprovar.',
|
||||
'dawarich.bucket.truncated': 'Només s’han comprovat les primeres entrades. Torna-ho a executar per a la resta.',
|
||||
'dawarich.bucket.visitedFrom': 'Marcat a partir dels teus enregistraments de Dawarich',
|
||||
'dawarich.bucket.clearVisit': 'Desfés',
|
||||
|
||||
// ── Atlas ──────────────────────────────────────────────────────────────────
|
||||
'dawarich.atlas.title': 'Països des de Dawarich',
|
||||
'dawarich.atlas.description':
|
||||
'Països on els teus enregistraments diuen que has estat. Confirma els que vulguis al teu Atles — no s’hi afegeix res tot sol, i el que has marcat a mà continua sent teu.',
|
||||
'dawarich.atlas.load': 'Busca països',
|
||||
'dawarich.atlas.loading': 'S’estan llegint els teus enregistraments…',
|
||||
'dawarich.atlas.empty': 'Els teus enregistraments no mostren cap país que TREK no tingui ja.',
|
||||
'dawarich.atlas.cities': '{count} ciutats',
|
||||
'dawarich.atlas.citiesOne': '1 ciutat',
|
||||
'dawarich.atlas.accept': 'Afegeix {count} països',
|
||||
'dawarich.atlas.accepted': '{count} països afegits',
|
||||
'dawarich.atlas.unresolved': 'TREK no ha pogut associar això a cap país: {names}.',
|
||||
'dawarich.atlas.source': 'Des de Dawarich',
|
||||
'dawarich.atlas.range': 'S’ha mirat de {from} a {to}',
|
||||
|
||||
'dawarich.atlas.trigger': 'Desitjos i països dels teus registres',
|
||||
'dawarich.atlas.dialogSubtitle': 'Què diuen els teus registres sobre el teu Atles',
|
||||
'dawarich.atlas.tab.wishes': 'Llista de desitjos',
|
||||
'dawarich.atlas.tab.countries': 'Països',
|
||||
'dawarich.atlas.window': 'S’han revisat els darrers 12 mesos.',
|
||||
'dawarich.selected': '{count} seleccionats',
|
||||
'dawarich.again': 'Tornar a comprovar',
|
||||
'dawarich.bucket.metersAway': 'a {meters} m',
|
||||
'dawarich.bucket.kilometersAway': 'a {km} km',
|
||||
'dawarich.bucket.rule': 'Un desig es considera assolit a menys de {meters} m i després de {minutes} minuts al lloc.',
|
||||
|
||||
};
|
||||
|
||||
export default dawarich;
|
||||
@@ -1,4 +1,5 @@
|
||||
import admin from './admin';
|
||||
import dawarich from './dawarich';
|
||||
import airport from './airport';
|
||||
import atlas from './atlas';
|
||||
import backup from './backup';
|
||||
@@ -106,5 +107,6 @@ const locale = {
|
||||
...mobileSettings,
|
||||
...mobileCollections,
|
||||
...storage,
|
||||
...dawarich,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const dawarich: TranslationStrings = {
|
||||
// ── Connection ─────────────────────────────────────────────────────────────
|
||||
'dawarich.title': 'Dawarich',
|
||||
'dawarich.intro':
|
||||
'Připojte svou vlastní instanci Dawarich a uvidíte, kde jste skutečně byli. TREK ji přečte a navrhne záznamy do deníku, místa a země — nic se nepřidá, dokud to nepotvrdíte, a do Dawarichu se nic nezapisuje.',
|
||||
'dawarich.url': 'Adresa instance',
|
||||
'dawarich.apiKey': 'API klíč',
|
||||
'dawarich.apiKeyPlaceholder': 'Vložte svůj API klíč Dawarich',
|
||||
'dawarich.apiKeyHint': 'Najdete jej v Dawarichu pod Účet → API klíč. Ukládá se zašifrovaně a znovu se nezobrazí.',
|
||||
'dawarich.allowInsecureTls': 'Povolit vlastnoručně podepsaný certifikát',
|
||||
'dawarich.allowInsecureTlsHint':
|
||||
'Potřebné jen tehdy, když vaše instance používá certifikát, kterému váš server nedůvěřuje.',
|
||||
'dawarich.syncEnabled': 'Automaticky hledat nové pobyty',
|
||||
'dawarich.syncEnabledHint': 'Když je vypnuto, TREK čte Dawarich jen na vyžádání.',
|
||||
'dawarich.test.button': 'Otestovat připojení',
|
||||
'dawarich.test.success': 'Připojeno. Za posledních 30 dní nalezeno {count} pobytů.',
|
||||
'dawarich.test.failed': 'Dawarich se nepodařilo kontaktovat.',
|
||||
'dawarich.syncNow': 'Zkontrolovat teď',
|
||||
'dawarich.connected': 'Připojeno',
|
||||
'dawarich.notConnected': 'Nepřipojeno',
|
||||
'dawarich.disconnect': 'Odpojit',
|
||||
'dawarich.lastSync': 'Naposledy zkontrolováno {when}',
|
||||
'dawarich.neverSynced': 'Zatím nezkontrolováno',
|
||||
'dawarich.syncPartial': 'některé cesty se nepodařilo načíst',
|
||||
'dawarich.serverVersion': 'Dawarich {version}',
|
||||
|
||||
'dawarich.toast.saved': 'Připojení k Dawarichu uloženo',
|
||||
'dawarich.toast.saveError': 'Připojení se nepodařilo uložit',
|
||||
'dawarich.toast.disconnected': 'Dawarich odpojen',
|
||||
'dawarich.toast.synced': 'Nalezeno {count} nových pobytů',
|
||||
'dawarich.toast.syncError': 'Dawarich se nepodařilo načíst',
|
||||
'dawarich.toast.syncRunning': 'Kontrola už probíhá',
|
||||
'dawarich.toast.acceptError': 'Toto se nepodařilo přidat',
|
||||
'dawarich.toast.updateError': 'Tento návrh se nepodařilo aktualizovat',
|
||||
'dawarich.toast.accepted.place': 'Přidáno do cesty',
|
||||
'dawarich.toast.accepted.journal': 'Přidáno do deníku',
|
||||
'dawarich.toast.accepted.bucket_list': 'Odškrtnuto ze seznamu přání',
|
||||
|
||||
// ── What the connected instance can do ─────────────────────────────────────
|
||||
'dawarich.capability.visits': 'pobyty',
|
||||
'dawarich.capability.track': 'zaznamenaná trasa',
|
||||
'dawarich.capability.locations': 'porovnání se seznamem přání',
|
||||
'dawarich.capability.visitedCities': 'země a města',
|
||||
'dawarich.capability.missing': 'Tato verze Dawarichu nenabízí: {features}.',
|
||||
|
||||
// ── Failure reasons, as sentences the reader can act on ────────────────────
|
||||
'dawarich.error.unreachable': 'TREK se na tuto adresu nedostal.',
|
||||
'dawarich.error.unauthorized': 'Dawarich odmítl API klíč.',
|
||||
'dawarich.error.forbidden': 'Tento API klíč nemá oprávnění toto číst.',
|
||||
'dawarich.error.not_found': 'Tato verze Dawarichu tento koncový bod nemá.',
|
||||
'dawarich.error.rate_limited': 'Dawarich požádal TREK o zpomalení. Zkuste to prosím za chvíli znovu.',
|
||||
'dawarich.error.server_error': 'Dawarich odpověděl chybou.',
|
||||
'dawarich.error.invalid_response': 'Tato adresa odpověděla něčím, co není Dawarich.',
|
||||
'dawarich.error.too_large': 'Dawarich poslal víc dat, než kolik TREK přečte najednou.',
|
||||
'dawarich.error.not_connected': 'Zatím není připojena žádná instance Dawarich.',
|
||||
'dawarich.error.addon_disabled': 'Doplněk Dawarich je v této instanci vypnutý.',
|
||||
'dawarich.error.offline': 'To vyžaduje připojení — TREK je právě offline.',
|
||||
'dawarich.error.invalid_url': 'TREK tuto adresu použít nemůže.',
|
||||
'dawarich.warning.private_ip': 'Tato adresa míří na privátní IP ({ip}). Ověřte, že to tak má být — server k ní možná potřebuje ALLOW_INTERNAL_NETWORK=true.',
|
||||
'dawarich.error.unknown': 'Při komunikaci s Dawarichem se něco pokazilo.',
|
||||
|
||||
// ── The recorded route on the map ──────────────────────────────────────────
|
||||
'dawarich.trail.show': 'Zobrazit zaznamenanou trasu',
|
||||
'dawarich.trail.hide': 'Skrýt zaznamenanou trasu',
|
||||
'dawarich.trail.loading': 'Načítá se zaznamenaná trasa…',
|
||||
'dawarich.trail.empty': 'V těchto dnech nebylo nic zaznamenáno',
|
||||
'dawarich.trail.offline': 'Zaznamenaná trasa vyžaduje připojení',
|
||||
'dawarich.trail.unavailable': 'Zaznamenanou trasu se nepodařilo načíst',
|
||||
|
||||
// ── Suggestions ────────────────────────────────────────────────────────────
|
||||
'dawarich.duration.minutes': '{minutes} min',
|
||||
'dawarich.duration.hours': '{hours} h',
|
||||
'dawarich.duration.hoursMinutes': '{hours} h {minutes} min',
|
||||
'dawarich.checkedAgo': 'zkontrolováno {ago}',
|
||||
|
||||
'dawarich.badge.lowConfidence': 'Nejisté',
|
||||
'dawarich.badge.sourceChanged': 'Změněno v Dawarichu',
|
||||
'dawarich.badge.sourceMissing': 'V Dawarichu už není',
|
||||
|
||||
'dawarich.suggestions.title': 'Z Dawarichu',
|
||||
'dawarich.suggestions.pending': '{count} čeká na vás',
|
||||
'dawarich.suggestions.loading': 'Načítá se Dawarich…',
|
||||
'dawarich.suggestions.notConnected': 'Připojte Dawarich v Nastavení a uvidíte tu své pobyty.',
|
||||
'dawarich.suggestions.unavailable': 'Dawarich se nepodařilo načíst.',
|
||||
'dawarich.suggestions.allHandled': 'Všechno, co je tu zaznamenáno, už je vyřízené.',
|
||||
'dawarich.suggestions.asJournal': 'Napsat záznam do deníku',
|
||||
'dawarich.suggestions.asPlace': 'Přidat jako místo',
|
||||
'dawarich.suggestions.dismiss': 'Tady jsem nebyl',
|
||||
'dawarich.suggestions.dismissed': 'Zamítnuto',
|
||||
'dawarich.suggestions.restore': 'Vrátit zpět',
|
||||
'dawarich.suggestions.showHandled': 'Zobrazit {count} již vyřízených',
|
||||
'dawarich.suggestions.hideHandled': 'Skrýt už vyřízené',
|
||||
'dawarich.suggestions.matchesWish': 'Na vašem seznamu přání: {name}',
|
||||
'dawarich.suggestions.acceptedAs.place': 'Přidáno jako místo',
|
||||
'dawarich.suggestions.acceptedAs.journal': 'V deníku',
|
||||
'dawarich.suggestions.acceptedAs.bucket_list': 'Přání odškrtnuto',
|
||||
'dawarich.suggestions.sourceChanged':
|
||||
'Tento pobyt se od vašeho použití v Dawarichu změnil. To, co jste napsali v TREKu, zůstává beze změny.',
|
||||
'dawarich.suggestions.sourceMissing':
|
||||
'Tento pobyt už v Dawarichu neexistuje. To, co jste napsali v TREKu, zůstává beze změny.',
|
||||
'dawarich.sourceStatus.suggested': 'Rozpoznáno, nepotvrzeno',
|
||||
'dawarich.confidence.high': 'Jisté rozpoznání',
|
||||
'dawarich.confidence.medium': 'Poměrně jisté rozpoznání',
|
||||
'dawarich.confidence.low': 'Nejisté rozpoznání',
|
||||
|
||||
// ── The review step ────────────────────────────────────────────────────────
|
||||
'dawarich.accept.title.place': 'Přidat tento pobyt jako místo',
|
||||
'dawarich.accept.title.journal': 'Napsat záznam do deníku',
|
||||
'dawarich.accept.title.bucket_list': 'Odškrtnout přání',
|
||||
'dawarich.accept.confirm.place': 'Přidat místo',
|
||||
'dawarich.accept.confirm.journal': 'Přidat záznam',
|
||||
'dawarich.accept.confirm.bucket_list': 'Odškrtnout',
|
||||
'dawarich.accept.recorded': 'Zaznamenáno od {from} do {to}',
|
||||
'dawarich.accept.duration': '{minutes} min',
|
||||
'dawarich.accept.name': 'Název',
|
||||
'dawarich.accept.date': 'Datum',
|
||||
'dawarich.accept.from': 'Příchod',
|
||||
'dawarich.accept.to': 'Odchod',
|
||||
'dawarich.accept.trip': 'Cesta',
|
||||
'dawarich.accept.thisTrip': 'Tato cesta',
|
||||
'dawarich.accept.pickTrip': 'Vyberte cestu',
|
||||
'dawarich.accept.day': 'Den',
|
||||
'dawarich.accept.noDay': 'Zatím bez dne',
|
||||
'dawarich.accept.journal': 'Deník',
|
||||
'dawarich.accept.pickJournal': 'Vyberte deník',
|
||||
'dawarich.accept.notes': 'Poznámky',
|
||||
'dawarich.accept.story': 'Váš příběh',
|
||||
'dawarich.accept.storyPlaceholder': 'Co se tu stalo?',
|
||||
'dawarich.accept.photosHint': 'Fotky přidáte k záznamu až po jeho vytvoření.',
|
||||
|
||||
// ── A place that came out of a recording ──────────────────────────────────
|
||||
'dawarich.place.fromDawarich': 'Přidáno z vašich záznamů Dawarich',
|
||||
|
||||
// ── Wishlist ───────────────────────────────────────────────────────────────
|
||||
'dawarich.bucket.title': 'Porovnat seznam přání s Dawarichem',
|
||||
'dawarich.bucket.description':
|
||||
'Prohledá vaše záznamy a hledá místa, kam jste se chtěli dostat. Návštěva vyžaduje blízkost i strávený čas — projet kolem se nepočítá.',
|
||||
'dawarich.bucket.scan': 'Zkontrolovat seznam přání',
|
||||
'dawarich.bucket.scanning': 'Kontroluje se…',
|
||||
'dawarich.bucket.noMatches': 'Ve vašich záznamech se neobjevilo nic z vašeho seznamu přání.',
|
||||
'dawarich.bucket.alreadyVisited': 'Již odškrtnuto',
|
||||
'dawarich.bucket.confirm': 'Odškrtnout {count}',
|
||||
'dawarich.bucket.confirmed': 'Odškrtnuto přání: {count}',
|
||||
'dawarich.bucket.skipped': '{count} položek nemá souřadnice a nešlo je zkontrolovat.',
|
||||
'dawarich.bucket.truncated': 'Zkontrolovány byly jen první položky. Zbytek zkontrolujete dalším spuštěním.',
|
||||
'dawarich.bucket.visitedFrom': 'Odškrtnuto podle vašich záznamů v Dawarichu',
|
||||
'dawarich.bucket.clearVisit': 'Vrátit zpět',
|
||||
|
||||
// ── Atlas ──────────────────────────────────────────────────────────────────
|
||||
'dawarich.atlas.title': 'Země z Dawarichu',
|
||||
'dawarich.atlas.description':
|
||||
'Země, ve kterých jste podle svých záznamů byli. Potvrďte ty, které chcete mít v Atlasu — nic se nepřidá samo a co jste označili ručně, zůstává vaše.',
|
||||
'dawarich.atlas.load': 'Vyhledat země',
|
||||
'dawarich.atlas.loading': 'Načítají se vaše záznamy…',
|
||||
'dawarich.atlas.empty': 'Vaše záznamy neukazují žádné země, které by TREK ještě neměl.',
|
||||
'dawarich.atlas.cities': 'Měst: {count}',
|
||||
'dawarich.atlas.citiesOne': '1 město',
|
||||
'dawarich.atlas.accept': 'Přidat země: {count}',
|
||||
'dawarich.atlas.accepted': 'Přidáno zemí: {count}',
|
||||
'dawarich.atlas.unresolved': 'TREK nedokázal tyto přiřadit k zemi: {names}.',
|
||||
'dawarich.atlas.source': 'Z Dawarichu',
|
||||
'dawarich.atlas.range': 'Prohlédnuto od {from} do {to}',
|
||||
|
||||
'dawarich.atlas.trigger': 'Přání a země z vašich záznamů',
|
||||
'dawarich.atlas.dialogSubtitle': 'Co vaše záznamy říkají o vašem Atlasu',
|
||||
'dawarich.atlas.tab.wishes': 'Seznam přání',
|
||||
'dawarich.atlas.tab.countries': 'Země',
|
||||
'dawarich.atlas.window': 'Prohledáno bylo posledních 12 měsíců.',
|
||||
'dawarich.selected': 'Vybráno: {count}',
|
||||
'dawarich.again': 'Zkontrolovat znovu',
|
||||
'dawarich.bucket.metersAway': '{meters} m daleko',
|
||||
'dawarich.bucket.kilometersAway': '{km} km daleko',
|
||||
'dawarich.bucket.rule': 'Přání se počítá jako splněné do {meters} m a po {minutes} minutách na místě.',
|
||||
|
||||
};
|
||||
|
||||
export default dawarich;
|
||||
@@ -1,4 +1,5 @@
|
||||
import admin from './admin';
|
||||
import dawarich from './dawarich';
|
||||
import airport from './airport';
|
||||
import atlas from './atlas';
|
||||
import backup from './backup';
|
||||
@@ -106,5 +107,6 @@ const locale = {
|
||||
...mobileSettings,
|
||||
...mobileCollections,
|
||||
...storage,
|
||||
...dawarich,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const dawarich: TranslationStrings = {
|
||||
// ── Connection ─────────────────────────────────────────────────────────────
|
||||
'dawarich.title': 'Dawarich',
|
||||
'dawarich.intro':
|
||||
'Verbinde deine eigene Dawarich-Instanz, um zu sehen, wo du tatsächlich warst. TREK liest sie aus und schlägt Tagebucheinträge, Orte und Länder vor — nichts wird übernommen, bevor du es bestätigst, und nach Dawarich wird nichts zurückgeschrieben.',
|
||||
'dawarich.url': 'Adresse der Instanz',
|
||||
'dawarich.apiKey': 'API-Schlüssel',
|
||||
'dawarich.apiKeyPlaceholder': 'Deinen Dawarich-API-Schlüssel einfügen',
|
||||
'dawarich.apiKeyHint':
|
||||
'Zu finden in Dawarich unter Account → API key. Wird verschlüsselt gespeichert und nie wieder angezeigt.',
|
||||
'dawarich.allowInsecureTls': 'Selbstsigniertes Zertifikat zulassen',
|
||||
'dawarich.allowInsecureTlsHint':
|
||||
'Nur nötig, wenn deine Instanz ein Zertifikat nutzt, dem dein Server nicht vertraut.',
|
||||
'dawarich.syncEnabled': 'Automatisch nach neuen Aufenthalten suchen',
|
||||
'dawarich.syncEnabledHint': 'Aus bedeutet: TREK liest Dawarich nur, wenn du es anstößt.',
|
||||
'dawarich.test.button': 'Verbindung testen',
|
||||
'dawarich.test.success': 'Verbunden. {count} Aufenthalte in den letzten 30 Tagen gefunden.',
|
||||
'dawarich.test.failed': 'Dawarich war nicht erreichbar.',
|
||||
'dawarich.syncNow': 'Jetzt nachsehen',
|
||||
'dawarich.connected': 'Verbunden',
|
||||
'dawarich.notConnected': 'Nicht verbunden',
|
||||
'dawarich.disconnect': 'Trennen',
|
||||
'dawarich.lastSync': 'Zuletzt nachgesehen {when}',
|
||||
'dawarich.neverSynced': 'Noch nicht nachgesehen',
|
||||
'dawarich.syncPartial': 'einige Reisen konnten nicht gelesen werden',
|
||||
'dawarich.serverVersion': 'Dawarich {version}',
|
||||
|
||||
'dawarich.toast.saved': 'Dawarich-Verbindung gespeichert',
|
||||
'dawarich.toast.saveError': 'Die Verbindung konnte nicht gespeichert werden',
|
||||
'dawarich.toast.disconnected': 'Dawarich getrennt',
|
||||
'dawarich.toast.synced': '{count} neue Aufenthalte gefunden',
|
||||
'dawarich.toast.syncError': 'Dawarich konnte nicht gelesen werden',
|
||||
'dawarich.toast.syncRunning': 'Es läuft bereits eine Prüfung',
|
||||
'dawarich.toast.acceptError': 'Das konnte nicht übernommen werden',
|
||||
'dawarich.toast.updateError': 'Dieser Vorschlag konnte nicht aktualisiert werden',
|
||||
'dawarich.toast.accepted.place': 'Zur Reise hinzugefügt',
|
||||
'dawarich.toast.accepted.journal': 'Ins Tagebuch übernommen',
|
||||
'dawarich.toast.accepted.bucket_list': 'Von deiner Wunschliste abgehakt',
|
||||
|
||||
// ── What the connected instance can do ─────────────────────────────────────
|
||||
'dawarich.capability.visits': 'Aufenthalte',
|
||||
'dawarich.capability.track': 'aufgezeichnete Route',
|
||||
'dawarich.capability.locations': 'Abgleich mit der Wunschliste',
|
||||
'dawarich.capability.visitedCities': 'Länder und Städte',
|
||||
'dawarich.capability.missing': 'Diese Dawarich-Version bietet nicht: {features}.',
|
||||
|
||||
// ── Failure reasons, as sentences the reader can act on ────────────────────
|
||||
'dawarich.error.unreachable': 'TREK konnte diese Adresse nicht erreichen.',
|
||||
'dawarich.error.unauthorized': 'Dawarich hat den API-Schlüssel abgelehnt.',
|
||||
'dawarich.error.forbidden': 'Dieser API-Schlüssel darf das nicht lesen.',
|
||||
'dawarich.error.not_found': 'Diese Dawarich-Version hat diesen Endpunkt nicht.',
|
||||
'dawarich.error.rate_limited':
|
||||
'Dawarich hat TREK gebeten, langsamer zu machen. Versuch es gleich noch einmal.',
|
||||
'dawarich.error.server_error': 'Dawarich hat mit einem Fehler geantwortet.',
|
||||
'dawarich.error.invalid_response': 'Diese Adresse hat mit etwas geantwortet, das kein Dawarich ist.',
|
||||
'dawarich.error.too_large': 'Dawarich hat mehr Daten geschickt, als TREK auf einmal liest.',
|
||||
'dawarich.error.not_connected': 'Es ist noch keine Dawarich-Instanz verbunden.',
|
||||
'dawarich.error.addon_disabled': 'Das Dawarich-Addon ist für diese Instanz abgeschaltet.',
|
||||
'dawarich.error.offline': 'Dafür braucht es eine Verbindung — TREK ist gerade offline.',
|
||||
'dawarich.error.invalid_url': 'TREK kann diese Adresse nicht verwenden.',
|
||||
'dawarich.warning.private_ip': 'Diese Adresse zeigt auf eine private IP ({ip}). Prüfe, ob das so gewollt ist — der Server braucht dafür eventuell ALLOW_INTERNAL_NETWORK=true.',
|
||||
'dawarich.error.unknown': 'Bei der Kommunikation mit Dawarich ist etwas schiefgelaufen.',
|
||||
|
||||
// ── The recorded route on the map ──────────────────────────────────────────
|
||||
'dawarich.trail.show': 'Aufgezeichnete Route anzeigen',
|
||||
'dawarich.trail.hide': 'Aufgezeichnete Route ausblenden',
|
||||
'dawarich.trail.loading': 'Aufgezeichnete Route wird geladen…',
|
||||
'dawarich.trail.empty': 'An diesen Tagen wurde nichts aufgezeichnet',
|
||||
'dawarich.trail.offline': 'Die aufgezeichnete Route braucht eine Verbindung',
|
||||
'dawarich.trail.unavailable': 'Die aufgezeichnete Route konnte nicht geladen werden',
|
||||
|
||||
// ── Suggestions ────────────────────────────────────────────────────────────
|
||||
'dawarich.duration.minutes': '{minutes} Min.',
|
||||
'dawarich.duration.hours': '{hours} Std.',
|
||||
'dawarich.duration.hoursMinutes': '{hours} Std. {minutes} Min.',
|
||||
'dawarich.checkedAgo': 'geprüft {ago}',
|
||||
|
||||
'dawarich.badge.lowConfidence': 'Unsicher',
|
||||
'dawarich.badge.sourceChanged': 'In Dawarich geändert',
|
||||
'dawarich.badge.sourceMissing': 'In Dawarich nicht mehr da',
|
||||
|
||||
'dawarich.suggestions.title': 'Aus Dawarich',
|
||||
'dawarich.suggestions.pending': '{count} warten auf dich',
|
||||
'dawarich.suggestions.loading': 'Dawarich wird gelesen…',
|
||||
'dawarich.suggestions.notConnected':
|
||||
'Verbinde Dawarich in den Einstellungen, um deine Aufenthalte hier zu sehen.',
|
||||
'dawarich.suggestions.unavailable': 'Dawarich konnte nicht gelesen werden.',
|
||||
'dawarich.suggestions.allHandled': 'Alles, was hier aufgezeichnet wurde, ist erledigt.',
|
||||
'dawarich.suggestions.asJournal': 'Tagebucheintrag schreiben',
|
||||
'dawarich.suggestions.asPlace': 'Als Ort hinzufügen',
|
||||
'dawarich.suggestions.dismiss': 'Da war ich nicht',
|
||||
'dawarich.suggestions.dismissed': 'Verworfen',
|
||||
'dawarich.suggestions.restore': 'Zurückholen',
|
||||
'dawarich.suggestions.showHandled': '{count} bereits erledigte anzeigen',
|
||||
'dawarich.suggestions.hideHandled': 'Bereits erledigte ausblenden',
|
||||
'dawarich.suggestions.matchesWish': 'Auf deiner Wunschliste: {name}',
|
||||
'dawarich.suggestions.acceptedAs.place': 'Als Ort hinzugefügt',
|
||||
'dawarich.suggestions.acceptedAs.journal': 'Im Tagebuch',
|
||||
'dawarich.suggestions.acceptedAs.bucket_list': 'Wunsch abgehakt',
|
||||
'dawarich.suggestions.sourceChanged':
|
||||
'Dieser Aufenthalt hat sich in Dawarich geändert, seit du ihn übernommen hast. Was du in TREK geschrieben hast, bleibt unberührt.',
|
||||
'dawarich.suggestions.sourceMissing':
|
||||
'Diesen Aufenthalt gibt es in Dawarich nicht mehr. Was du in TREK geschrieben hast, bleibt unberührt.',
|
||||
'dawarich.sourceStatus.suggested': 'Erkannt, nicht bestätigt',
|
||||
'dawarich.confidence.high': 'Sichere Erkennung',
|
||||
'dawarich.confidence.medium': 'Recht sichere Erkennung',
|
||||
'dawarich.confidence.low': 'Unsichere Erkennung',
|
||||
|
||||
// ── The review step ────────────────────────────────────────────────────────
|
||||
'dawarich.accept.title.place': 'Diesen Aufenthalt als Ort hinzufügen',
|
||||
'dawarich.accept.title.journal': 'Tagebucheintrag schreiben',
|
||||
'dawarich.accept.title.bucket_list': 'Einen Wunsch abhaken',
|
||||
'dawarich.accept.confirm.place': 'Ort hinzufügen',
|
||||
'dawarich.accept.confirm.journal': 'Eintrag hinzufügen',
|
||||
'dawarich.accept.confirm.bucket_list': 'Abhaken',
|
||||
'dawarich.accept.recorded': 'Aufgezeichnet {from} bis {to}',
|
||||
'dawarich.accept.duration': '{minutes} Min.',
|
||||
'dawarich.accept.name': 'Name',
|
||||
'dawarich.accept.date': 'Datum',
|
||||
'dawarich.accept.from': 'Angekommen',
|
||||
'dawarich.accept.to': 'Aufgebrochen',
|
||||
'dawarich.accept.trip': 'Reise',
|
||||
'dawarich.accept.thisTrip': 'Diese Reise',
|
||||
'dawarich.accept.pickTrip': 'Reise auswählen',
|
||||
'dawarich.accept.day': 'Tag',
|
||||
'dawarich.accept.noDay': 'Noch keinem Tag zugeordnet',
|
||||
'dawarich.accept.journal': 'Tagebuch',
|
||||
'dawarich.accept.pickJournal': 'Tagebuch auswählen',
|
||||
'dawarich.accept.notes': 'Notizen',
|
||||
'dawarich.accept.story': 'Deine Geschichte',
|
||||
'dawarich.accept.storyPlaceholder': 'Was ist hier passiert?',
|
||||
'dawarich.accept.photosHint': 'Fotos kannst du dem Eintrag hinzufügen, sobald er angelegt ist.',
|
||||
|
||||
// ── A place that came out of a recording ──────────────────────────────────
|
||||
'dawarich.place.fromDawarich': 'Aus deinen Dawarich-Aufzeichnungen übernommen',
|
||||
|
||||
// ── Wishlist ───────────────────────────────────────────────────────────────
|
||||
'dawarich.bucket.title': 'Wunschliste mit Dawarich abgleichen',
|
||||
'dawarich.bucket.description':
|
||||
'Durchsucht deine Aufzeichnungen nach den Orten, die du erreichen wolltest. Ein Besuch braucht Nähe und verbrachte Zeit — Vorbeifahren zählt nicht.',
|
||||
'dawarich.bucket.scan': 'Wunschliste prüfen',
|
||||
'dawarich.bucket.scanning': 'Wird geprüft…',
|
||||
'dawarich.bucket.noMatches': 'Nichts von deiner Wunschliste taucht in deinen Aufzeichnungen auf.',
|
||||
'dawarich.bucket.alreadyVisited': 'Bereits abgehakt',
|
||||
'dawarich.bucket.confirm': '{count} abhaken',
|
||||
'dawarich.bucket.confirmed': '{count} Wünsche abgehakt',
|
||||
'dawarich.bucket.skipped': '{count} Einträge haben keine Koordinaten und konnten nicht geprüft werden.',
|
||||
'dawarich.bucket.truncated':
|
||||
'Es wurden nur die ersten Einträge geprüft. Starte den Abgleich für den Rest noch einmal.',
|
||||
'dawarich.bucket.visitedFrom': 'Anhand deiner Dawarich-Aufzeichnungen abgehakt',
|
||||
'dawarich.bucket.clearVisit': 'Rückgängig',
|
||||
|
||||
// ── Atlas ──────────────────────────────────────────────────────────────────
|
||||
'dawarich.atlas.title': 'Länder aus Dawarich',
|
||||
'dawarich.atlas.description':
|
||||
'Länder, in denen du deinen Aufzeichnungen zufolge warst. Bestätige die, die in deinen Atlas sollen — nichts wird von allein übernommen, und was du von Hand markiert hast, bleibt deins.',
|
||||
'dawarich.atlas.load': 'Nach Ländern suchen',
|
||||
'dawarich.atlas.loading': 'Deine Aufzeichnungen werden gelesen…',
|
||||
'dawarich.atlas.empty': 'Deine Aufzeichnungen zeigen keine Länder, die TREK nicht schon hat.',
|
||||
'dawarich.atlas.cities': '{count} Städte',
|
||||
'dawarich.atlas.citiesOne': '1 Stadt',
|
||||
'dawarich.atlas.accept': '{count} Länder hinzufügen',
|
||||
'dawarich.atlas.accepted': '{count} Länder hinzugefügt',
|
||||
'dawarich.atlas.unresolved': 'TREK konnte diese keinem Land zuordnen: {names}.',
|
||||
'dawarich.atlas.source': 'Aus Dawarich',
|
||||
'dawarich.atlas.range': 'Betrachtet: {from} bis {to}',
|
||||
|
||||
'dawarich.atlas.trigger': 'Wünsche und Länder aus deinen Aufzeichnungen',
|
||||
'dawarich.atlas.dialogSubtitle': 'Was deine Aufzeichnungen über deinen Atlas sagen',
|
||||
'dawarich.atlas.tab.wishes': 'Wunschliste',
|
||||
'dawarich.atlas.tab.countries': 'Länder',
|
||||
'dawarich.atlas.window': 'Angesehen wurden die letzten 12 Monate.',
|
||||
'dawarich.selected': '{count} ausgewählt',
|
||||
'dawarich.again': 'Erneut prüfen',
|
||||
'dawarich.bucket.metersAway': '{meters} m entfernt',
|
||||
'dawarich.bucket.kilometersAway': '{km} km entfernt',
|
||||
'dawarich.bucket.rule': 'Ein Wunsch gilt ab {meters} m Nähe und {minutes} Minuten vor Ort als erreicht.',
|
||||
|
||||
};
|
||||
|
||||
export default dawarich;
|
||||
@@ -1,4 +1,5 @@
|
||||
import admin from './admin';
|
||||
import dawarich from './dawarich';
|
||||
import airport from './airport';
|
||||
import atlas from './atlas';
|
||||
import backup from './backup';
|
||||
@@ -106,5 +107,6 @@ const locale = {
|
||||
...mobileSettings,
|
||||
...mobileCollections,
|
||||
...storage,
|
||||
...dawarich,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const dawarich: TranslationStrings = {
|
||||
// ── Connection ─────────────────────────────────────────────────────────────
|
||||
'dawarich.title': 'Dawarich',
|
||||
'dawarich.intro':
|
||||
'Connect your own Dawarich instance to see where you actually went. TREK reads it and suggests journal entries, places and countries — nothing is added until you confirm it, and nothing is written back to Dawarich.',
|
||||
'dawarich.url': 'Instance address',
|
||||
'dawarich.apiKey': 'API key',
|
||||
'dawarich.apiKeyPlaceholder': 'Paste your Dawarich API key',
|
||||
'dawarich.apiKeyHint': 'Found in Dawarich under Account → API key. Stored encrypted and never shown again.',
|
||||
'dawarich.allowInsecureTls': 'Allow self-signed certificate',
|
||||
'dawarich.allowInsecureTlsHint': 'Only needed if your instance uses a certificate your server does not trust.',
|
||||
'dawarich.syncEnabled': 'Check for new stays automatically',
|
||||
'dawarich.syncEnabledHint': 'Off means TREK only reads Dawarich when you ask it to.',
|
||||
'dawarich.test.button': 'Test connection',
|
||||
'dawarich.test.success': 'Connected. {count} stays found in the last 30 days.',
|
||||
'dawarich.test.failed': 'Could not reach Dawarich.',
|
||||
'dawarich.syncNow': 'Check now',
|
||||
'dawarich.connected': 'Connected',
|
||||
'dawarich.notConnected': 'Not connected',
|
||||
'dawarich.disconnect': 'Disconnect',
|
||||
'dawarich.lastSync': 'Last checked {when}',
|
||||
'dawarich.neverSynced': 'Not checked yet',
|
||||
'dawarich.syncPartial': 'some trips could not be read',
|
||||
'dawarich.serverVersion': 'Dawarich {version}',
|
||||
|
||||
'dawarich.toast.saved': 'Dawarich connection saved',
|
||||
'dawarich.toast.saveError': 'Could not save the connection',
|
||||
'dawarich.toast.disconnected': 'Dawarich disconnected',
|
||||
'dawarich.toast.synced': '{count} new stays found',
|
||||
'dawarich.toast.syncError': 'Could not read Dawarich',
|
||||
'dawarich.toast.syncRunning': 'A check is already running',
|
||||
'dawarich.toast.acceptError': 'Could not add this',
|
||||
'dawarich.toast.updateError': 'Could not update this suggestion',
|
||||
'dawarich.toast.accepted.place': 'Added to the trip',
|
||||
'dawarich.toast.accepted.journal': 'Added to the journal',
|
||||
'dawarich.toast.accepted.bucket_list': 'Ticked off your wishlist',
|
||||
|
||||
// ── What the connected instance can do ─────────────────────────────────────
|
||||
'dawarich.capability.visits': 'stays',
|
||||
'dawarich.capability.track': 'recorded route',
|
||||
'dawarich.capability.locations': 'wishlist matching',
|
||||
'dawarich.capability.visitedCities': 'countries and cities',
|
||||
'dawarich.capability.missing': 'This Dawarich version does not offer: {features}.',
|
||||
|
||||
// ── Failure reasons, as sentences the reader can act on ────────────────────
|
||||
'dawarich.error.unreachable': 'TREK could not reach that address.',
|
||||
'dawarich.error.unauthorized': 'Dawarich rejected the API key.',
|
||||
'dawarich.error.forbidden': 'That API key is not allowed to read this.',
|
||||
'dawarich.error.not_found': 'This Dawarich version does not have that endpoint.',
|
||||
'dawarich.error.rate_limited': 'Dawarich asked TREK to slow down. Try again shortly.',
|
||||
'dawarich.error.server_error': 'Dawarich answered with an error.',
|
||||
'dawarich.error.invalid_response': 'That address answered with something that is not Dawarich.',
|
||||
'dawarich.error.too_large': 'Dawarich sent more data than TREK will read at once.',
|
||||
'dawarich.error.not_connected': 'No Dawarich instance is connected yet.',
|
||||
'dawarich.error.addon_disabled': 'The Dawarich addon is switched off for this instance.',
|
||||
'dawarich.error.offline': 'This needs a connection — TREK is offline right now.',
|
||||
'dawarich.error.invalid_url': 'TREK cannot use that address.',
|
||||
'dawarich.warning.private_ip': 'That address resolves to a private IP ({ip}). Make sure that is what you meant — the server may need ALLOW_INTERNAL_NETWORK=true to reach it.',
|
||||
'dawarich.error.unknown': 'Something went wrong talking to Dawarich.',
|
||||
|
||||
// ── The recorded route on the map ──────────────────────────────────────────
|
||||
'dawarich.trail.show': 'Show recorded route',
|
||||
'dawarich.trail.hide': 'Hide recorded route',
|
||||
'dawarich.trail.loading': 'Loading the recorded route…',
|
||||
'dawarich.trail.empty': 'Nothing was recorded on these dates',
|
||||
'dawarich.trail.offline': 'The recorded route needs a connection',
|
||||
'dawarich.trail.unavailable': 'The recorded route could not be loaded',
|
||||
|
||||
// ── Suggestions ────────────────────────────────────────────────────────────
|
||||
'dawarich.duration.minutes': '{minutes} min',
|
||||
'dawarich.duration.hours': '{hours} h',
|
||||
'dawarich.duration.hoursMinutes': '{hours} h {minutes} min',
|
||||
'dawarich.checkedAgo': 'checked {ago}',
|
||||
|
||||
'dawarich.badge.lowConfidence': 'Unsure',
|
||||
'dawarich.badge.sourceChanged': 'Changed in Dawarich',
|
||||
'dawarich.badge.sourceMissing': 'Gone from Dawarich',
|
||||
|
||||
'dawarich.suggestions.title': 'From Dawarich',
|
||||
'dawarich.suggestions.pending': '{count} waiting for you',
|
||||
'dawarich.suggestions.loading': 'Reading Dawarich…',
|
||||
'dawarich.suggestions.notConnected': 'Connect Dawarich in Settings to see your stays here.',
|
||||
'dawarich.suggestions.unavailable': 'Dawarich could not be read.',
|
||||
'dawarich.suggestions.allHandled': 'Everything recorded here has been dealt with.',
|
||||
'dawarich.suggestions.asJournal': 'Write a journal entry',
|
||||
'dawarich.suggestions.asPlace': 'Add as a place',
|
||||
'dawarich.suggestions.dismiss': 'Not a place I visited',
|
||||
'dawarich.suggestions.dismissed': 'Dismissed',
|
||||
'dawarich.suggestions.restore': 'Put back',
|
||||
'dawarich.suggestions.showHandled': 'Show {count} already dealt with',
|
||||
'dawarich.suggestions.hideHandled': 'Hide the ones already dealt with',
|
||||
'dawarich.suggestions.matchesWish': 'On your wishlist: {name}',
|
||||
'dawarich.suggestions.acceptedAs.place': 'Added as a place',
|
||||
'dawarich.suggestions.acceptedAs.journal': 'In the journal',
|
||||
'dawarich.suggestions.acceptedAs.bucket_list': 'Wish ticked off',
|
||||
'dawarich.suggestions.sourceChanged':
|
||||
'This stay has changed in Dawarich since you used it. What you wrote in TREK is untouched.',
|
||||
'dawarich.suggestions.sourceMissing':
|
||||
'This stay no longer exists in Dawarich. What you wrote in TREK is untouched.',
|
||||
'dawarich.sourceStatus.suggested': 'Detected, unconfirmed',
|
||||
'dawarich.confidence.high': 'Confident detection',
|
||||
'dawarich.confidence.medium': 'Fairly confident detection',
|
||||
'dawarich.confidence.low': 'Uncertain detection',
|
||||
|
||||
// ── The review step ────────────────────────────────────────────────────────
|
||||
'dawarich.accept.title.place': 'Add this stay as a place',
|
||||
'dawarich.accept.title.journal': 'Write a journal entry',
|
||||
'dawarich.accept.title.bucket_list': 'Tick off a wish',
|
||||
'dawarich.accept.confirm.place': 'Add place',
|
||||
'dawarich.accept.confirm.journal': 'Add entry',
|
||||
'dawarich.accept.confirm.bucket_list': 'Tick it off',
|
||||
'dawarich.accept.recorded': 'Recorded {from} until {to}',
|
||||
'dawarich.accept.duration': '{minutes} min',
|
||||
'dawarich.accept.name': 'Name',
|
||||
'dawarich.accept.date': 'Date',
|
||||
'dawarich.accept.from': 'Arrived',
|
||||
'dawarich.accept.to': 'Left',
|
||||
'dawarich.accept.trip': 'Trip',
|
||||
'dawarich.accept.thisTrip': 'This trip',
|
||||
'dawarich.accept.pickTrip': 'Choose a trip',
|
||||
'dawarich.accept.day': 'Day',
|
||||
'dawarich.accept.noDay': 'Not on a day yet',
|
||||
'dawarich.accept.journal': 'Journal',
|
||||
'dawarich.accept.pickJournal': 'Choose a journal',
|
||||
'dawarich.accept.notes': 'Notes',
|
||||
'dawarich.accept.story': 'Your story',
|
||||
'dawarich.accept.storyPlaceholder': 'What happened here?',
|
||||
'dawarich.accept.photosHint': 'Add photos to the entry after it is created.',
|
||||
|
||||
// ── A place that came out of a recording ──────────────────────────────────
|
||||
'dawarich.place.fromDawarich': 'Added from your Dawarich recordings',
|
||||
|
||||
// ── Wishlist ───────────────────────────────────────────────────────────────
|
||||
'dawarich.bucket.title': 'Check your wishlist against Dawarich',
|
||||
'dawarich.bucket.description':
|
||||
'Looks through your recordings for the places you wanted to reach. A visit needs both closeness and time spent — driving past does not count.',
|
||||
'dawarich.bucket.scan': 'Check wishlist',
|
||||
'dawarich.bucket.scanning': 'Checking…',
|
||||
'dawarich.bucket.noMatches': 'Nothing on your wishlist turned up in your recordings.',
|
||||
'dawarich.bucket.alreadyVisited': 'Already ticked off',
|
||||
'dawarich.bucket.confirm': 'Tick off {count}',
|
||||
'dawarich.bucket.confirmed': '{count} wishes ticked off',
|
||||
'dawarich.bucket.skipped': '{count} entries have no coordinates and could not be checked.',
|
||||
'dawarich.bucket.truncated': 'Only the first entries were checked. Run it again for the rest.',
|
||||
'dawarich.bucket.visitedFrom': 'Ticked off from your Dawarich recordings',
|
||||
'dawarich.bucket.clearVisit': 'Undo',
|
||||
|
||||
// ── Atlas ──────────────────────────────────────────────────────────────────
|
||||
'dawarich.atlas.title': 'Countries from Dawarich',
|
||||
'dawarich.atlas.description':
|
||||
'Countries your recordings say you were in. Confirm the ones you want in your Atlas — nothing is added on its own, and what you marked by hand stays yours.',
|
||||
'dawarich.atlas.load': 'Look for countries',
|
||||
'dawarich.atlas.loading': 'Reading your recordings…',
|
||||
'dawarich.atlas.empty': 'Your recordings show no countries TREK does not already have.',
|
||||
'dawarich.atlas.cities': '{count} cities',
|
||||
'dawarich.atlas.citiesOne': '1 city',
|
||||
'dawarich.atlas.accept': 'Add {count} countries',
|
||||
'dawarich.atlas.accepted': '{count} countries added',
|
||||
'dawarich.atlas.unresolved': 'TREK could not match these to a country: {names}.',
|
||||
'dawarich.atlas.source': 'From Dawarich',
|
||||
'dawarich.atlas.range': 'Looked at {from} to {to}',
|
||||
|
||||
'dawarich.atlas.trigger': 'Wishes and countries from your recordings',
|
||||
'dawarich.atlas.dialogSubtitle': 'What your recordings say about your Atlas',
|
||||
'dawarich.atlas.tab.wishes': 'Wishlist',
|
||||
'dawarich.atlas.tab.countries': 'Countries',
|
||||
'dawarich.atlas.window': 'The last 12 months were looked at.',
|
||||
'dawarich.selected': '{count} selected',
|
||||
'dawarich.again': 'Check again',
|
||||
'dawarich.bucket.metersAway': '{meters} m away',
|
||||
'dawarich.bucket.kilometersAway': '{km} km away',
|
||||
'dawarich.bucket.rule': 'A wish counts as reached within {meters} m and after {minutes} minutes on the spot.',
|
||||
|
||||
};
|
||||
|
||||
export default dawarich;
|
||||
@@ -1,4 +1,5 @@
|
||||
import admin from './admin';
|
||||
import dawarich from './dawarich';
|
||||
import airport from './airport';
|
||||
import atlas from './atlas';
|
||||
import backup from './backup';
|
||||
@@ -106,5 +107,6 @@ const locale = {
|
||||
...mobileSettings,
|
||||
...mobileCollections,
|
||||
...storage,
|
||||
...dawarich,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const dawarich: TranslationStrings = {
|
||||
// ── Connection ─────────────────────────────────────────────────────────────
|
||||
'dawarich.title': 'Dawarich',
|
||||
'dawarich.intro':
|
||||
'Conecta tu propia instancia de Dawarich para ver dónde estuviste realmente. TREK la lee y te sugiere entradas de diario, lugares y países: no se añade nada hasta que lo confirmes y no se escribe nada en Dawarich.',
|
||||
'dawarich.url': 'Dirección de la instancia',
|
||||
'dawarich.apiKey': 'Clave de API',
|
||||
'dawarich.apiKeyPlaceholder': 'Pega tu clave de API de Dawarich',
|
||||
'dawarich.apiKeyHint':
|
||||
'La encontrarás en Dawarich en Cuenta → Clave de API. Se guarda cifrada y no se vuelve a mostrar.',
|
||||
'dawarich.allowInsecureTls': 'Permitir certificado autofirmado',
|
||||
'dawarich.allowInsecureTlsHint':
|
||||
'Solo hace falta si tu instancia usa un certificado en el que tu servidor no confía.',
|
||||
'dawarich.syncEnabled': 'Buscar estancias nuevas automáticamente',
|
||||
'dawarich.syncEnabledHint': 'Desactivado significa que TREK solo lee Dawarich cuando se lo pides.',
|
||||
'dawarich.test.button': 'Probar conexión',
|
||||
'dawarich.test.success': 'Conectado. {count} estancias encontradas en los últimos 30 días.',
|
||||
'dawarich.test.failed': 'No se pudo contactar con Dawarich.',
|
||||
'dawarich.syncNow': 'Buscar ahora',
|
||||
'dawarich.connected': 'Conectado',
|
||||
'dawarich.notConnected': 'No conectado',
|
||||
'dawarich.disconnect': 'Desconectar',
|
||||
'dawarich.lastSync': 'Última comprobación {when}',
|
||||
'dawarich.neverSynced': 'Aún sin comprobar',
|
||||
'dawarich.syncPartial': 'algunos viajes no se pudieron leer',
|
||||
'dawarich.serverVersion': 'Dawarich {version}',
|
||||
|
||||
'dawarich.toast.saved': 'Conexión con Dawarich guardada',
|
||||
'dawarich.toast.saveError': 'No se pudo guardar la conexión',
|
||||
'dawarich.toast.disconnected': 'Dawarich desconectado',
|
||||
'dawarich.toast.synced': '{count} estancias nuevas encontradas',
|
||||
'dawarich.toast.syncError': 'No se pudo leer Dawarich',
|
||||
'dawarich.toast.syncRunning': 'Ya hay una comprobación en curso',
|
||||
'dawarich.toast.acceptError': 'No se pudo añadir esto',
|
||||
'dawarich.toast.updateError': 'No se pudo actualizar esta sugerencia',
|
||||
'dawarich.toast.accepted.place': 'Añadido al viaje',
|
||||
'dawarich.toast.accepted.journal': 'Añadido al diario',
|
||||
'dawarich.toast.accepted.bucket_list': 'Tachado de tu lista de deseos',
|
||||
|
||||
// ── What the connected instance can do ─────────────────────────────────────
|
||||
'dawarich.capability.visits': 'estancias',
|
||||
'dawarich.capability.track': 'ruta grabada',
|
||||
'dawarich.capability.locations': 'coincidencias con la lista de deseos',
|
||||
'dawarich.capability.visitedCities': 'países y ciudades',
|
||||
'dawarich.capability.missing': 'Esta versión de Dawarich no ofrece: {features}.',
|
||||
|
||||
// ── Failure reasons, as sentences the reader can act on ────────────────────
|
||||
'dawarich.error.unreachable': 'TREK no pudo contactar con esa dirección.',
|
||||
'dawarich.error.unauthorized': 'Dawarich rechazó la clave de API.',
|
||||
'dawarich.error.forbidden': 'Esa clave de API no tiene permiso para leer esto.',
|
||||
'dawarich.error.not_found': 'Esta versión de Dawarich no tiene ese endpoint.',
|
||||
'dawarich.error.rate_limited': 'Dawarich pidió a TREK que fuera más despacio. Inténtalo de nuevo en un momento.',
|
||||
'dawarich.error.server_error': 'Dawarich respondió con un error.',
|
||||
'dawarich.error.invalid_response': 'Esa dirección respondió con algo que no es Dawarich.',
|
||||
'dawarich.error.too_large': 'Dawarich envió más datos de los que TREK lee de una vez.',
|
||||
'dawarich.error.not_connected': 'Todavía no hay ninguna instancia de Dawarich conectada.',
|
||||
'dawarich.error.addon_disabled': 'El complemento de Dawarich está desactivado en esta instancia.',
|
||||
'dawarich.error.offline': 'Esto necesita conexión: TREK está sin conexión ahora mismo.',
|
||||
'dawarich.error.invalid_url': 'TREK no puede usar esa dirección.',
|
||||
'dawarich.warning.private_ip': 'Esa dirección apunta a una IP privada ({ip}). Comprueba que es lo que querías: el servidor puede necesitar ALLOW_INTERNAL_NETWORK=true para alcanzarla.',
|
||||
'dawarich.error.unknown': 'Algo salió mal al hablar con Dawarich.',
|
||||
|
||||
// ── The recorded route on the map ──────────────────────────────────────────
|
||||
'dawarich.trail.show': 'Mostrar ruta grabada',
|
||||
'dawarich.trail.hide': 'Ocultar ruta grabada',
|
||||
'dawarich.trail.loading': 'Cargando la ruta grabada…',
|
||||
'dawarich.trail.empty': 'No se grabó nada en estas fechas',
|
||||
'dawarich.trail.offline': 'La ruta grabada necesita conexión',
|
||||
'dawarich.trail.unavailable': 'No se pudo cargar la ruta grabada',
|
||||
|
||||
// ── Suggestions ────────────────────────────────────────────────────────────
|
||||
'dawarich.duration.minutes': '{minutes} min',
|
||||
'dawarich.duration.hours': '{hours} h',
|
||||
'dawarich.duration.hoursMinutes': '{hours} h {minutes} min',
|
||||
'dawarich.checkedAgo': 'comprobado {ago}',
|
||||
|
||||
'dawarich.badge.lowConfidence': 'Incierto',
|
||||
'dawarich.badge.sourceChanged': 'Cambiado en Dawarich',
|
||||
'dawarich.badge.sourceMissing': 'Ya no está en Dawarich',
|
||||
|
||||
'dawarich.suggestions.title': 'Desde Dawarich',
|
||||
'dawarich.suggestions.pending': '{count} esperándote',
|
||||
'dawarich.suggestions.loading': 'Leyendo Dawarich…',
|
||||
'dawarich.suggestions.notConnected': 'Conecta Dawarich en Ajustes para ver aquí tus estancias.',
|
||||
'dawarich.suggestions.unavailable': 'No se pudo leer Dawarich.',
|
||||
'dawarich.suggestions.allHandled': 'Ya has resuelto todo lo grabado aquí.',
|
||||
'dawarich.suggestions.asJournal': 'Escribir una entrada de diario',
|
||||
'dawarich.suggestions.asPlace': 'Añadir como lugar',
|
||||
'dawarich.suggestions.dismiss': 'No es un lugar que visitara',
|
||||
'dawarich.suggestions.dismissed': 'Descartado',
|
||||
'dawarich.suggestions.restore': 'Devolver',
|
||||
'dawarich.suggestions.showHandled': 'Mostrar {count} ya resueltas',
|
||||
'dawarich.suggestions.hideHandled': 'Ocultar las ya resueltas',
|
||||
'dawarich.suggestions.matchesWish': 'En tu lista de deseos: {name}',
|
||||
'dawarich.suggestions.acceptedAs.place': 'Añadido como lugar',
|
||||
'dawarich.suggestions.acceptedAs.journal': 'En el diario',
|
||||
'dawarich.suggestions.acceptedAs.bucket_list': 'Deseo tachado',
|
||||
'dawarich.suggestions.sourceChanged':
|
||||
'Esta estancia ha cambiado en Dawarich desde que la usaste. Lo que escribiste en TREK sigue intacto.',
|
||||
'dawarich.suggestions.sourceMissing':
|
||||
'Esta estancia ya no existe en Dawarich. Lo que escribiste en TREK sigue intacto.',
|
||||
'dawarich.sourceStatus.suggested': 'Detectada, sin confirmar',
|
||||
'dawarich.confidence.high': 'Detección segura',
|
||||
'dawarich.confidence.medium': 'Detección bastante segura',
|
||||
'dawarich.confidence.low': 'Detección dudosa',
|
||||
|
||||
// ── The review step ────────────────────────────────────────────────────────
|
||||
'dawarich.accept.title.place': 'Añadir esta estancia como lugar',
|
||||
'dawarich.accept.title.journal': 'Escribir una entrada de diario',
|
||||
'dawarich.accept.title.bucket_list': 'Tachar un deseo',
|
||||
'dawarich.accept.confirm.place': 'Añadir lugar',
|
||||
'dawarich.accept.confirm.journal': 'Añadir entrada',
|
||||
'dawarich.accept.confirm.bucket_list': 'Tacharlo',
|
||||
'dawarich.accept.recorded': 'Grabado de {from} a {to}',
|
||||
'dawarich.accept.duration': '{minutes} min',
|
||||
'dawarich.accept.name': 'Nombre',
|
||||
'dawarich.accept.date': 'Fecha',
|
||||
'dawarich.accept.from': 'Llegada',
|
||||
'dawarich.accept.to': 'Salida',
|
||||
'dawarich.accept.trip': 'Viaje',
|
||||
'dawarich.accept.thisTrip': 'Este viaje',
|
||||
'dawarich.accept.pickTrip': 'Elige un viaje',
|
||||
'dawarich.accept.day': 'Día',
|
||||
'dawarich.accept.noDay': 'Aún sin día asignado',
|
||||
'dawarich.accept.journal': 'Diario',
|
||||
'dawarich.accept.pickJournal': 'Elige un diario',
|
||||
'dawarich.accept.notes': 'Notas',
|
||||
'dawarich.accept.story': 'Tu historia',
|
||||
'dawarich.accept.storyPlaceholder': '¿Qué pasó aquí?',
|
||||
'dawarich.accept.photosHint': 'Añade fotos a la entrada una vez creada.',
|
||||
|
||||
// ── A place that came out of a recording ──────────────────────────────────
|
||||
'dawarich.place.fromDawarich': 'Añadido desde tus registros de Dawarich',
|
||||
|
||||
// ── Wishlist ───────────────────────────────────────────────────────────────
|
||||
'dawarich.bucket.title': 'Contrasta tu lista de deseos con Dawarich',
|
||||
'dawarich.bucket.description':
|
||||
'Busca en tus grabaciones los lugares a los que querías llegar. Una visita necesita cercanía y tiempo: pasar de largo no cuenta.',
|
||||
'dawarich.bucket.scan': 'Comprobar la lista de deseos',
|
||||
'dawarich.bucket.scanning': 'Comprobando…',
|
||||
'dawarich.bucket.noMatches': 'No apareció nada de tu lista de deseos en tus grabaciones.',
|
||||
'dawarich.bucket.alreadyVisited': 'Ya tachado',
|
||||
'dawarich.bucket.confirm': 'Tachar {count}',
|
||||
'dawarich.bucket.confirmed': '{count} deseos tachados',
|
||||
'dawarich.bucket.skipped': '{count} entradas no tienen coordenadas y no se pudieron comprobar.',
|
||||
'dawarich.bucket.truncated': 'Solo se comprobaron las primeras entradas. Vuelve a ejecutarlo para el resto.',
|
||||
'dawarich.bucket.visitedFrom': 'Tachado a partir de tus grabaciones de Dawarich',
|
||||
'dawarich.bucket.clearVisit': 'Deshacer',
|
||||
|
||||
// ── Atlas ──────────────────────────────────────────────────────────────────
|
||||
'dawarich.atlas.title': 'Países desde Dawarich',
|
||||
'dawarich.atlas.description':
|
||||
'Países en los que, según tus grabaciones, estuviste. Confirma los que quieras en tu Atlas: no se añade nada por su cuenta y lo que marcaste a mano sigue siendo tuyo.',
|
||||
'dawarich.atlas.load': 'Buscar países',
|
||||
'dawarich.atlas.loading': 'Leyendo tus grabaciones…',
|
||||
'dawarich.atlas.empty': 'Tus grabaciones no muestran ningún país que TREK no tenga ya.',
|
||||
'dawarich.atlas.cities': '{count} ciudades',
|
||||
'dawarich.atlas.citiesOne': '1 ciudad',
|
||||
'dawarich.atlas.accept': 'Añadir {count} países',
|
||||
'dawarich.atlas.accepted': '{count} países añadidos',
|
||||
'dawarich.atlas.unresolved': 'TREK no pudo asociar estos a ningún país: {names}.',
|
||||
'dawarich.atlas.source': 'Desde Dawarich',
|
||||
'dawarich.atlas.range': 'Revisado del {from} al {to}',
|
||||
|
||||
'dawarich.atlas.trigger': 'Deseos y países de tus registros',
|
||||
'dawarich.atlas.dialogSubtitle': 'Lo que tus registros dicen de tu Atlas',
|
||||
'dawarich.atlas.tab.wishes': 'Lista de deseos',
|
||||
'dawarich.atlas.tab.countries': 'Países',
|
||||
'dawarich.atlas.window': 'Se han revisado los últimos 12 meses.',
|
||||
'dawarich.selected': '{count} seleccionados',
|
||||
'dawarich.again': 'Volver a comprobar',
|
||||
'dawarich.bucket.metersAway': 'a {meters} m',
|
||||
'dawarich.bucket.kilometersAway': 'a {km} km',
|
||||
'dawarich.bucket.rule': 'Un deseo cuenta como cumplido a menos de {meters} m y tras {minutes} minutos en el lugar.',
|
||||
|
||||
};
|
||||
|
||||
export default dawarich;
|
||||
@@ -1,4 +1,5 @@
|
||||
import admin from './admin';
|
||||
import dawarich from './dawarich';
|
||||
import airport from './airport';
|
||||
import atlas from './atlas';
|
||||
import backup from './backup';
|
||||
@@ -106,5 +107,6 @@ const locale = {
|
||||
...mobileSettings,
|
||||
...mobileCollections,
|
||||
...storage,
|
||||
...dawarich,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const dawarich: TranslationStrings = {
|
||||
// ── Connection ─────────────────────────────────────────────────────────────
|
||||
'dawarich.title': 'Dawarich',
|
||||
'dawarich.intro':
|
||||
'Connectez votre propre instance Dawarich pour voir où vous êtes réellement allé. TREK la consulte et vous propose des entrées de journal, des lieux et des pays — rien n’est ajouté sans votre confirmation, et rien n’est réécrit dans Dawarich.',
|
||||
'dawarich.url': 'Adresse de l’instance',
|
||||
'dawarich.apiKey': 'Clé API',
|
||||
'dawarich.apiKeyPlaceholder': 'Collez votre clé API Dawarich',
|
||||
'dawarich.apiKeyHint':
|
||||
'Disponible dans Dawarich sous Compte → Clé API. Stockée chiffrée et jamais affichée à nouveau.',
|
||||
'dawarich.allowInsecureTls': 'Autoriser un certificat auto-signé',
|
||||
'dawarich.allowInsecureTlsHint':
|
||||
'Nécessaire uniquement si votre instance utilise un certificat auquel votre serveur ne fait pas confiance.',
|
||||
'dawarich.syncEnabled': 'Rechercher automatiquement les nouveaux séjours',
|
||||
'dawarich.syncEnabledHint': 'Désactivé, TREK ne consulte Dawarich que lorsque vous le lui demandez.',
|
||||
'dawarich.test.button': 'Tester la connexion',
|
||||
'dawarich.test.success': 'Connecté. {count} séjours trouvés sur les 30 derniers jours.',
|
||||
'dawarich.test.failed': 'Impossible de joindre Dawarich.',
|
||||
'dawarich.syncNow': 'Vérifier maintenant',
|
||||
'dawarich.connected': 'Connecté',
|
||||
'dawarich.notConnected': 'Non connecté',
|
||||
'dawarich.disconnect': 'Déconnecter',
|
||||
'dawarich.lastSync': 'Dernière vérification {when}',
|
||||
'dawarich.neverSynced': 'Jamais vérifié',
|
||||
'dawarich.syncPartial': 'certains voyages n’ont pas pu être lus',
|
||||
'dawarich.serverVersion': 'Dawarich {version}',
|
||||
|
||||
'dawarich.toast.saved': 'Connexion Dawarich enregistrée',
|
||||
'dawarich.toast.saveError': 'Impossible d’enregistrer la connexion',
|
||||
'dawarich.toast.disconnected': 'Dawarich déconnecté',
|
||||
'dawarich.toast.synced': '{count} nouveaux séjours trouvés',
|
||||
'dawarich.toast.syncError': 'Impossible de lire Dawarich',
|
||||
'dawarich.toast.syncRunning': 'Une vérification est déjà en cours',
|
||||
'dawarich.toast.acceptError': 'Impossible d’ajouter cet élément',
|
||||
'dawarich.toast.updateError': 'Impossible de mettre à jour cette suggestion',
|
||||
'dawarich.toast.accepted.place': 'Ajouté au voyage',
|
||||
'dawarich.toast.accepted.journal': 'Ajouté au journal',
|
||||
'dawarich.toast.accepted.bucket_list': 'Coché dans votre liste de souhaits',
|
||||
|
||||
// ── What the connected instance can do ─────────────────────────────────────
|
||||
'dawarich.capability.visits': 'séjours',
|
||||
'dawarich.capability.track': 'trajet enregistré',
|
||||
'dawarich.capability.locations': 'correspondance avec la liste de souhaits',
|
||||
'dawarich.capability.visitedCities': 'pays et villes',
|
||||
'dawarich.capability.missing': 'Cette version de Dawarich ne propose pas : {features}.',
|
||||
|
||||
// ── Failure reasons, as sentences the reader can act on ────────────────────
|
||||
'dawarich.error.unreachable': 'TREK n’a pas pu joindre cette adresse.',
|
||||
'dawarich.error.unauthorized': 'Dawarich a refusé la clé API.',
|
||||
'dawarich.error.forbidden': 'Cette clé API n’a pas le droit de lire ces données.',
|
||||
'dawarich.error.not_found': 'Cette version de Dawarich ne dispose pas de ce point d’accès.',
|
||||
'dawarich.error.rate_limited': 'Dawarich a demandé à TREK de ralentir. Réessayez dans un instant.',
|
||||
'dawarich.error.server_error': 'Dawarich a répondu par une erreur.',
|
||||
'dawarich.error.invalid_response': 'Cette adresse a répondu quelque chose qui n’est pas Dawarich.',
|
||||
'dawarich.error.too_large': 'Dawarich a envoyé plus de données que TREK n’en lit en une fois.',
|
||||
'dawarich.error.not_connected': 'Aucune instance Dawarich n’est encore connectée.',
|
||||
'dawarich.error.addon_disabled': 'Le module Dawarich est désactivé sur cette instance.',
|
||||
'dawarich.error.offline': 'Cela demande une connexion — TREK est hors ligne pour le moment.',
|
||||
'dawarich.error.invalid_url': 'TREK ne peut pas utiliser cette adresse.',
|
||||
'dawarich.warning.private_ip': 'Cette adresse pointe vers une IP privée ({ip}). Vérifiez que c’est voulu — le serveur peut avoir besoin de ALLOW_INTERNAL_NETWORK=true pour l’atteindre.',
|
||||
'dawarich.error.unknown': 'Un problème est survenu lors de l’échange avec Dawarich.',
|
||||
|
||||
// ── The recorded route on the map ──────────────────────────────────────────
|
||||
'dawarich.trail.show': 'Afficher le trajet enregistré',
|
||||
'dawarich.trail.hide': 'Masquer le trajet enregistré',
|
||||
'dawarich.trail.loading': 'Chargement du trajet enregistré…',
|
||||
'dawarich.trail.empty': 'Rien n’a été enregistré à ces dates',
|
||||
'dawarich.trail.offline': 'Le trajet enregistré nécessite une connexion',
|
||||
'dawarich.trail.unavailable': 'Le trajet enregistré n’a pas pu être chargé',
|
||||
|
||||
// ── Suggestions ────────────────────────────────────────────────────────────
|
||||
'dawarich.duration.minutes': '{minutes} min',
|
||||
'dawarich.duration.hours': '{hours} h',
|
||||
'dawarich.duration.hoursMinutes': '{hours} h {minutes} min',
|
||||
'dawarich.checkedAgo': 'vérifié {ago}',
|
||||
|
||||
'dawarich.badge.lowConfidence': 'Incertain',
|
||||
'dawarich.badge.sourceChanged': 'Modifié dans Dawarich',
|
||||
'dawarich.badge.sourceMissing': 'Disparu de Dawarich',
|
||||
|
||||
'dawarich.suggestions.title': 'Depuis Dawarich',
|
||||
'dawarich.suggestions.pending': '{count} en attente',
|
||||
'dawarich.suggestions.loading': 'Lecture de Dawarich…',
|
||||
'dawarich.suggestions.notConnected':
|
||||
'Connectez Dawarich dans les Paramètres pour voir vos séjours ici.',
|
||||
'dawarich.suggestions.unavailable': 'Dawarich n’a pas pu être lu.',
|
||||
'dawarich.suggestions.allHandled': 'Tout ce qui a été enregistré ici a été traité.',
|
||||
'dawarich.suggestions.asJournal': 'Écrire une entrée de journal',
|
||||
'dawarich.suggestions.asPlace': 'Ajouter comme lieu',
|
||||
'dawarich.suggestions.dismiss': 'Je n’ai pas visité ce lieu',
|
||||
'dawarich.suggestions.dismissed': 'Ignoré',
|
||||
'dawarich.suggestions.restore': 'Rétablir',
|
||||
'dawarich.suggestions.showHandled': 'Afficher les {count} déjà traités',
|
||||
'dawarich.suggestions.hideHandled': 'Masquer ceux déjà traités',
|
||||
'dawarich.suggestions.matchesWish': 'Dans votre liste de souhaits : {name}',
|
||||
'dawarich.suggestions.acceptedAs.place': 'Ajouté comme lieu',
|
||||
'dawarich.suggestions.acceptedAs.journal': 'Dans le journal',
|
||||
'dawarich.suggestions.acceptedAs.bucket_list': 'Souhait coché',
|
||||
'dawarich.suggestions.sourceChanged':
|
||||
'Ce séjour a changé dans Dawarich depuis que vous l’avez utilisé. Ce que vous avez écrit dans TREK reste intact.',
|
||||
'dawarich.suggestions.sourceMissing':
|
||||
'Ce séjour n’existe plus dans Dawarich. Ce que vous avez écrit dans TREK reste intact.',
|
||||
'dawarich.sourceStatus.suggested': 'Détecté, non confirmé',
|
||||
'dawarich.confidence.high': 'Détection sûre',
|
||||
'dawarich.confidence.medium': 'Détection plutôt sûre',
|
||||
'dawarich.confidence.low': 'Détection incertaine',
|
||||
|
||||
// ── The review step ────────────────────────────────────────────────────────
|
||||
'dawarich.accept.title.place': 'Ajouter ce séjour comme lieu',
|
||||
'dawarich.accept.title.journal': 'Écrire une entrée de journal',
|
||||
'dawarich.accept.title.bucket_list': 'Cocher un souhait',
|
||||
'dawarich.accept.confirm.place': 'Ajouter le lieu',
|
||||
'dawarich.accept.confirm.journal': 'Ajouter l’entrée',
|
||||
'dawarich.accept.confirm.bucket_list': 'Le cocher',
|
||||
'dawarich.accept.recorded': 'Enregistré du {from} au {to}',
|
||||
'dawarich.accept.duration': '{minutes} min',
|
||||
'dawarich.accept.name': 'Nom',
|
||||
'dawarich.accept.date': 'Date',
|
||||
'dawarich.accept.from': 'Arrivée',
|
||||
'dawarich.accept.to': 'Départ',
|
||||
'dawarich.accept.trip': 'Voyage',
|
||||
'dawarich.accept.thisTrip': 'Ce voyage',
|
||||
'dawarich.accept.pickTrip': 'Choisir un voyage',
|
||||
'dawarich.accept.day': 'Jour',
|
||||
'dawarich.accept.noDay': 'Pas encore rattaché à un jour',
|
||||
'dawarich.accept.journal': 'Journal',
|
||||
'dawarich.accept.pickJournal': 'Choisir un journal',
|
||||
'dawarich.accept.notes': 'Notes',
|
||||
'dawarich.accept.story': 'Votre histoire',
|
||||
'dawarich.accept.storyPlaceholder': 'Que s’est-il passé ici ?',
|
||||
'dawarich.accept.photosHint': 'Ajoutez des photos à l’entrée une fois qu’elle est créée.',
|
||||
|
||||
// ── A place that came out of a recording ──────────────────────────────────
|
||||
'dawarich.place.fromDawarich': 'Ajouté depuis vos enregistrements Dawarich',
|
||||
|
||||
// ── Wishlist ───────────────────────────────────────────────────────────────
|
||||
'dawarich.bucket.title': 'Confronter votre liste de souhaits à Dawarich',
|
||||
'dawarich.bucket.description':
|
||||
'Parcourt vos enregistrements à la recherche des lieux que vous vouliez atteindre. Une visite exige à la fois la proximité et du temps passé sur place — passer devant en voiture ne compte pas.',
|
||||
'dawarich.bucket.scan': 'Vérifier la liste de souhaits',
|
||||
'dawarich.bucket.scanning': 'Vérification…',
|
||||
'dawarich.bucket.noMatches': 'Rien de votre liste de souhaits n’apparaît dans vos enregistrements.',
|
||||
'dawarich.bucket.alreadyVisited': 'Déjà coché',
|
||||
'dawarich.bucket.confirm': 'Cocher {count}',
|
||||
'dawarich.bucket.confirmed': '{count} souhaits cochés',
|
||||
'dawarich.bucket.skipped': '{count} entrées n’ont pas de coordonnées et n’ont pas pu être vérifiées.',
|
||||
'dawarich.bucket.truncated':
|
||||
'Seules les premières entrées ont été vérifiées. Relancez pour traiter le reste.',
|
||||
'dawarich.bucket.visitedFrom': 'Coché à partir de vos enregistrements Dawarich',
|
||||
'dawarich.bucket.clearVisit': 'Annuler',
|
||||
|
||||
// ── Atlas ──────────────────────────────────────────────────────────────────
|
||||
'dawarich.atlas.title': 'Pays issus de Dawarich',
|
||||
'dawarich.atlas.description':
|
||||
'Les pays où vos enregistrements indiquent que vous êtes allé. Confirmez ceux que vous voulez dans votre Atlas — rien n’est ajouté tout seul, et ce que vous avez marqué à la main vous appartient.',
|
||||
'dawarich.atlas.load': 'Rechercher des pays',
|
||||
'dawarich.atlas.loading': 'Lecture de vos enregistrements…',
|
||||
'dawarich.atlas.empty': 'Vos enregistrements ne révèlent aucun pays que TREK ne connaisse déjà.',
|
||||
'dawarich.atlas.cities': '{count} villes',
|
||||
'dawarich.atlas.citiesOne': '1 ville',
|
||||
'dawarich.atlas.accept': 'Ajouter {count} pays',
|
||||
'dawarich.atlas.accepted': '{count} pays ajoutés',
|
||||
'dawarich.atlas.unresolved': 'TREK n’a pas pu rattacher ceci à un pays : {names}.',
|
||||
'dawarich.atlas.source': 'Depuis Dawarich',
|
||||
'dawarich.atlas.range': 'Période examinée : du {from} au {to}',
|
||||
|
||||
'dawarich.atlas.trigger': 'Envies et pays issus de vos enregistrements',
|
||||
'dawarich.atlas.dialogSubtitle': 'Ce que vos enregistrements disent de votre Atlas',
|
||||
'dawarich.atlas.tab.wishes': 'Liste d’envies',
|
||||
'dawarich.atlas.tab.countries': 'Pays',
|
||||
'dawarich.atlas.window': 'Les 12 derniers mois ont été examinés.',
|
||||
'dawarich.selected': '{count} sélectionnés',
|
||||
'dawarich.again': 'Vérifier à nouveau',
|
||||
'dawarich.bucket.metersAway': 'à {meters} m',
|
||||
'dawarich.bucket.kilometersAway': 'à {km} km',
|
||||
'dawarich.bucket.rule': 'Une envie est atteinte à moins de {meters} m et après {minutes} minutes sur place.',
|
||||
|
||||
};
|
||||
|
||||
export default dawarich;
|
||||
@@ -1,4 +1,5 @@
|
||||
import admin from './admin';
|
||||
import dawarich from './dawarich';
|
||||
import airport from './airport';
|
||||
import atlas from './atlas';
|
||||
import backup from './backup';
|
||||
@@ -106,5 +107,6 @@ const locale = {
|
||||
...mobileSettings,
|
||||
...mobileCollections,
|
||||
...storage,
|
||||
...dawarich,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const dawarich: TranslationStrings = {
|
||||
// ── Connection ─────────────────────────────────────────────────────────────
|
||||
'dawarich.title': 'Dawarich',
|
||||
'dawarich.intro':
|
||||
'Συνδέστε τη δική σας εγκατάσταση Dawarich για να δείτε πού βρεθήκατε πραγματικά. Το TREK τη διαβάζει και προτείνει καταχωρήσεις ημερολογίου, τοποθεσίες και χώρες — τίποτα δεν προστίθεται πριν το επιβεβαιώσετε και τίποτα δεν γράφεται πίσω στο Dawarich.',
|
||||
'dawarich.url': 'Διεύθυνση εγκατάστασης',
|
||||
'dawarich.apiKey': 'Κλειδί API',
|
||||
'dawarich.apiKeyPlaceholder': 'Επικολλήστε το κλειδί API του Dawarich',
|
||||
'dawarich.apiKeyHint':
|
||||
'Βρίσκεται στο Dawarich στο Λογαριασμός → Κλειδί API. Αποθηκεύεται κρυπτογραφημένο και δεν εμφανίζεται ξανά.',
|
||||
'dawarich.allowInsecureTls': 'Να επιτρέπεται αυτο-υπογεγραμμένο πιστοποιητικό',
|
||||
'dawarich.allowInsecureTlsHint':
|
||||
'Χρειάζεται μόνο αν η εγκατάστασή σας χρησιμοποιεί πιστοποιητικό που δεν εμπιστεύεται ο διακομιστής σας.',
|
||||
'dawarich.syncEnabled': 'Αυτόματος έλεγχος για νέες στάσεις',
|
||||
'dawarich.syncEnabledHint':
|
||||
'Απενεργοποιημένο σημαίνει ότι το TREK διαβάζει το Dawarich μόνο όταν του το ζητήσετε.',
|
||||
'dawarich.test.button': 'Δοκιμή σύνδεσης',
|
||||
'dawarich.test.success': 'Συνδέθηκε. Βρέθηκαν {count} στάσεις τις τελευταίες 30 ημέρες.',
|
||||
'dawarich.test.failed': 'Δεν ήταν δυνατή η επικοινωνία με το Dawarich.',
|
||||
'dawarich.syncNow': 'Έλεγχος τώρα',
|
||||
'dawarich.connected': 'Συνδεδεμένο',
|
||||
'dawarich.notConnected': 'Μη συνδεδεμένο',
|
||||
'dawarich.disconnect': 'Αποσύνδεση',
|
||||
'dawarich.lastSync': 'Τελευταίος έλεγχος {when}',
|
||||
'dawarich.neverSynced': 'Δεν έχει ελεγχθεί ακόμα',
|
||||
'dawarich.syncPartial': 'ορισμένα ταξίδια δεν ήταν δυνατό να διαβαστούν',
|
||||
'dawarich.serverVersion': 'Dawarich {version}',
|
||||
|
||||
'dawarich.toast.saved': 'Η σύνδεση με το Dawarich αποθηκεύτηκε',
|
||||
'dawarich.toast.saveError': 'Δεν ήταν δυνατή η αποθήκευση της σύνδεσης',
|
||||
'dawarich.toast.disconnected': 'Το Dawarich αποσυνδέθηκε',
|
||||
'dawarich.toast.synced': 'Βρέθηκαν {count} νέες στάσεις',
|
||||
'dawarich.toast.syncError': 'Δεν ήταν δυνατή η ανάγνωση του Dawarich',
|
||||
'dawarich.toast.syncRunning': 'Εκτελείται ήδη έλεγχος',
|
||||
'dawarich.toast.acceptError': 'Δεν ήταν δυνατή η προσθήκη',
|
||||
'dawarich.toast.updateError': 'Δεν ήταν δυνατή η ενημέρωση αυτής της πρότασης',
|
||||
'dawarich.toast.accepted.place': 'Προστέθηκε στο ταξίδι',
|
||||
'dawarich.toast.accepted.journal': 'Προστέθηκε στο ημερολόγιο',
|
||||
'dawarich.toast.accepted.bucket_list': 'Σημειώθηκε ως εκπληρωμένη επιθυμία',
|
||||
|
||||
// ── What the connected instance can do ─────────────────────────────────────
|
||||
'dawarich.capability.visits': 'στάσεις',
|
||||
'dawarich.capability.track': 'καταγεγραμμένη διαδρομή',
|
||||
'dawarich.capability.locations': 'αντιστοίχιση λίστας επιθυμιών',
|
||||
'dawarich.capability.visitedCities': 'χώρες και πόλεις',
|
||||
'dawarich.capability.missing': 'Αυτή η έκδοση του Dawarich δεν προσφέρει: {features}.',
|
||||
|
||||
// ── Failure reasons, as sentences the reader can act on ────────────────────
|
||||
'dawarich.error.unreachable': 'Το TREK δεν μπόρεσε να επικοινωνήσει με αυτή τη διεύθυνση.',
|
||||
'dawarich.error.unauthorized': 'Το Dawarich απέρριψε το κλειδί API.',
|
||||
'dawarich.error.forbidden': 'Αυτό το κλειδί API δεν επιτρέπεται να διαβάσει κάτι τέτοιο.',
|
||||
'dawarich.error.not_found': 'Αυτή η έκδοση του Dawarich δεν διαθέτει αυτό το σημείο πρόσβασης.',
|
||||
'dawarich.error.rate_limited': 'Το Dawarich ζήτησε από το TREK να επιβραδύνει. Δοκιμάστε ξανά σε λίγο.',
|
||||
'dawarich.error.server_error': 'Το Dawarich απάντησε με σφάλμα.',
|
||||
'dawarich.error.invalid_response': 'Αυτή η διεύθυνση απάντησε με κάτι που δεν είναι Dawarich.',
|
||||
'dawarich.error.too_large': 'Το Dawarich έστειλε περισσότερα δεδομένα από όσα διαβάζει το TREK με τη μία.',
|
||||
'dawarich.error.not_connected': 'Δεν έχει συνδεθεί ακόμα καμία εγκατάσταση Dawarich.',
|
||||
'dawarich.error.addon_disabled': 'Το πρόσθετο Dawarich είναι απενεργοποιημένο σε αυτή την εγκατάσταση.',
|
||||
'dawarich.error.offline': 'Αυτό χρειάζεται σύνδεση — το TREK είναι εκτός σύνδεσης αυτή τη στιγμή.',
|
||||
'dawarich.error.invalid_url': 'Το TREK δεν μπορεί να χρησιμοποιήσει αυτή τη διεύθυνση.',
|
||||
'dawarich.warning.private_ip': 'Αυτή η διεύθυνση οδηγεί σε ιδιωτική IP ({ip}). Βεβαιωθείτε ότι αυτό εννοούσατε — ο διακομιστής ίσως χρειάζεται ALLOW_INTERNAL_NETWORK=true.',
|
||||
'dawarich.error.unknown': 'Κάτι πήγε στραβά στην επικοινωνία με το Dawarich.',
|
||||
|
||||
// ── The recorded route on the map ──────────────────────────────────────────
|
||||
'dawarich.trail.show': 'Εμφάνιση καταγεγραμμένης διαδρομής',
|
||||
'dawarich.trail.hide': 'Απόκρυψη καταγεγραμμένης διαδρομής',
|
||||
'dawarich.trail.loading': 'Φόρτωση της καταγεγραμμένης διαδρομής…',
|
||||
'dawarich.trail.empty': 'Δεν καταγράφηκε τίποτα αυτές τις ημερομηνίες',
|
||||
'dawarich.trail.offline': 'Η καταγεγραμμένη διαδρομή χρειάζεται σύνδεση',
|
||||
'dawarich.trail.unavailable': 'Δεν ήταν δυνατή η φόρτωση της καταγεγραμμένης διαδρομής',
|
||||
|
||||
// ── Suggestions ────────────────────────────────────────────────────────────
|
||||
'dawarich.duration.minutes': '{minutes} λ',
|
||||
'dawarich.duration.hours': '{hours} ώ',
|
||||
'dawarich.duration.hoursMinutes': '{hours} ώ {minutes} λ',
|
||||
'dawarich.checkedAgo': 'ελέγχθηκε {ago}',
|
||||
|
||||
'dawarich.badge.lowConfidence': 'Αβέβαιο',
|
||||
'dawarich.badge.sourceChanged': 'Άλλαξε στο Dawarich',
|
||||
'dawarich.badge.sourceMissing': 'Χάθηκε από το Dawarich',
|
||||
|
||||
'dawarich.suggestions.title': 'Από το Dawarich',
|
||||
'dawarich.suggestions.pending': '{count} περιμένουν από εσάς',
|
||||
'dawarich.suggestions.loading': 'Ανάγνωση του Dawarich…',
|
||||
'dawarich.suggestions.notConnected':
|
||||
'Συνδέστε το Dawarich στις Ρυθμίσεις για να βλέπετε εδώ τις στάσεις σας.',
|
||||
'dawarich.suggestions.unavailable': 'Δεν ήταν δυνατή η ανάγνωση του Dawarich.',
|
||||
'dawarich.suggestions.allHandled': 'Όλα όσα καταγράφηκαν εδώ έχουν διεκπεραιωθεί.',
|
||||
'dawarich.suggestions.asJournal': 'Γράψτε καταχώρηση ημερολογίου',
|
||||
'dawarich.suggestions.asPlace': 'Προσθήκη ως τοποθεσία',
|
||||
'dawarich.suggestions.dismiss': 'Δεν είναι μέρος που επισκέφθηκα',
|
||||
'dawarich.suggestions.dismissed': 'Απορρίφθηκε',
|
||||
'dawarich.suggestions.restore': 'Επαναφορά',
|
||||
'dawarich.suggestions.showHandled': 'Εμφάνιση {count} που έχουν ήδη διεκπεραιωθεί',
|
||||
'dawarich.suggestions.hideHandled': 'Απόκρυψη όσων έχουν ήδη διεκπεραιωθεί',
|
||||
'dawarich.suggestions.matchesWish': 'Στη λίστα επιθυμιών σας: {name}',
|
||||
'dawarich.suggestions.acceptedAs.place': 'Προστέθηκε ως τοποθεσία',
|
||||
'dawarich.suggestions.acceptedAs.journal': 'Στο ημερολόγιο',
|
||||
'dawarich.suggestions.acceptedAs.bucket_list': 'Η επιθυμία σημειώθηκε ως εκπληρωμένη',
|
||||
'dawarich.suggestions.sourceChanged':
|
||||
'Αυτή η στάση έχει αλλάξει στο Dawarich από τότε που τη χρησιμοποιήσατε. Ό,τι γράψατε στο TREK παραμένει ανέπαφο.',
|
||||
'dawarich.suggestions.sourceMissing':
|
||||
'Αυτή η στάση δεν υπάρχει πλέον στο Dawarich. Ό,τι γράψατε στο TREK παραμένει ανέπαφο.',
|
||||
'dawarich.sourceStatus.suggested': 'Εντοπίστηκε, χωρίς επιβεβαίωση',
|
||||
'dawarich.confidence.high': 'Εντοπισμός με βεβαιότητα',
|
||||
'dawarich.confidence.medium': 'Εντοπισμός με σχετική βεβαιότητα',
|
||||
'dawarich.confidence.low': 'Αβέβαιος εντοπισμός',
|
||||
|
||||
// ── The review step ────────────────────────────────────────────────────────
|
||||
'dawarich.accept.title.place': 'Προσθήκη αυτής της στάσης ως τοποθεσία',
|
||||
'dawarich.accept.title.journal': 'Γράψτε καταχώρηση ημερολογίου',
|
||||
'dawarich.accept.title.bucket_list': 'Σημειώστε μια επιθυμία ως εκπληρωμένη',
|
||||
'dawarich.accept.confirm.place': 'Προσθήκη τοποθεσίας',
|
||||
'dawarich.accept.confirm.journal': 'Προσθήκη καταχώρησης',
|
||||
'dawarich.accept.confirm.bucket_list': 'Σημείωση ως εκπληρωμένης',
|
||||
'dawarich.accept.recorded': 'Καταγράφηκε από {from} έως {to}',
|
||||
'dawarich.accept.duration': '{minutes} λεπτά',
|
||||
'dawarich.accept.name': 'Όνομα',
|
||||
'dawarich.accept.date': 'Ημερομηνία',
|
||||
'dawarich.accept.from': 'Άφιξη',
|
||||
'dawarich.accept.to': 'Αναχώρηση',
|
||||
'dawarich.accept.trip': 'Ταξίδι',
|
||||
'dawarich.accept.thisTrip': 'Αυτό το ταξίδι',
|
||||
'dawarich.accept.pickTrip': 'Επιλέξτε ταξίδι',
|
||||
'dawarich.accept.day': 'Ημέρα',
|
||||
'dawarich.accept.noDay': 'Χωρίς ημέρα ακόμα',
|
||||
'dawarich.accept.journal': 'Ημερολόγιο',
|
||||
'dawarich.accept.pickJournal': 'Επιλέξτε ημερολόγιο',
|
||||
'dawarich.accept.notes': 'Σημειώσεις',
|
||||
'dawarich.accept.story': 'Η ιστορία σας',
|
||||
'dawarich.accept.storyPlaceholder': 'Τι συνέβη εδώ;',
|
||||
'dawarich.accept.photosHint': 'Προσθέστε φωτογραφίες στην καταχώρηση αφού δημιουργηθεί.',
|
||||
|
||||
// ── A place that came out of a recording ──────────────────────────────────
|
||||
'dawarich.place.fromDawarich': 'Προστέθηκε από τις καταγραφές σας στο Dawarich',
|
||||
|
||||
// ── Wishlist ───────────────────────────────────────────────────────────────
|
||||
'dawarich.bucket.title': 'Ελέγξτε τη λίστα επιθυμιών σας με το Dawarich',
|
||||
'dawarich.bucket.description':
|
||||
'Ψάχνει στις καταγραφές σας για τα μέρη που θέλατε να φτάσετε. Μια επίσκεψη χρειάζεται και εγγύτητα και χρόνο παραμονής — το να περάσετε απλώς από μπροστά δεν μετράει.',
|
||||
'dawarich.bucket.scan': 'Έλεγχος λίστας επιθυμιών',
|
||||
'dawarich.bucket.scanning': 'Γίνεται έλεγχος…',
|
||||
'dawarich.bucket.noMatches': 'Τίποτα από τη λίστα επιθυμιών σας δεν εντοπίστηκε στις καταγραφές σας.',
|
||||
'dawarich.bucket.alreadyVisited': 'Ήδη σημειωμένο ως εκπληρωμένο',
|
||||
'dawarich.bucket.confirm': 'Σημείωση {count} ως εκπληρωμένων',
|
||||
'dawarich.bucket.confirmed': '{count} επιθυμίες σημειώθηκαν ως εκπληρωμένες',
|
||||
'dawarich.bucket.skipped': '{count} καταχωρήσεις δεν έχουν συντεταγμένες και δεν ήταν δυνατό να ελεγχθούν.',
|
||||
'dawarich.bucket.truncated':
|
||||
'Ελέγχθηκαν μόνο οι πρώτες καταχωρήσεις. Εκτελέστε το ξανά για τις υπόλοιπες.',
|
||||
'dawarich.bucket.visitedFrom': 'Σημειώθηκε ως εκπληρωμένη από τις καταγραφές σας στο Dawarich',
|
||||
'dawarich.bucket.clearVisit': 'Αναίρεση',
|
||||
|
||||
// ── Atlas ──────────────────────────────────────────────────────────────────
|
||||
'dawarich.atlas.title': 'Χώρες από το Dawarich',
|
||||
'dawarich.atlas.description':
|
||||
'Χώρες στις οποίες βρεθήκατε σύμφωνα με τις καταγραφές σας. Επιβεβαιώστε όσες θέλετε στο Atlas σας — τίποτα δεν προστίθεται από μόνο του και όσα σημειώσατε χειροκίνητα παραμένουν δικά σας.',
|
||||
'dawarich.atlas.load': 'Αναζήτηση χωρών',
|
||||
'dawarich.atlas.loading': 'Ανάγνωση των καταγραφών σας…',
|
||||
'dawarich.atlas.empty': 'Οι καταγραφές σας δεν δείχνουν χώρες που δεν έχει ήδη το TREK.',
|
||||
'dawarich.atlas.cities': '{count} πόλεις',
|
||||
'dawarich.atlas.citiesOne': '1 πόλη',
|
||||
'dawarich.atlas.accept': 'Προσθήκη {count} χωρών',
|
||||
'dawarich.atlas.accepted': '{count} χώρες προστέθηκαν',
|
||||
'dawarich.atlas.unresolved': 'Το TREK δεν μπόρεσε να τα αντιστοιχίσει σε χώρα: {names}.',
|
||||
'dawarich.atlas.source': 'Από το Dawarich',
|
||||
'dawarich.atlas.range': 'Εξετάστηκε το διάστημα {from} έως {to}',
|
||||
|
||||
'dawarich.atlas.trigger': 'Επιθυμίες και χώρες από τις καταγραφές σας',
|
||||
'dawarich.atlas.dialogSubtitle': 'Τι λένε οι καταγραφές σας για το Atlas σας',
|
||||
'dawarich.atlas.tab.wishes': 'Λίστα επιθυμιών',
|
||||
'dawarich.atlas.tab.countries': 'Χώρες',
|
||||
'dawarich.atlas.window': 'Ελέγχθηκαν οι τελευταίοι 12 μήνες.',
|
||||
'dawarich.selected': 'Επιλέχθηκαν {count}',
|
||||
'dawarich.again': 'Έλεγχος ξανά',
|
||||
'dawarich.bucket.metersAway': '{meters} μ. μακριά',
|
||||
'dawarich.bucket.kilometersAway': '{km} χλμ. μακριά',
|
||||
'dawarich.bucket.rule': 'Μια επιθυμία μετράει ως εκπληρωμένη σε απόσταση έως {meters} μ. και μετά από {minutes} λεπτά στο σημείο.',
|
||||
|
||||
};
|
||||
|
||||
export default dawarich;
|
||||
@@ -1,4 +1,5 @@
|
||||
import admin from './admin';
|
||||
import dawarich from './dawarich';
|
||||
import airport from './airport';
|
||||
import atlas from './atlas';
|
||||
import backup from './backup';
|
||||
@@ -106,5 +107,6 @@ const locale = {
|
||||
...mobileSettings,
|
||||
...mobileCollections,
|
||||
...storage,
|
||||
...dawarich,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const dawarich: TranslationStrings = {
|
||||
// ── Connection ─────────────────────────────────────────────────────────────
|
||||
'dawarich.title': 'Dawarich',
|
||||
'dawarich.intro':
|
||||
'Kapcsold össze a saját Dawarich-példányodat, és lásd, merre jártál valójában. A TREK kiolvassa, és naplóbejegyzéseket, helyeket és országokat javasol — semmi nem kerül be, amíg meg nem erősíted, és a Dawarichba semmit nem ír vissza.',
|
||||
'dawarich.url': 'A példány címe',
|
||||
'dawarich.apiKey': 'API-kulcs',
|
||||
'dawarich.apiKeyPlaceholder': 'Illeszd be a Dawarich API-kulcsodat',
|
||||
'dawarich.apiKeyHint':
|
||||
'A Dawarichban a Fiók → API-kulcs alatt található. Titkosítva tároljuk, és többé nem jelenik meg.',
|
||||
'dawarich.allowInsecureTls': 'Saját aláírású tanúsítvány engedélyezése',
|
||||
'dawarich.allowInsecureTlsHint':
|
||||
'Csak akkor kell, ha a példányod olyan tanúsítványt használ, amelyben a kiszolgálód nem bízik meg.',
|
||||
'dawarich.syncEnabled': 'Új tartózkodások automatikus keresése',
|
||||
'dawarich.syncEnabledHint': 'Kikapcsolva a TREK csak akkor olvassa a Dawarichot, ha te kéred.',
|
||||
'dawarich.test.button': 'Kapcsolat tesztelése',
|
||||
'dawarich.test.success': 'Kapcsolódva. {count} tartózkodás az elmúlt 30 napban.',
|
||||
'dawarich.test.failed': 'Nem sikerült elérni a Dawarichot.',
|
||||
'dawarich.syncNow': 'Keresés most',
|
||||
'dawarich.connected': 'Kapcsolódva',
|
||||
'dawarich.notConnected': 'Nincs kapcsolódva',
|
||||
'dawarich.disconnect': 'Leválasztás',
|
||||
'dawarich.lastSync': 'Utoljára ellenőrizve: {when}',
|
||||
'dawarich.neverSynced': 'Még nem volt ellenőrzés',
|
||||
'dawarich.syncPartial': 'néhány utat nem sikerült kiolvasni',
|
||||
'dawarich.serverVersion': 'Dawarich {version}',
|
||||
|
||||
'dawarich.toast.saved': 'Dawarich-kapcsolat mentve',
|
||||
'dawarich.toast.saveError': 'Nem sikerült menteni a kapcsolatot',
|
||||
'dawarich.toast.disconnected': 'Dawarich leválasztva',
|
||||
'dawarich.toast.synced': '{count} új tartózkodás',
|
||||
'dawarich.toast.syncError': 'Nem sikerült kiolvasni a Dawarichot',
|
||||
'dawarich.toast.syncRunning': 'Már fut egy ellenőrzés',
|
||||
'dawarich.toast.acceptError': 'Nem sikerült hozzáadni',
|
||||
'dawarich.toast.updateError': 'Nem sikerült frissíteni ezt a javaslatot',
|
||||
'dawarich.toast.accepted.place': 'Hozzáadva az úthoz',
|
||||
'dawarich.toast.accepted.journal': 'Hozzáadva a naplóhoz',
|
||||
'dawarich.toast.accepted.bucket_list': 'Kipipálva a kívánságlistádon',
|
||||
|
||||
// ── What the connected instance can do ─────────────────────────────────────
|
||||
'dawarich.capability.visits': 'tartózkodások',
|
||||
'dawarich.capability.track': 'rögzített útvonal',
|
||||
'dawarich.capability.locations': 'kívánságlista-egyeztetés',
|
||||
'dawarich.capability.visitedCities': 'országok és városok',
|
||||
'dawarich.capability.missing': 'Ez a Dawarich-verzió nem kínálja a következőket: {features}.',
|
||||
|
||||
// ── Failure reasons, as sentences the reader can act on ────────────────────
|
||||
'dawarich.error.unreachable': 'A TREK nem érte el ezt a címet.',
|
||||
'dawarich.error.unauthorized': 'A Dawarich elutasította az API-kulcsot.',
|
||||
'dawarich.error.forbidden': 'Ez az API-kulcs nem olvashatja ezt.',
|
||||
'dawarich.error.not_found': 'Ebben a Dawarich-verzióban nincs ilyen végpont.',
|
||||
'dawarich.error.rate_limited': 'A Dawarich lassítást kért a TREK-től. Próbáld újra hamarosan.',
|
||||
'dawarich.error.server_error': 'A Dawarich hibával válaszolt.',
|
||||
'dawarich.error.invalid_response': 'Ez a cím olyasmivel válaszolt, ami nem Dawarich.',
|
||||
'dawarich.error.too_large': 'A Dawarich több adatot küldött, mint amennyit a TREK egyszerre beolvas.',
|
||||
'dawarich.error.not_connected': 'Még nincs csatlakoztatott Dawarich-példány.',
|
||||
'dawarich.error.addon_disabled': 'A Dawarich bővítmény ki van kapcsolva ezen a példányon.',
|
||||
'dawarich.error.offline': 'Ehhez kapcsolat kell — a TREK most offline.',
|
||||
'dawarich.error.invalid_url': 'A TREK nem tudja használni ezt a címet.',
|
||||
'dawarich.warning.private_ip': 'Ez a cím privát IP-re mutat ({ip}). Ellenőrizd, hogy így akartad-e — a szervernek ehhez ALLOW_INTERNAL_NETWORK=true kellhet.',
|
||||
'dawarich.error.unknown': 'Hiba történt a Dawarichhal folytatott kommunikáció közben.',
|
||||
|
||||
// ── The recorded route on the map ──────────────────────────────────────────
|
||||
'dawarich.trail.show': 'Rögzített útvonal megjelenítése',
|
||||
'dawarich.trail.hide': 'Rögzített útvonal elrejtése',
|
||||
'dawarich.trail.loading': 'Rögzített útvonal betöltése…',
|
||||
'dawarich.trail.empty': 'Ezeken a napokon nem készült rögzítés',
|
||||
'dawarich.trail.offline': 'A rögzített útvonalhoz kapcsolat kell',
|
||||
'dawarich.trail.unavailable': 'A rögzített útvonalat nem sikerült betölteni',
|
||||
|
||||
// ── Suggestions ────────────────────────────────────────────────────────────
|
||||
'dawarich.duration.minutes': '{minutes} perc',
|
||||
'dawarich.duration.hours': '{hours} ó',
|
||||
'dawarich.duration.hoursMinutes': '{hours} ó {minutes} perc',
|
||||
'dawarich.checkedAgo': 'ellenőrizve {ago}',
|
||||
|
||||
'dawarich.badge.lowConfidence': 'Bizonytalan',
|
||||
'dawarich.badge.sourceChanged': 'Megváltozott a Dawarichban',
|
||||
'dawarich.badge.sourceMissing': 'Eltűnt a Dawarichból',
|
||||
|
||||
'dawarich.suggestions.title': 'A Dawarichból',
|
||||
'dawarich.suggestions.pending': '{count} vár rád',
|
||||
'dawarich.suggestions.loading': 'A Dawarich olvasása…',
|
||||
'dawarich.suggestions.notConnected':
|
||||
'Kapcsold össze a Dawarichot a Beállításokban, hogy itt lásd a tartózkodásaidat.',
|
||||
'dawarich.suggestions.unavailable': 'A Dawarichot nem sikerült kiolvasni.',
|
||||
'dawarich.suggestions.allHandled': 'Minden itt rögzített dologgal foglalkoztál már.',
|
||||
'dawarich.suggestions.asJournal': 'Naplóbejegyzés írása',
|
||||
'dawarich.suggestions.asPlace': 'Hozzáadás helyszínként',
|
||||
'dawarich.suggestions.dismiss': 'Itt nem jártam',
|
||||
'dawarich.suggestions.dismissed': 'Elvetve',
|
||||
'dawarich.suggestions.restore': 'Visszatétel',
|
||||
'dawarich.suggestions.showHandled': '{count} elintézett megjelenítése',
|
||||
'dawarich.suggestions.hideHandled': 'A már elintézettek elrejtése',
|
||||
'dawarich.suggestions.matchesWish': 'A kívánságlistádon: {name}',
|
||||
'dawarich.suggestions.acceptedAs.place': 'Hozzáadva helyszínként',
|
||||
'dawarich.suggestions.acceptedAs.journal': 'A naplóban',
|
||||
'dawarich.suggestions.acceptedAs.bucket_list': 'Kívánság kipipálva',
|
||||
'dawarich.suggestions.sourceChanged':
|
||||
'Ez a tartózkodás megváltozott a Dawarichban, amióta felhasználtad. Amit a TREK-ben írtál, érintetlen maradt.',
|
||||
'dawarich.suggestions.sourceMissing':
|
||||
'Ez a tartózkodás már nem létezik a Dawarichban. Amit a TREK-ben írtál, érintetlen maradt.',
|
||||
'dawarich.sourceStatus.suggested': 'Felismerve, megerősítetlen',
|
||||
'dawarich.confidence.high': 'Biztos felismerés',
|
||||
'dawarich.confidence.medium': 'Meglehetősen biztos felismerés',
|
||||
'dawarich.confidence.low': 'Bizonytalan felismerés',
|
||||
|
||||
// ── The review step ────────────────────────────────────────────────────────
|
||||
'dawarich.accept.title.place': 'Tartózkodás hozzáadása helyszínként',
|
||||
'dawarich.accept.title.journal': 'Naplóbejegyzés írása',
|
||||
'dawarich.accept.title.bucket_list': 'Kívánság kipipálása',
|
||||
'dawarich.accept.confirm.place': 'Helyszín hozzáadása',
|
||||
'dawarich.accept.confirm.journal': 'Bejegyzés hozzáadása',
|
||||
'dawarich.accept.confirm.bucket_list': 'Pipáld ki',
|
||||
'dawarich.accept.recorded': 'Rögzítve {from} és {to} között',
|
||||
'dawarich.accept.duration': '{minutes} perc',
|
||||
'dawarich.accept.name': 'Név',
|
||||
'dawarich.accept.date': 'Dátum',
|
||||
'dawarich.accept.from': 'Érkezés',
|
||||
'dawarich.accept.to': 'Távozás',
|
||||
'dawarich.accept.trip': 'Utazás',
|
||||
'dawarich.accept.thisTrip': 'Ez az utazás',
|
||||
'dawarich.accept.pickTrip': 'Válassz utazást',
|
||||
'dawarich.accept.day': 'Nap',
|
||||
'dawarich.accept.noDay': 'Még nincs naphoz rendelve',
|
||||
'dawarich.accept.journal': 'Napló',
|
||||
'dawarich.accept.pickJournal': 'Válassz naplót',
|
||||
'dawarich.accept.notes': 'Jegyzetek',
|
||||
'dawarich.accept.story': 'A történeted',
|
||||
'dawarich.accept.storyPlaceholder': 'Mi történt itt?',
|
||||
'dawarich.accept.photosHint': 'A fotókat a bejegyzés létrehozása után add hozzá.',
|
||||
|
||||
// ── A place that came out of a recording ──────────────────────────────────
|
||||
'dawarich.place.fromDawarich': 'A Dawarich-felvételeidből hozzáadva',
|
||||
|
||||
// ── Wishlist ───────────────────────────────────────────────────────────────
|
||||
'dawarich.bucket.title': 'Kívánságlista összevetése a Dawarichcsal',
|
||||
'dawarich.bucket.description':
|
||||
'Átnézi a rögzítéseidet azokat a helyeket keresve, amelyeket el akartál érni. Egy látogatáshoz közelség és eltöltött idő is kell — az elhajtás nem számít.',
|
||||
'dawarich.bucket.scan': 'Kívánságlista ellenőrzése',
|
||||
'dawarich.bucket.scanning': 'Ellenőrzés…',
|
||||
'dawarich.bucket.noMatches': 'A kívánságlistádról semmi nem bukkant fel a rögzítéseidben.',
|
||||
'dawarich.bucket.alreadyVisited': 'Már kipipálva',
|
||||
'dawarich.bucket.confirm': '{count} kipipálása',
|
||||
'dawarich.bucket.confirmed': '{count} kívánság kipipálva',
|
||||
'dawarich.bucket.skipped': '{count} bejegyzésnek nincs koordinátája, ezért nem volt ellenőrizhető.',
|
||||
'dawarich.bucket.truncated': 'Csak az első bejegyzések lettek ellenőrizve. Futtasd le újra a többihez.',
|
||||
'dawarich.bucket.visitedFrom': 'A Dawarich-rögzítéseid alapján kipipálva',
|
||||
'dawarich.bucket.clearVisit': 'Visszavonás',
|
||||
|
||||
// ── Atlas ──────────────────────────────────────────────────────────────────
|
||||
'dawarich.atlas.title': 'Országok a Dawarichból',
|
||||
'dawarich.atlas.description':
|
||||
'Azok az országok, amelyekben a rögzítéseid szerint jártál. Erősítsd meg, melyek kerüljenek az Atlaszodba — magától semmi nem kerül be, és amit kézzel jelöltél, a tiéd marad.',
|
||||
'dawarich.atlas.load': 'Országok keresése',
|
||||
'dawarich.atlas.loading': 'A rögzítéseid olvasása…',
|
||||
'dawarich.atlas.empty': 'A rögzítéseid nem mutatnak olyan országot, amelyet a TREK ne ismerne már.',
|
||||
'dawarich.atlas.cities': '{count} város',
|
||||
'dawarich.atlas.citiesOne': '1 város',
|
||||
'dawarich.atlas.accept': '{count} ország hozzáadása',
|
||||
'dawarich.atlas.accepted': '{count} ország hozzáadva',
|
||||
'dawarich.atlas.unresolved': 'A TREK ezeket nem tudta országhoz párosítani: {names}.',
|
||||
'dawarich.atlas.source': 'A Dawarichból',
|
||||
'dawarich.atlas.range': 'Vizsgált időszak: {from} – {to}',
|
||||
|
||||
'dawarich.atlas.trigger': 'Kívánságok és országok a felvételeidből',
|
||||
'dawarich.atlas.dialogSubtitle': 'Mit mondanak a felvételeid az Atlaszodról',
|
||||
'dawarich.atlas.tab.wishes': 'Kívánságlista',
|
||||
'dawarich.atlas.tab.countries': 'Országok',
|
||||
'dawarich.atlas.window': 'Az elmúlt 12 hónapot néztük át.',
|
||||
'dawarich.selected': '{count} kiválasztva',
|
||||
'dawarich.again': 'Ellenőrzés újra',
|
||||
'dawarich.bucket.metersAway': '{meters} m-re',
|
||||
'dawarich.bucket.kilometersAway': '{km} km-re',
|
||||
'dawarich.bucket.rule': 'Egy kívánság {meters} méteren belül és {minutes} perc helyszíni idő után számít teljesítettnek.',
|
||||
|
||||
};
|
||||
|
||||
export default dawarich;
|
||||
@@ -1,4 +1,5 @@
|
||||
import admin from './admin';
|
||||
import dawarich from './dawarich';
|
||||
import airport from './airport';
|
||||
import atlas from './atlas';
|
||||
import backup from './backup';
|
||||
@@ -106,5 +107,6 @@ const locale = {
|
||||
...mobileSettings,
|
||||
...mobileCollections,
|
||||
...storage,
|
||||
...dawarich,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const dawarich: TranslationStrings = {
|
||||
// ── Connection ─────────────────────────────────────────────────────────────
|
||||
'dawarich.title': 'Dawarich',
|
||||
'dawarich.intro':
|
||||
'Hubungkan instans Dawarich milikmu sendiri untuk melihat ke mana kamu benar-benar pergi. TREK membacanya dan mengusulkan entri jurnal, tempat, dan negara — tidak ada yang ditambahkan sebelum kamu mengonfirmasinya, dan tidak ada yang ditulis kembali ke Dawarich.',
|
||||
'dawarich.url': 'Alamat instans',
|
||||
'dawarich.apiKey': 'Kunci API',
|
||||
'dawarich.apiKeyPlaceholder': 'Tempelkan kunci API Dawarich milikmu',
|
||||
'dawarich.apiKeyHint':
|
||||
'Ada di Dawarich pada Account → API key. Disimpan terenkripsi dan tidak pernah ditampilkan lagi.',
|
||||
'dawarich.allowInsecureTls': 'Izinkan sertifikat yang ditandatangani sendiri',
|
||||
'dawarich.allowInsecureTlsHint':
|
||||
'Hanya perlu jika instansmu memakai sertifikat yang tidak dipercaya oleh servermu.',
|
||||
'dawarich.syncEnabled': 'Periksa persinggahan baru secara otomatis',
|
||||
'dawarich.syncEnabledHint': 'Jika mati, TREK hanya membaca Dawarich saat kamu memintanya.',
|
||||
'dawarich.test.button': 'Uji koneksi',
|
||||
'dawarich.test.success': 'Terhubung. {count} persinggahan ditemukan dalam 30 hari terakhir.',
|
||||
'dawarich.test.failed': 'Tidak dapat menjangkau Dawarich.',
|
||||
'dawarich.syncNow': 'Periksa sekarang',
|
||||
'dawarich.connected': 'Terhubung',
|
||||
'dawarich.notConnected': 'Tidak terhubung',
|
||||
'dawarich.disconnect': 'Putuskan',
|
||||
'dawarich.lastSync': 'Terakhir diperiksa {when}',
|
||||
'dawarich.neverSynced': 'Belum pernah diperiksa',
|
||||
'dawarich.syncPartial': 'beberapa perjalanan tidak dapat dibaca',
|
||||
'dawarich.serverVersion': 'Dawarich {version}',
|
||||
|
||||
'dawarich.toast.saved': 'Koneksi Dawarich disimpan',
|
||||
'dawarich.toast.saveError': 'Tidak dapat menyimpan koneksi',
|
||||
'dawarich.toast.disconnected': 'Dawarich diputuskan',
|
||||
'dawarich.toast.synced': '{count} persinggahan baru ditemukan',
|
||||
'dawarich.toast.syncError': 'Tidak dapat membaca Dawarich',
|
||||
'dawarich.toast.syncRunning': 'Pemeriksaan sedang berjalan',
|
||||
'dawarich.toast.acceptError': 'Tidak dapat menambahkan ini',
|
||||
'dawarich.toast.updateError': 'Tidak dapat memperbarui saran ini',
|
||||
'dawarich.toast.accepted.place': 'Ditambahkan ke perjalanan',
|
||||
'dawarich.toast.accepted.journal': 'Ditambahkan ke jurnal',
|
||||
'dawarich.toast.accepted.bucket_list': 'Dicoret dari daftar keinginanmu',
|
||||
|
||||
// ── What the connected instance can do ─────────────────────────────────────
|
||||
'dawarich.capability.visits': 'persinggahan',
|
||||
'dawarich.capability.track': 'rute terekam',
|
||||
'dawarich.capability.locations': 'pencocokan daftar keinginan',
|
||||
'dawarich.capability.visitedCities': 'negara dan kota',
|
||||
'dawarich.capability.missing': 'Versi Dawarich ini tidak menyediakan: {features}.',
|
||||
|
||||
// ── Failure reasons, as sentences the reader can act on ────────────────────
|
||||
'dawarich.error.unreachable': 'TREK tidak dapat menjangkau alamat itu.',
|
||||
'dawarich.error.unauthorized': 'Dawarich menolak kunci API tersebut.',
|
||||
'dawarich.error.forbidden': 'Kunci API itu tidak diizinkan membaca ini.',
|
||||
'dawarich.error.not_found': 'Versi Dawarich ini tidak punya endpoint tersebut.',
|
||||
'dawarich.error.rate_limited': 'Dawarich meminta TREK memperlambat. Coba lagi sebentar lagi.',
|
||||
'dawarich.error.server_error': 'Dawarich menjawab dengan galat.',
|
||||
'dawarich.error.invalid_response': 'Alamat itu menjawab dengan sesuatu yang bukan Dawarich.',
|
||||
'dawarich.error.too_large': 'Dawarich mengirim data lebih banyak daripada yang dibaca TREK sekaligus.',
|
||||
'dawarich.error.not_connected': 'Belum ada instans Dawarich yang terhubung.',
|
||||
'dawarich.error.addon_disabled': 'Addon Dawarich dimatikan untuk instans ini.',
|
||||
'dawarich.error.offline': 'Ini butuh koneksi — TREK sedang offline.',
|
||||
'dawarich.error.invalid_url': 'TREK tidak bisa memakai alamat itu.',
|
||||
'dawarich.warning.private_ip': 'Alamat itu mengarah ke IP privat ({ip}). Pastikan memang itu maksudmu — server mungkin perlu ALLOW_INTERNAL_NETWORK=true untuk menjangkaunya.',
|
||||
'dawarich.error.unknown': 'Terjadi kesalahan saat berkomunikasi dengan Dawarich.',
|
||||
|
||||
// ── The recorded route on the map ──────────────────────────────────────────
|
||||
'dawarich.trail.show': 'Tampilkan rute terekam',
|
||||
'dawarich.trail.hide': 'Sembunyikan rute terekam',
|
||||
'dawarich.trail.loading': 'Memuat rute terekam…',
|
||||
'dawarich.trail.empty': 'Tidak ada yang terekam pada tanggal ini',
|
||||
'dawarich.trail.offline': 'Rute terekam memerlukan koneksi',
|
||||
'dawarich.trail.unavailable': 'Rute terekam tidak dapat dimuat',
|
||||
|
||||
// ── Suggestions ────────────────────────────────────────────────────────────
|
||||
'dawarich.duration.minutes': '{minutes} mnt',
|
||||
'dawarich.duration.hours': '{hours} j',
|
||||
'dawarich.duration.hoursMinutes': '{hours} j {minutes} mnt',
|
||||
'dawarich.checkedAgo': 'diperiksa {ago}',
|
||||
|
||||
'dawarich.badge.lowConfidence': 'Ragu',
|
||||
'dawarich.badge.sourceChanged': 'Berubah di Dawarich',
|
||||
'dawarich.badge.sourceMissing': 'Hilang dari Dawarich',
|
||||
|
||||
'dawarich.suggestions.title': 'Dari Dawarich',
|
||||
'dawarich.suggestions.pending': '{count} menunggumu',
|
||||
'dawarich.suggestions.loading': 'Membaca Dawarich…',
|
||||
'dawarich.suggestions.notConnected': 'Hubungkan Dawarich di Pengaturan untuk melihat persinggahanmu di sini.',
|
||||
'dawarich.suggestions.unavailable': 'Dawarich tidak dapat dibaca.',
|
||||
'dawarich.suggestions.allHandled': 'Semua yang terekam di sini sudah ditangani.',
|
||||
'dawarich.suggestions.asJournal': 'Tulis entri jurnal',
|
||||
'dawarich.suggestions.asPlace': 'Tambahkan sebagai tempat',
|
||||
'dawarich.suggestions.dismiss': 'Bukan tempat yang saya kunjungi',
|
||||
'dawarich.suggestions.dismissed': 'Diabaikan',
|
||||
'dawarich.suggestions.restore': 'Kembalikan',
|
||||
'dawarich.suggestions.showHandled': 'Tampilkan {count} yang sudah ditangani',
|
||||
'dawarich.suggestions.hideHandled': 'Sembunyikan yang sudah ditangani',
|
||||
'dawarich.suggestions.matchesWish': 'Ada di daftar keinginanmu: {name}',
|
||||
'dawarich.suggestions.acceptedAs.place': 'Ditambahkan sebagai tempat',
|
||||
'dawarich.suggestions.acceptedAs.journal': 'Ada di jurnal',
|
||||
'dawarich.suggestions.acceptedAs.bucket_list': 'Keinginan dicoret',
|
||||
'dawarich.suggestions.sourceChanged':
|
||||
'Persinggahan ini berubah di Dawarich sejak kamu memakainya. Apa yang kamu tulis di TREK tetap utuh.',
|
||||
'dawarich.suggestions.sourceMissing':
|
||||
'Persinggahan ini tidak ada lagi di Dawarich. Apa yang kamu tulis di TREK tetap utuh.',
|
||||
'dawarich.sourceStatus.suggested': 'Terdeteksi, belum dikonfirmasi',
|
||||
'dawarich.confidence.high': 'Deteksi meyakinkan',
|
||||
'dawarich.confidence.medium': 'Deteksi cukup meyakinkan',
|
||||
'dawarich.confidence.low': 'Deteksi tidak pasti',
|
||||
|
||||
// ── The review step ────────────────────────────────────────────────────────
|
||||
'dawarich.accept.title.place': 'Tambahkan persinggahan ini sebagai tempat',
|
||||
'dawarich.accept.title.journal': 'Tulis entri jurnal',
|
||||
'dawarich.accept.title.bucket_list': 'Coret sebuah keinginan',
|
||||
'dawarich.accept.confirm.place': 'Tambah tempat',
|
||||
'dawarich.accept.confirm.journal': 'Tambah entri',
|
||||
'dawarich.accept.confirm.bucket_list': 'Coret',
|
||||
'dawarich.accept.recorded': 'Terekam {from} sampai {to}',
|
||||
'dawarich.accept.duration': '{minutes} mnt',
|
||||
'dawarich.accept.name': 'Nama',
|
||||
'dawarich.accept.date': 'Tanggal',
|
||||
'dawarich.accept.from': 'Tiba',
|
||||
'dawarich.accept.to': 'Pergi',
|
||||
'dawarich.accept.trip': 'Perjalanan',
|
||||
'dawarich.accept.thisTrip': 'Perjalanan ini',
|
||||
'dawarich.accept.pickTrip': 'Pilih perjalanan',
|
||||
'dawarich.accept.day': 'Hari',
|
||||
'dawarich.accept.noDay': 'Belum pada suatu hari',
|
||||
'dawarich.accept.journal': 'Jurnal',
|
||||
'dawarich.accept.pickJournal': 'Pilih jurnal',
|
||||
'dawarich.accept.notes': 'Catatan',
|
||||
'dawarich.accept.story': 'Kisahmu',
|
||||
'dawarich.accept.storyPlaceholder': 'Apa yang terjadi di sini?',
|
||||
'dawarich.accept.photosHint': 'Tambahkan foto ke entri setelah entri dibuat.',
|
||||
|
||||
// ── A place that came out of a recording ──────────────────────────────────
|
||||
'dawarich.place.fromDawarich': 'Ditambahkan dari rekaman Dawarich-mu',
|
||||
|
||||
// ── Wishlist ───────────────────────────────────────────────────────────────
|
||||
'dawarich.bucket.title': 'Cocokkan daftar keinginanmu dengan Dawarich',
|
||||
'dawarich.bucket.description':
|
||||
'Menelusuri rekamanmu untuk mencari tempat-tempat yang ingin kamu capai. Sebuah kunjungan butuh kedekatan sekaligus waktu yang dihabiskan — sekadar lewat tidak dihitung.',
|
||||
'dawarich.bucket.scan': 'Periksa daftar keinginan',
|
||||
'dawarich.bucket.scanning': 'Memeriksa…',
|
||||
'dawarich.bucket.noMatches': 'Tidak ada isi daftar keinginanmu yang muncul di rekamanmu.',
|
||||
'dawarich.bucket.alreadyVisited': 'Sudah dicoret',
|
||||
'dawarich.bucket.confirm': 'Coret {count}',
|
||||
'dawarich.bucket.confirmed': '{count} keinginan dicoret',
|
||||
'dawarich.bucket.skipped': '{count} entri tidak punya koordinat dan tidak dapat diperiksa.',
|
||||
'dawarich.bucket.truncated': 'Hanya entri pertama yang diperiksa. Jalankan lagi untuk sisanya.',
|
||||
'dawarich.bucket.visitedFrom': 'Dicoret dari rekaman Dawarich milikmu',
|
||||
'dawarich.bucket.clearVisit': 'Batalkan',
|
||||
|
||||
// ── Atlas ──────────────────────────────────────────────────────────────────
|
||||
'dawarich.atlas.title': 'Negara dari Dawarich',
|
||||
'dawarich.atlas.description':
|
||||
'Negara-negara yang menurut rekamanmu pernah kamu singgahi. Konfirmasi mana yang kamu inginkan di Atlas-mu — tidak ada yang ditambahkan dengan sendirinya, dan yang kamu tandai sendiri tetap milikmu.',
|
||||
'dawarich.atlas.load': 'Cari negara',
|
||||
'dawarich.atlas.loading': 'Membaca rekamanmu…',
|
||||
'dawarich.atlas.empty': 'Rekamanmu tidak menunjukkan negara yang belum dimiliki TREK.',
|
||||
'dawarich.atlas.cities': '{count} kota',
|
||||
'dawarich.atlas.citiesOne': '1 kota',
|
||||
'dawarich.atlas.accept': 'Tambah {count} negara',
|
||||
'dawarich.atlas.accepted': '{count} negara ditambahkan',
|
||||
'dawarich.atlas.unresolved': 'TREK tidak dapat mencocokkan ini dengan sebuah negara: {names}.',
|
||||
'dawarich.atlas.source': 'Dari Dawarich',
|
||||
'dawarich.atlas.range': 'Ditinjau dari {from} sampai {to}',
|
||||
|
||||
'dawarich.atlas.trigger': 'Keinginan dan negara dari rekamanmu',
|
||||
'dawarich.atlas.dialogSubtitle': 'Apa kata rekamanmu tentang Atlas-mu',
|
||||
'dawarich.atlas.tab.wishes': 'Daftar keinginan',
|
||||
'dawarich.atlas.tab.countries': 'Negara',
|
||||
'dawarich.atlas.window': 'Yang diperiksa adalah 12 bulan terakhir.',
|
||||
'dawarich.selected': '{count} dipilih',
|
||||
'dawarich.again': 'Periksa lagi',
|
||||
'dawarich.bucket.metersAway': '{meters} m dari sana',
|
||||
'dawarich.bucket.kilometersAway': '{km} km dari sana',
|
||||
'dawarich.bucket.rule': 'Sebuah keinginan dianggap tercapai dalam radius {meters} m dan setelah {minutes} menit di lokasi.',
|
||||
|
||||
};
|
||||
|
||||
export default dawarich;
|
||||
@@ -1,4 +1,5 @@
|
||||
import admin from './admin';
|
||||
import dawarich from './dawarich';
|
||||
import airport from './airport';
|
||||
import atlas from './atlas';
|
||||
import backup from './backup';
|
||||
@@ -106,5 +107,6 @@ const locale = {
|
||||
...mobileSettings,
|
||||
...mobileCollections,
|
||||
...storage,
|
||||
...dawarich,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const dawarich: TranslationStrings = {
|
||||
// ── Connection ─────────────────────────────────────────────────────────────
|
||||
'dawarich.title': 'Dawarich',
|
||||
'dawarich.intro':
|
||||
'Collega la tua istanza Dawarich per vedere dove sei stato davvero. TREK la legge e propone voci di diario, luoghi e paesi — nulla viene aggiunto finché non lo confermi e nulla viene riscritto su Dawarich.',
|
||||
'dawarich.url': 'Indirizzo dell’istanza',
|
||||
'dawarich.apiKey': 'Chiave API',
|
||||
'dawarich.apiKeyPlaceholder': 'Incolla la tua chiave API di Dawarich',
|
||||
'dawarich.apiKeyHint':
|
||||
'La trovi in Dawarich sotto Account → Chiave API. Viene salvata cifrata e non viene più mostrata.',
|
||||
'dawarich.allowInsecureTls': 'Consenti certificato autofirmato',
|
||||
'dawarich.allowInsecureTlsHint':
|
||||
'Serve solo se la tua istanza usa un certificato di cui il tuo server non si fida.',
|
||||
'dawarich.syncEnabled': 'Cerca automaticamente nuove soste',
|
||||
'dawarich.syncEnabledHint': 'Se disattivato, TREK legge Dawarich solo quando glielo chiedi.',
|
||||
'dawarich.test.button': 'Prova la connessione',
|
||||
'dawarich.test.success': 'Connesso. {count} soste trovate negli ultimi 30 giorni.',
|
||||
'dawarich.test.failed': 'Impossibile raggiungere Dawarich.',
|
||||
'dawarich.syncNow': 'Controlla ora',
|
||||
'dawarich.connected': 'Connesso',
|
||||
'dawarich.notConnected': 'Non connesso',
|
||||
'dawarich.disconnect': 'Disconnetti',
|
||||
'dawarich.lastSync': 'Ultimo controllo {when}',
|
||||
'dawarich.neverSynced': 'Non ancora controllato',
|
||||
'dawarich.syncPartial': 'alcuni viaggi non sono stati letti',
|
||||
'dawarich.serverVersion': 'Dawarich {version}',
|
||||
|
||||
'dawarich.toast.saved': 'Connessione a Dawarich salvata',
|
||||
'dawarich.toast.saveError': 'Impossibile salvare la connessione',
|
||||
'dawarich.toast.disconnected': 'Dawarich disconnesso',
|
||||
'dawarich.toast.synced': '{count} nuove soste trovate',
|
||||
'dawarich.toast.syncError': 'Impossibile leggere Dawarich',
|
||||
'dawarich.toast.syncRunning': 'Un controllo è già in corso',
|
||||
'dawarich.toast.acceptError': 'Impossibile aggiungerlo',
|
||||
'dawarich.toast.updateError': 'Impossibile aggiornare questo suggerimento',
|
||||
'dawarich.toast.accepted.place': 'Aggiunto al viaggio',
|
||||
'dawarich.toast.accepted.journal': 'Aggiunto al diario',
|
||||
'dawarich.toast.accepted.bucket_list': 'Spuntato dalla tua lista dei desideri',
|
||||
|
||||
// ── What the connected instance can do ─────────────────────────────────────
|
||||
'dawarich.capability.visits': 'soste',
|
||||
'dawarich.capability.track': 'percorso registrato',
|
||||
'dawarich.capability.locations': 'confronto con la lista dei desideri',
|
||||
'dawarich.capability.visitedCities': 'paesi e città',
|
||||
'dawarich.capability.missing': 'Questa versione di Dawarich non offre: {features}.',
|
||||
|
||||
// ── Failure reasons, as sentences the reader can act on ────────────────────
|
||||
'dawarich.error.unreachable': 'TREK non è riuscito a raggiungere quell’indirizzo.',
|
||||
'dawarich.error.unauthorized': 'Dawarich ha rifiutato la chiave API.',
|
||||
'dawarich.error.forbidden': 'Quella chiave API non è autorizzata a leggere questi dati.',
|
||||
'dawarich.error.not_found': 'Questa versione di Dawarich non ha quell’endpoint.',
|
||||
'dawarich.error.rate_limited': 'Dawarich ha chiesto a TREK di rallentare. Riprova tra poco.',
|
||||
'dawarich.error.server_error': 'Dawarich ha risposto con un errore.',
|
||||
'dawarich.error.invalid_response': 'Quell’indirizzo ha risposto con qualcosa che non è Dawarich.',
|
||||
'dawarich.error.too_large': 'Dawarich ha inviato più dati di quanti TREK ne legga in una volta.',
|
||||
'dawarich.error.not_connected': 'Nessuna istanza Dawarich è ancora collegata.',
|
||||
'dawarich.error.addon_disabled': 'Il componente aggiuntivo Dawarich è disattivato su questa istanza.',
|
||||
'dawarich.error.offline': 'Serve una connessione: TREK al momento è offline.',
|
||||
'dawarich.error.invalid_url': 'TREK non può usare questo indirizzo.',
|
||||
'dawarich.warning.private_ip': 'Questo indirizzo punta a un IP privato ({ip}). Verifica che sia voluto: il server potrebbe richiedere ALLOW_INTERNAL_NETWORK=true per raggiungerlo.',
|
||||
'dawarich.error.unknown': 'Qualcosa è andato storto nella comunicazione con Dawarich.',
|
||||
|
||||
// ── The recorded route on the map ──────────────────────────────────────────
|
||||
'dawarich.trail.show': 'Mostra il percorso registrato',
|
||||
'dawarich.trail.hide': 'Nascondi il percorso registrato',
|
||||
'dawarich.trail.loading': 'Caricamento del percorso registrato…',
|
||||
'dawarich.trail.empty': 'In queste date non è stato registrato nulla',
|
||||
'dawarich.trail.offline': 'Il percorso registrato richiede una connessione',
|
||||
'dawarich.trail.unavailable': 'Impossibile caricare il percorso registrato',
|
||||
|
||||
// ── Suggestions ────────────────────────────────────────────────────────────
|
||||
'dawarich.duration.minutes': '{minutes} min',
|
||||
'dawarich.duration.hours': '{hours} h',
|
||||
'dawarich.duration.hoursMinutes': '{hours} h {minutes} min',
|
||||
'dawarich.checkedAgo': 'verificato {ago}',
|
||||
|
||||
'dawarich.badge.lowConfidence': 'Incerto',
|
||||
'dawarich.badge.sourceChanged': 'Cambiato in Dawarich',
|
||||
'dawarich.badge.sourceMissing': 'Sparito da Dawarich',
|
||||
|
||||
'dawarich.suggestions.title': 'Da Dawarich',
|
||||
'dawarich.suggestions.pending': '{count} in attesa di te',
|
||||
'dawarich.suggestions.loading': 'Lettura di Dawarich…',
|
||||
'dawarich.suggestions.notConnected':
|
||||
'Collega Dawarich nelle Impostazioni per vedere qui le tue soste.',
|
||||
'dawarich.suggestions.unavailable': 'Impossibile leggere Dawarich.',
|
||||
'dawarich.suggestions.allHandled': 'Tutto ciò che è registrato qui è già stato gestito.',
|
||||
'dawarich.suggestions.asJournal': 'Scrivi una voce di diario',
|
||||
'dawarich.suggestions.asPlace': 'Aggiungi come luogo',
|
||||
'dawarich.suggestions.dismiss': 'Non è un luogo che ho visitato',
|
||||
'dawarich.suggestions.dismissed': 'Ignorato',
|
||||
'dawarich.suggestions.restore': 'Ripristina',
|
||||
'dawarich.suggestions.showHandled': 'Mostra {count} già gestiti',
|
||||
'dawarich.suggestions.hideHandled': 'Nascondi quelli già gestiti',
|
||||
'dawarich.suggestions.matchesWish': 'Nella tua lista dei desideri: {name}',
|
||||
'dawarich.suggestions.acceptedAs.place': 'Aggiunto come luogo',
|
||||
'dawarich.suggestions.acceptedAs.journal': 'Nel diario',
|
||||
'dawarich.suggestions.acceptedAs.bucket_list': 'Desiderio spuntato',
|
||||
'dawarich.suggestions.sourceChanged':
|
||||
'Questa sosta è cambiata in Dawarich da quando l’hai usata. Quello che hai scritto in TREK resta intatto.',
|
||||
'dawarich.suggestions.sourceMissing':
|
||||
'Questa sosta non esiste più in Dawarich. Quello che hai scritto in TREK resta intatto.',
|
||||
'dawarich.sourceStatus.suggested': 'Rilevata, non confermata',
|
||||
'dawarich.confidence.high': 'Rilevamento sicuro',
|
||||
'dawarich.confidence.medium': 'Rilevamento abbastanza sicuro',
|
||||
'dawarich.confidence.low': 'Rilevamento incerto',
|
||||
|
||||
// ── The review step ────────────────────────────────────────────────────────
|
||||
'dawarich.accept.title.place': 'Aggiungi questa sosta come luogo',
|
||||
'dawarich.accept.title.journal': 'Scrivi una voce di diario',
|
||||
'dawarich.accept.title.bucket_list': 'Spunta un desiderio',
|
||||
'dawarich.accept.confirm.place': 'Aggiungi luogo',
|
||||
'dawarich.accept.confirm.journal': 'Aggiungi voce',
|
||||
'dawarich.accept.confirm.bucket_list': 'Spuntalo',
|
||||
'dawarich.accept.recorded': 'Registrata dal {from} al {to}',
|
||||
'dawarich.accept.duration': '{minutes} min',
|
||||
'dawarich.accept.name': 'Nome',
|
||||
'dawarich.accept.date': 'Data',
|
||||
'dawarich.accept.from': 'Arrivo',
|
||||
'dawarich.accept.to': 'Partenza',
|
||||
'dawarich.accept.trip': 'Viaggio',
|
||||
'dawarich.accept.thisTrip': 'Questo viaggio',
|
||||
'dawarich.accept.pickTrip': 'Scegli un viaggio',
|
||||
'dawarich.accept.day': 'Giorno',
|
||||
'dawarich.accept.noDay': 'Non ancora assegnata a un giorno',
|
||||
'dawarich.accept.journal': 'Diario',
|
||||
'dawarich.accept.pickJournal': 'Scegli un diario',
|
||||
'dawarich.accept.notes': 'Note',
|
||||
'dawarich.accept.story': 'La tua storia',
|
||||
'dawarich.accept.storyPlaceholder': 'Cosa è successo qui?',
|
||||
'dawarich.accept.photosHint': 'Aggiungi le foto alla voce dopo averla creata.',
|
||||
|
||||
// ── A place that came out of a recording ──────────────────────────────────
|
||||
'dawarich.place.fromDawarich': 'Aggiunto dalle tue registrazioni Dawarich',
|
||||
|
||||
// ── Wishlist ───────────────────────────────────────────────────────────────
|
||||
'dawarich.bucket.title': 'Confronta la tua lista dei desideri con Dawarich',
|
||||
'dawarich.bucket.description':
|
||||
'Cerca nelle tue registrazioni i luoghi che volevi raggiungere. Una visita richiede sia la vicinanza sia il tempo trascorso — passarci davanti in auto non conta.',
|
||||
'dawarich.bucket.scan': 'Controlla la lista dei desideri',
|
||||
'dawarich.bucket.scanning': 'Controllo in corso…',
|
||||
'dawarich.bucket.noMatches': 'Nelle tue registrazioni non è emerso nulla della tua lista dei desideri.',
|
||||
'dawarich.bucket.alreadyVisited': 'Già spuntato',
|
||||
'dawarich.bucket.confirm': 'Spunta {count}',
|
||||
'dawarich.bucket.confirmed': '{count} desideri spuntati',
|
||||
'dawarich.bucket.skipped': '{count} voci non hanno coordinate e non sono state controllate.',
|
||||
'dawarich.bucket.truncated':
|
||||
'Sono state controllate solo le prime voci. Esegui di nuovo il controllo per le altre.',
|
||||
'dawarich.bucket.visitedFrom': 'Spuntato dalle tue registrazioni Dawarich',
|
||||
'dawarich.bucket.clearVisit': 'Annulla',
|
||||
|
||||
// ── Atlas ──────────────────────────────────────────────────────────────────
|
||||
'dawarich.atlas.title': 'Paesi da Dawarich',
|
||||
'dawarich.atlas.description':
|
||||
'I paesi in cui, secondo le tue registrazioni, sei stato. Conferma quelli che vuoi nel tuo Atlante — nulla viene aggiunto da solo e ciò che hai segnato a mano resta tuo.',
|
||||
'dawarich.atlas.load': 'Cerca i paesi',
|
||||
'dawarich.atlas.loading': 'Lettura delle tue registrazioni…',
|
||||
'dawarich.atlas.empty': 'Le tue registrazioni non mostrano paesi che TREK non abbia già.',
|
||||
'dawarich.atlas.cities': '{count} città',
|
||||
'dawarich.atlas.citiesOne': '1 città',
|
||||
'dawarich.atlas.accept': 'Aggiungi {count} paesi',
|
||||
'dawarich.atlas.accepted': '{count} paesi aggiunti',
|
||||
'dawarich.atlas.unresolved': 'TREK non è riuscito ad abbinare questi a un paese: {names}.',
|
||||
'dawarich.atlas.source': 'Da Dawarich',
|
||||
'dawarich.atlas.range': 'Esaminato dal {from} al {to}',
|
||||
|
||||
'dawarich.atlas.trigger': 'Desideri e paesi dalle tue registrazioni',
|
||||
'dawarich.atlas.dialogSubtitle': 'Cosa dicono le tue registrazioni sul tuo Atlas',
|
||||
'dawarich.atlas.tab.wishes': 'Lista dei desideri',
|
||||
'dawarich.atlas.tab.countries': 'Paesi',
|
||||
'dawarich.atlas.window': 'Sono stati esaminati gli ultimi 12 mesi.',
|
||||
'dawarich.selected': '{count} selezionati',
|
||||
'dawarich.again': 'Controlla di nuovo',
|
||||
'dawarich.bucket.metersAway': 'a {meters} m',
|
||||
'dawarich.bucket.kilometersAway': 'a {km} km',
|
||||
'dawarich.bucket.rule': 'Un desiderio è raggiunto entro {meters} m e dopo {minutes} minuti sul posto.',
|
||||
|
||||
};
|
||||
|
||||
export default dawarich;
|
||||
@@ -1,4 +1,5 @@
|
||||
import admin from './admin';
|
||||
import dawarich from './dawarich';
|
||||
import airport from './airport';
|
||||
import atlas from './atlas';
|
||||
import backup from './backup';
|
||||
@@ -106,5 +107,6 @@ const locale = {
|
||||
...mobileSettings,
|
||||
...mobileCollections,
|
||||
...storage,
|
||||
...dawarich,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const dawarich: TranslationStrings = {
|
||||
// ── Connection ─────────────────────────────────────────────────────────────
|
||||
'dawarich.title': 'Dawarich',
|
||||
'dawarich.intro':
|
||||
'自分の Dawarich インスタンスを接続すると、実際に訪れた場所がわかります。TREK はそれを読み取り、日記のエントリー、場所、国を提案します。確認するまで何も追加されず、Dawarich に書き戻すこともありません。',
|
||||
'dawarich.url': 'インスタンスのアドレス',
|
||||
'dawarich.apiKey': 'API キー',
|
||||
'dawarich.apiKeyPlaceholder': 'Dawarich の API キーを貼り付け',
|
||||
'dawarich.apiKeyHint':
|
||||
'Dawarich の「Account → API key」にあります。暗号化して保存され、以後は表示されません。',
|
||||
'dawarich.allowInsecureTls': '自己署名証明書を許可',
|
||||
'dawarich.allowInsecureTlsHint':
|
||||
'サーバーが信頼していない証明書をインスタンスが使っている場合にのみ必要です。',
|
||||
'dawarich.syncEnabled': '新しい滞在を自動で確認',
|
||||
'dawarich.syncEnabledHint': 'オフにすると、TREK は指示したときだけ Dawarich を読み取ります。',
|
||||
'dawarich.test.button': '接続をテスト',
|
||||
'dawarich.test.success': '接続しました。過去30日間で{count}件の滞在が見つかりました。',
|
||||
'dawarich.test.failed': 'Dawarich に接続できませんでした。',
|
||||
'dawarich.syncNow': '今すぐ確認',
|
||||
'dawarich.connected': '接続済み',
|
||||
'dawarich.notConnected': '未接続',
|
||||
'dawarich.disconnect': '接続解除',
|
||||
'dawarich.lastSync': '最終確認:{when}',
|
||||
'dawarich.neverSynced': 'まだ確認していません',
|
||||
'dawarich.syncPartial': '一部の旅行を読み取れませんでした',
|
||||
'dawarich.serverVersion': 'Dawarich {version}',
|
||||
|
||||
'dawarich.toast.saved': 'Dawarich の接続を保存しました',
|
||||
'dawarich.toast.saveError': '接続を保存できませんでした',
|
||||
'dawarich.toast.disconnected': 'Dawarich の接続を解除しました',
|
||||
'dawarich.toast.synced': '{count}件の新しい滞在が見つかりました',
|
||||
'dawarich.toast.syncError': 'Dawarich を読み取れませんでした',
|
||||
'dawarich.toast.syncRunning': 'すでに確認中です',
|
||||
'dawarich.toast.acceptError': '追加できませんでした',
|
||||
'dawarich.toast.updateError': 'この提案を更新できませんでした',
|
||||
'dawarich.toast.accepted.place': '旅行に追加しました',
|
||||
'dawarich.toast.accepted.journal': '日記に追加しました',
|
||||
'dawarich.toast.accepted.bucket_list': 'ウィッシュリストにチェックを付けました',
|
||||
|
||||
// ── What the connected instance can do ─────────────────────────────────────
|
||||
'dawarich.capability.visits': '滞在',
|
||||
'dawarich.capability.track': '記録されたルート',
|
||||
'dawarich.capability.locations': 'ウィッシュリストの照合',
|
||||
'dawarich.capability.visitedCities': '国と都市',
|
||||
'dawarich.capability.missing': 'この Dawarich のバージョンでは利用できません:{features}。',
|
||||
|
||||
// ── Failure reasons, as sentences the reader can act on ────────────────────
|
||||
'dawarich.error.unreachable': 'TREK はそのアドレスに接続できませんでした。',
|
||||
'dawarich.error.unauthorized': 'Dawarich が API キーを拒否しました。',
|
||||
'dawarich.error.forbidden': 'その API キーにはこれを読み取る権限がありません。',
|
||||
'dawarich.error.not_found': 'この Dawarich のバージョンにはそのエンドポイントがありません。',
|
||||
'dawarich.error.rate_limited':
|
||||
'Dawarich からリクエストを控えるよう求められました。しばらくしてからお試しください。',
|
||||
'dawarich.error.server_error': 'Dawarich がエラーを返しました。',
|
||||
'dawarich.error.invalid_response': 'そのアドレスからの応答は Dawarich のものではありません。',
|
||||
'dawarich.error.too_large': 'Dawarich が、TREK が一度に読み取れる量を超えるデータを返しました。',
|
||||
'dawarich.error.not_connected': 'Dawarich インスタンスはまだ接続されていません。',
|
||||
'dawarich.error.addon_disabled': 'このインスタンスでは Dawarich アドオンが無効になっています。',
|
||||
'dawarich.error.offline': 'これには接続が必要です。TREK は現在オフラインです。',
|
||||
'dawarich.error.invalid_url': 'TREK はこのアドレスを使用できません。',
|
||||
'dawarich.warning.private_ip': 'このアドレスはプライベート IP ({ip}) を指しています。意図した設定かご確認ください。サーバー側に ALLOW_INTERNAL_NETWORK=true が必要な場合があります。',
|
||||
'dawarich.error.unknown': 'Dawarich との通信中に問題が発生しました。',
|
||||
|
||||
// ── The recorded route on the map ──────────────────────────────────────────
|
||||
'dawarich.trail.show': '記録されたルートを表示',
|
||||
'dawarich.trail.hide': '記録されたルートを非表示',
|
||||
'dawarich.trail.loading': '記録されたルートを読み込み中…',
|
||||
'dawarich.trail.empty': 'この日付には記録がありません',
|
||||
'dawarich.trail.offline': '記録されたルートの表示には接続が必要です',
|
||||
'dawarich.trail.unavailable': '記録されたルートを読み込めませんでした',
|
||||
|
||||
// ── Suggestions ────────────────────────────────────────────────────────────
|
||||
'dawarich.duration.minutes': '{minutes}分',
|
||||
'dawarich.duration.hours': '{hours}時間',
|
||||
'dawarich.duration.hoursMinutes': '{hours}時間{minutes}分',
|
||||
'dawarich.checkedAgo': '{ago}に確認',
|
||||
|
||||
'dawarich.badge.lowConfidence': '不確か',
|
||||
'dawarich.badge.sourceChanged': 'Dawarich で変更',
|
||||
'dawarich.badge.sourceMissing': 'Dawarich から消失',
|
||||
|
||||
'dawarich.suggestions.title': 'Dawarich から',
|
||||
'dawarich.suggestions.pending': '{count}件が未処理',
|
||||
'dawarich.suggestions.loading': 'Dawarich を読み取り中…',
|
||||
'dawarich.suggestions.notConnected':
|
||||
'設定で Dawarich を接続すると、ここに滞在が表示されます。',
|
||||
'dawarich.suggestions.unavailable': 'Dawarich を読み取れませんでした。',
|
||||
'dawarich.suggestions.allHandled': 'ここに記録されたものはすべて処理済みです。',
|
||||
'dawarich.suggestions.asJournal': '日記を書く',
|
||||
'dawarich.suggestions.asPlace': '場所として追加',
|
||||
'dawarich.suggestions.dismiss': '訪れていない場所',
|
||||
'dawarich.suggestions.dismissed': '却下済み',
|
||||
'dawarich.suggestions.restore': '戻す',
|
||||
'dawarich.suggestions.showHandled': '処理済みの{count}件を表示',
|
||||
'dawarich.suggestions.hideHandled': '処理済みのものを非表示',
|
||||
'dawarich.suggestions.matchesWish': 'ウィッシュリストにあります:{name}',
|
||||
'dawarich.suggestions.acceptedAs.place': '場所として追加済み',
|
||||
'dawarich.suggestions.acceptedAs.journal': '日記に記載済み',
|
||||
'dawarich.suggestions.acceptedAs.bucket_list': 'ウィッシュにチェック済み',
|
||||
'dawarich.suggestions.sourceChanged':
|
||||
'この滞在は、使用したあとに Dawarich 側で変更されました。TREK に書いた内容はそのままです。',
|
||||
'dawarich.suggestions.sourceMissing':
|
||||
'この滞在は Dawarich にもう存在しません。TREK に書いた内容はそのままです。',
|
||||
'dawarich.sourceStatus.suggested': '検出済み・未確認',
|
||||
'dawarich.confidence.high': '確度の高い検出',
|
||||
'dawarich.confidence.medium': 'ある程度確かな検出',
|
||||
'dawarich.confidence.low': '不確かな検出',
|
||||
|
||||
// ── The review step ────────────────────────────────────────────────────────
|
||||
'dawarich.accept.title.place': 'この滞在を場所として追加',
|
||||
'dawarich.accept.title.journal': '日記を書く',
|
||||
'dawarich.accept.title.bucket_list': 'ウィッシュにチェックを付ける',
|
||||
'dawarich.accept.confirm.place': '場所を追加',
|
||||
'dawarich.accept.confirm.journal': 'エントリーを追加',
|
||||
'dawarich.accept.confirm.bucket_list': 'チェックを付ける',
|
||||
'dawarich.accept.recorded': '記録:{from}から{to}まで',
|
||||
'dawarich.accept.duration': '{minutes}分',
|
||||
'dawarich.accept.name': '名前',
|
||||
'dawarich.accept.date': '日付',
|
||||
'dawarich.accept.from': '到着',
|
||||
'dawarich.accept.to': '出発',
|
||||
'dawarich.accept.trip': '旅行',
|
||||
'dawarich.accept.thisTrip': 'この旅行',
|
||||
'dawarich.accept.pickTrip': '旅行を選択',
|
||||
'dawarich.accept.day': '日',
|
||||
'dawarich.accept.noDay': 'まだ日付が未設定',
|
||||
'dawarich.accept.journal': '日記',
|
||||
'dawarich.accept.pickJournal': '日記を選択',
|
||||
'dawarich.accept.notes': 'メモ',
|
||||
'dawarich.accept.story': 'あなたのストーリー',
|
||||
'dawarich.accept.storyPlaceholder': 'ここで何がありましたか?',
|
||||
'dawarich.accept.photosHint': '写真は、エントリーを作成したあとに追加できます。',
|
||||
|
||||
// ── A place that came out of a recording ──────────────────────────────────
|
||||
'dawarich.place.fromDawarich': 'Dawarich の記録から追加されました',
|
||||
|
||||
// ── Wishlist ───────────────────────────────────────────────────────────────
|
||||
'dawarich.bucket.title': 'ウィッシュリストを Dawarich と照合',
|
||||
'dawarich.bucket.description':
|
||||
'行きたかった場所を記録の中から探します。訪問と見なすには、近さと滞在時間の両方が必要です。通り過ぎただけでは含まれません。',
|
||||
'dawarich.bucket.scan': 'ウィッシュリストを照合',
|
||||
'dawarich.bucket.scanning': '照合中…',
|
||||
'dawarich.bucket.noMatches': 'ウィッシュリストの項目は記録の中に見つかりませんでした。',
|
||||
'dawarich.bucket.alreadyVisited': 'チェック済み',
|
||||
'dawarich.bucket.confirm': '{count}件にチェックを付ける',
|
||||
'dawarich.bucket.confirmed': '{count}件のウィッシュにチェックを付けました',
|
||||
'dawarich.bucket.skipped': '{count}件は座標がないため照合できませんでした。',
|
||||
'dawarich.bucket.truncated':
|
||||
'最初の項目のみ照合しました。残りはもう一度実行してください。',
|
||||
'dawarich.bucket.visitedFrom': 'Dawarich の記録からチェックを付けました',
|
||||
'dawarich.bucket.clearVisit': '元に戻す',
|
||||
|
||||
// ── Atlas ──────────────────────────────────────────────────────────────────
|
||||
'dawarich.atlas.title': 'Dawarich からの国',
|
||||
'dawarich.atlas.description':
|
||||
'記録から滞在したとみられる国です。Atlas に入れたいものを確認してください。自動では追加されず、手動で付けた印もそのまま残ります。',
|
||||
'dawarich.atlas.load': '国を探す',
|
||||
'dawarich.atlas.loading': '記録を読み取り中…',
|
||||
'dawarich.atlas.empty': '記録の中に、TREK にまだない国はありませんでした。',
|
||||
'dawarich.atlas.cities': '{count}都市',
|
||||
'dawarich.atlas.citiesOne': '1 都市',
|
||||
'dawarich.atlas.accept': '{count}か国を追加',
|
||||
'dawarich.atlas.accepted': '{count}か国を追加しました',
|
||||
'dawarich.atlas.unresolved': 'TREK はこれらを国と照合できませんでした:{names}。',
|
||||
'dawarich.atlas.source': 'Dawarich から',
|
||||
'dawarich.atlas.range': '{from}から{to}までを対象',
|
||||
|
||||
'dawarich.atlas.trigger': '記録から見つかった願いと国',
|
||||
'dawarich.atlas.dialogSubtitle': '記録があなたの Atlas について語ること',
|
||||
'dawarich.atlas.tab.wishes': 'ウィッシュリスト',
|
||||
'dawarich.atlas.tab.countries': '国',
|
||||
'dawarich.atlas.window': '直近12か月を確認しました。',
|
||||
'dawarich.selected': '{count}件を選択中',
|
||||
'dawarich.again': 'もう一度確認',
|
||||
'dawarich.bucket.metersAway': '{meters} m 先',
|
||||
'dawarich.bucket.kilometersAway': '{km} km 先',
|
||||
'dawarich.bucket.rule': '{meters} m 以内に {minutes} 分以上滞在すると、願いがかなったとみなします。',
|
||||
|
||||
};
|
||||
|
||||
export default dawarich;
|
||||
@@ -1,4 +1,5 @@
|
||||
import admin from './admin';
|
||||
import dawarich from './dawarich';
|
||||
import airport from './airport';
|
||||
import atlas from './atlas';
|
||||
import backup from './backup';
|
||||
@@ -106,5 +107,6 @@ const locale = {
|
||||
...mobileSettings,
|
||||
...mobileCollections,
|
||||
...storage,
|
||||
...dawarich,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const dawarich: TranslationStrings = {
|
||||
// ── Connection ─────────────────────────────────────────────────────────────
|
||||
'dawarich.title': 'Dawarich',
|
||||
'dawarich.intro':
|
||||
'직접 운영하는 Dawarich 인스턴스를 연결하면 실제로 어디에 다녀왔는지 확인할 수 있습니다. TREK은 기록을 읽어 일기, 장소, 국가를 제안합니다. 확인하기 전까지는 아무것도 추가되지 않으며, Dawarich에 다시 기록되는 내용도 없습니다.',
|
||||
'dawarich.url': '인스턴스 주소',
|
||||
'dawarich.apiKey': 'API 키',
|
||||
'dawarich.apiKeyPlaceholder': 'Dawarich API 키를 붙여넣으세요',
|
||||
'dawarich.apiKeyHint': 'Dawarich의 계정 → API 키에서 확인할 수 있습니다. 암호화하여 저장되며 다시 표시되지 않습니다.',
|
||||
'dawarich.allowInsecureTls': '자체 서명 인증서 허용',
|
||||
'dawarich.allowInsecureTlsHint': '인스턴스가 서버에서 신뢰하지 않는 인증서를 사용할 때만 필요합니다.',
|
||||
'dawarich.syncEnabled': '새 방문 기록을 자동으로 확인',
|
||||
'dawarich.syncEnabledHint': '끄면 요청할 때만 TREK이 Dawarich를 읽습니다.',
|
||||
'dawarich.test.button': '연결 테스트',
|
||||
'dawarich.test.success': '연결되었습니다. 최근 30일 동안 방문 기록 {count}건을 찾았습니다.',
|
||||
'dawarich.test.failed': 'Dawarich에 연결할 수 없습니다.',
|
||||
'dawarich.syncNow': '지금 확인',
|
||||
'dawarich.connected': '연결됨',
|
||||
'dawarich.notConnected': '연결되지 않음',
|
||||
'dawarich.disconnect': '연결 해제',
|
||||
'dawarich.lastSync': '마지막 확인 {when}',
|
||||
'dawarich.neverSynced': '아직 확인하지 않았습니다',
|
||||
'dawarich.syncPartial': '일부 여행은 읽지 못했습니다',
|
||||
'dawarich.serverVersion': 'Dawarich {version}',
|
||||
|
||||
'dawarich.toast.saved': 'Dawarich 연결이 저장되었습니다',
|
||||
'dawarich.toast.saveError': '연결을 저장할 수 없습니다',
|
||||
'dawarich.toast.disconnected': 'Dawarich 연결이 해제되었습니다',
|
||||
'dawarich.toast.synced': '새 방문 기록 {count}건을 찾았습니다',
|
||||
'dawarich.toast.syncError': 'Dawarich를 읽을 수 없습니다',
|
||||
'dawarich.toast.syncRunning': '이미 확인 중입니다',
|
||||
'dawarich.toast.acceptError': '추가할 수 없습니다',
|
||||
'dawarich.toast.updateError': '이 제안을 업데이트할 수 없습니다',
|
||||
'dawarich.toast.accepted.place': '여행에 추가했습니다',
|
||||
'dawarich.toast.accepted.journal': '일기에 추가했습니다',
|
||||
'dawarich.toast.accepted.bucket_list': '위시리스트에서 완료 처리했습니다',
|
||||
|
||||
// ── What the connected instance can do ─────────────────────────────────────
|
||||
'dawarich.capability.visits': '방문 기록',
|
||||
'dawarich.capability.track': '기록된 경로',
|
||||
'dawarich.capability.locations': '위시리스트 대조',
|
||||
'dawarich.capability.visitedCities': '국가 및 도시',
|
||||
'dawarich.capability.missing': '이 Dawarich 버전에서는 다음 기능을 사용할 수 없습니다: {features}.',
|
||||
|
||||
// ── Failure reasons, as sentences the reader can act on ────────────────────
|
||||
'dawarich.error.unreachable': 'TREK이 해당 주소에 연결하지 못했습니다.',
|
||||
'dawarich.error.unauthorized': 'Dawarich가 API 키를 거부했습니다.',
|
||||
'dawarich.error.forbidden': '이 API 키에는 해당 내용을 읽을 권한이 없습니다.',
|
||||
'dawarich.error.not_found': '이 Dawarich 버전에는 해당 엔드포인트가 없습니다.',
|
||||
'dawarich.error.rate_limited': 'Dawarich가 요청 속도를 낮춰 달라고 응답했습니다. 잠시 후 다시 시도하세요.',
|
||||
'dawarich.error.server_error': 'Dawarich가 오류로 응답했습니다.',
|
||||
'dawarich.error.invalid_response': '해당 주소가 Dawarich가 아닌 응답을 보냈습니다.',
|
||||
'dawarich.error.too_large': 'Dawarich가 TREK이 한 번에 읽을 수 있는 양보다 많은 데이터를 보냈습니다.',
|
||||
'dawarich.error.not_connected': '아직 연결된 Dawarich 인스턴스가 없습니다.',
|
||||
'dawarich.error.addon_disabled': '이 인스턴스에서는 Dawarich 애드온이 꺼져 있습니다.',
|
||||
'dawarich.error.offline': '연결이 필요합니다 — TREK이 지금 오프라인입니다.',
|
||||
'dawarich.error.invalid_url': 'TREK은 이 주소를 사용할 수 없습니다.',
|
||||
'dawarich.warning.private_ip': '이 주소는 사설 IP({ip})를 가리킵니다. 의도한 설정인지 확인하세요. 서버에 ALLOW_INTERNAL_NETWORK=true가 필요할 수 있습니다.',
|
||||
'dawarich.error.unknown': 'Dawarich와 통신하는 중 문제가 발생했습니다.',
|
||||
|
||||
// ── The recorded route on the map ──────────────────────────────────────────
|
||||
'dawarich.trail.show': '기록된 경로 표시',
|
||||
'dawarich.trail.hide': '기록된 경로 숨기기',
|
||||
'dawarich.trail.loading': '기록된 경로를 불러오는 중…',
|
||||
'dawarich.trail.empty': '해당 날짜에 기록된 내용이 없습니다',
|
||||
'dawarich.trail.offline': '기록된 경로를 보려면 연결이 필요합니다',
|
||||
'dawarich.trail.unavailable': '기록된 경로를 불러올 수 없습니다',
|
||||
|
||||
// ── Suggestions ────────────────────────────────────────────────────────────
|
||||
'dawarich.duration.minutes': '{minutes}분',
|
||||
'dawarich.duration.hours': '{hours}시간',
|
||||
'dawarich.duration.hoursMinutes': '{hours}시간 {minutes}분',
|
||||
'dawarich.checkedAgo': '{ago} 확인함',
|
||||
|
||||
'dawarich.badge.lowConfidence': '불확실',
|
||||
'dawarich.badge.sourceChanged': 'Dawarich에서 변경됨',
|
||||
'dawarich.badge.sourceMissing': 'Dawarich에서 사라짐',
|
||||
|
||||
'dawarich.suggestions.title': 'Dawarich에서',
|
||||
'dawarich.suggestions.pending': '{count}건이 기다리고 있습니다',
|
||||
'dawarich.suggestions.loading': 'Dawarich를 읽는 중…',
|
||||
'dawarich.suggestions.notConnected': '설정에서 Dawarich를 연결하면 방문 기록을 여기서 볼 수 있습니다.',
|
||||
'dawarich.suggestions.unavailable': 'Dawarich를 읽을 수 없습니다.',
|
||||
'dawarich.suggestions.allHandled': '여기에 기록된 항목은 모두 처리했습니다.',
|
||||
'dawarich.suggestions.asJournal': '일기 작성',
|
||||
'dawarich.suggestions.asPlace': '장소로 추가',
|
||||
'dawarich.suggestions.dismiss': '방문한 장소가 아님',
|
||||
'dawarich.suggestions.dismissed': '제외됨',
|
||||
'dawarich.suggestions.restore': '되돌리기',
|
||||
'dawarich.suggestions.showHandled': '처리한 {count}건 표시',
|
||||
'dawarich.suggestions.hideHandled': '처리한 항목 숨기기',
|
||||
'dawarich.suggestions.matchesWish': '위시리스트에 있음: {name}',
|
||||
'dawarich.suggestions.acceptedAs.place': '장소로 추가됨',
|
||||
'dawarich.suggestions.acceptedAs.journal': '일기에 있음',
|
||||
'dawarich.suggestions.acceptedAs.bucket_list': '위시리스트 완료됨',
|
||||
'dawarich.suggestions.sourceChanged':
|
||||
'이 방문 기록은 사용한 뒤 Dawarich에서 변경되었습니다. TREK에 작성한 내용은 그대로 유지됩니다.',
|
||||
'dawarich.suggestions.sourceMissing':
|
||||
'이 방문 기록은 더 이상 Dawarich에 없습니다. TREK에 작성한 내용은 그대로 유지됩니다.',
|
||||
'dawarich.sourceStatus.suggested': '감지됨, 미확인',
|
||||
'dawarich.confidence.high': '확실한 감지',
|
||||
'dawarich.confidence.medium': '비교적 확실한 감지',
|
||||
'dawarich.confidence.low': '불확실한 감지',
|
||||
|
||||
// ── The review step ────────────────────────────────────────────────────────
|
||||
'dawarich.accept.title.place': '이 방문 기록을 장소로 추가',
|
||||
'dawarich.accept.title.journal': '일기 작성',
|
||||
'dawarich.accept.title.bucket_list': '위시리스트 완료 처리',
|
||||
'dawarich.accept.confirm.place': '장소 추가',
|
||||
'dawarich.accept.confirm.journal': '항목 추가',
|
||||
'dawarich.accept.confirm.bucket_list': '완료 처리',
|
||||
'dawarich.accept.recorded': '{from}부터 {to}까지 기록됨',
|
||||
'dawarich.accept.duration': '{minutes}분',
|
||||
'dawarich.accept.name': '이름',
|
||||
'dawarich.accept.date': '날짜',
|
||||
'dawarich.accept.from': '도착',
|
||||
'dawarich.accept.to': '출발',
|
||||
'dawarich.accept.trip': '여행',
|
||||
'dawarich.accept.thisTrip': '이 여행',
|
||||
'dawarich.accept.pickTrip': '여행 선택',
|
||||
'dawarich.accept.day': '일자',
|
||||
'dawarich.accept.noDay': '아직 일자에 배정되지 않음',
|
||||
'dawarich.accept.journal': 'Journey',
|
||||
'dawarich.accept.pickJournal': 'Journey 선택',
|
||||
'dawarich.accept.notes': '메모',
|
||||
'dawarich.accept.story': '나의 이야기',
|
||||
'dawarich.accept.storyPlaceholder': '여기서 어떤 일이 있었나요?',
|
||||
'dawarich.accept.photosHint': '항목을 만든 뒤에 사진을 추가하세요.',
|
||||
|
||||
// ── A place that came out of a recording ──────────────────────────────────
|
||||
'dawarich.place.fromDawarich': 'Dawarich 기록에서 추가됨',
|
||||
|
||||
// ── Wishlist ───────────────────────────────────────────────────────────────
|
||||
'dawarich.bucket.title': '위시리스트를 Dawarich와 대조하기',
|
||||
'dawarich.bucket.description':
|
||||
'가고 싶었던 장소를 기록에서 찾아봅니다. 방문으로 인정하려면 충분히 가까이 있었고 머문 시간도 있어야 합니다. 지나가기만 한 것은 포함되지 않습니다.',
|
||||
'dawarich.bucket.scan': '위시리스트 확인',
|
||||
'dawarich.bucket.scanning': '확인하는 중…',
|
||||
'dawarich.bucket.noMatches': '기록에서 위시리스트에 있는 장소를 찾지 못했습니다.',
|
||||
'dawarich.bucket.alreadyVisited': '이미 완료 처리됨',
|
||||
'dawarich.bucket.confirm': '{count}건 완료 처리',
|
||||
'dawarich.bucket.confirmed': '위시리스트 {count}건을 완료 처리했습니다',
|
||||
'dawarich.bucket.skipped': '{count}건은 좌표가 없어 확인하지 못했습니다.',
|
||||
'dawarich.bucket.truncated': '앞부분 항목만 확인했습니다. 나머지는 다시 실행하세요.',
|
||||
'dawarich.bucket.visitedFrom': 'Dawarich 기록을 바탕으로 완료 처리됨',
|
||||
'dawarich.bucket.clearVisit': '실행 취소',
|
||||
|
||||
// ── Atlas ──────────────────────────────────────────────────────────────────
|
||||
'dawarich.atlas.title': 'Dawarich에서 가져온 국가',
|
||||
'dawarich.atlas.description':
|
||||
'기록에 따르면 다녀온 것으로 보이는 국가입니다. Atlas에 넣고 싶은 국가를 확인해 주세요. 저절로 추가되는 것은 없으며, 직접 표시한 내용도 그대로 유지됩니다.',
|
||||
'dawarich.atlas.load': '국가 찾기',
|
||||
'dawarich.atlas.loading': '기록을 읽는 중…',
|
||||
'dawarich.atlas.empty': '기록에는 TREK에 아직 없는 국가가 없습니다.',
|
||||
'dawarich.atlas.cities': '도시 {count}곳',
|
||||
'dawarich.atlas.citiesOne': '도시 1곳',
|
||||
'dawarich.atlas.accept': '국가 {count}곳 추가',
|
||||
'dawarich.atlas.accepted': '국가 {count}곳을 추가했습니다',
|
||||
'dawarich.atlas.unresolved': 'TREK이 다음 항목을 국가와 연결하지 못했습니다: {names}.',
|
||||
'dawarich.atlas.source': 'Dawarich에서',
|
||||
'dawarich.atlas.range': '{from}부터 {to}까지 확인함',
|
||||
|
||||
'dawarich.atlas.trigger': '기록에서 찾은 위시리스트와 국가',
|
||||
'dawarich.atlas.dialogSubtitle': '기록이 당신의 Atlas에 대해 말해 주는 것',
|
||||
'dawarich.atlas.tab.wishes': '위시리스트',
|
||||
'dawarich.atlas.tab.countries': '국가',
|
||||
'dawarich.atlas.window': '최근 12개월을 확인했습니다.',
|
||||
'dawarich.selected': '{count}개 선택됨',
|
||||
'dawarich.again': '다시 확인',
|
||||
'dawarich.bucket.metersAway': '{meters} m 거리',
|
||||
'dawarich.bucket.kilometersAway': '{km} km 거리',
|
||||
'dawarich.bucket.rule': '{meters} m 이내에서 {minutes}분 이상 머무르면 소원을 이룬 것으로 봅니다.',
|
||||
|
||||
};
|
||||
|
||||
export default dawarich;
|
||||
@@ -1,4 +1,5 @@
|
||||
import admin from './admin';
|
||||
import dawarich from './dawarich';
|
||||
import airport from './airport';
|
||||
import atlas from './atlas';
|
||||
import backup from './backup';
|
||||
@@ -106,5 +107,6 @@ const locale = {
|
||||
...mobileSettings,
|
||||
...mobileCollections,
|
||||
...storage,
|
||||
...dawarich,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const dawarich: TranslationStrings = {
|
||||
// ── Connection ─────────────────────────────────────────────────────────────
|
||||
'dawarich.title': 'Dawarich',
|
||||
'dawarich.intro':
|
||||
'Koppel je eigen Dawarich-instantie om te zien waar je echt bent geweest. TREK leest hem uit en stelt dagboekvermeldingen, plaatsen en landen voor — er wordt niets toegevoegd voordat jij het bevestigt, en er wordt niets teruggeschreven naar Dawarich.',
|
||||
'dawarich.url': 'Adres van de instantie',
|
||||
'dawarich.apiKey': 'API-sleutel',
|
||||
'dawarich.apiKeyPlaceholder': 'Plak je Dawarich-API-sleutel',
|
||||
'dawarich.apiKeyHint':
|
||||
'Te vinden in Dawarich onder Account → API key. Wordt versleuteld opgeslagen en nooit meer getoond.',
|
||||
'dawarich.allowInsecureTls': 'Zelfondertekend certificaat toestaan',
|
||||
'dawarich.allowInsecureTlsHint':
|
||||
'Alleen nodig als je instantie een certificaat gebruikt dat je server niet vertrouwt.',
|
||||
'dawarich.syncEnabled': 'Automatisch op nieuwe bezoeken controleren',
|
||||
'dawarich.syncEnabledHint': 'Uit betekent dat TREK Dawarich alleen uitleest als jij erom vraagt.',
|
||||
'dawarich.test.button': 'Verbinding testen',
|
||||
'dawarich.test.success': 'Verbonden. {count} bezoeken gevonden in de laatste 30 dagen.',
|
||||
'dawarich.test.failed': 'Kon Dawarich niet bereiken.',
|
||||
'dawarich.syncNow': 'Nu controleren',
|
||||
'dawarich.connected': 'Verbonden',
|
||||
'dawarich.notConnected': 'Niet verbonden',
|
||||
'dawarich.disconnect': 'Verbinding verbreken',
|
||||
'dawarich.lastSync': 'Laatst gecontroleerd {when}',
|
||||
'dawarich.neverSynced': 'Nog niet gecontroleerd',
|
||||
'dawarich.syncPartial': 'sommige reizen konden niet worden gelezen',
|
||||
'dawarich.serverVersion': 'Dawarich {version}',
|
||||
|
||||
'dawarich.toast.saved': 'Dawarich-verbinding opgeslagen',
|
||||
'dawarich.toast.saveError': 'Kon de verbinding niet opslaan',
|
||||
'dawarich.toast.disconnected': 'Dawarich losgekoppeld',
|
||||
'dawarich.toast.synced': '{count} nieuwe bezoeken gevonden',
|
||||
'dawarich.toast.syncError': 'Kon Dawarich niet uitlezen',
|
||||
'dawarich.toast.syncRunning': 'Er loopt al een controle',
|
||||
'dawarich.toast.acceptError': 'Kon dit niet toevoegen',
|
||||
'dawarich.toast.updateError': 'Kon deze suggestie niet bijwerken',
|
||||
'dawarich.toast.accepted.place': 'Toegevoegd aan de reis',
|
||||
'dawarich.toast.accepted.journal': 'Toegevoegd aan het reisverslag',
|
||||
'dawarich.toast.accepted.bucket_list': 'Afgevinkt op je wensenlijst',
|
||||
|
||||
// ── What the connected instance can do ─────────────────────────────────────
|
||||
'dawarich.capability.visits': 'bezoeken',
|
||||
'dawarich.capability.track': 'opgenomen route',
|
||||
'dawarich.capability.locations': 'wensenlijst vergelijken',
|
||||
'dawarich.capability.visitedCities': 'landen en steden',
|
||||
'dawarich.capability.missing': 'Deze versie van Dawarich biedt niet: {features}.',
|
||||
|
||||
// ── Failure reasons, as sentences the reader can act on ────────────────────
|
||||
'dawarich.error.unreachable': 'TREK kon dat adres niet bereiken.',
|
||||
'dawarich.error.unauthorized': 'Dawarich heeft de API-sleutel geweigerd.',
|
||||
'dawarich.error.forbidden': 'Die API-sleutel mag dit niet lezen.',
|
||||
'dawarich.error.not_found': 'Deze versie van Dawarich heeft dat eindpunt niet.',
|
||||
'dawarich.error.rate_limited': 'Dawarich vroeg TREK om het rustiger aan te doen. Probeer het zo meteen opnieuw.',
|
||||
'dawarich.error.server_error': 'Dawarich antwoordde met een fout.',
|
||||
'dawarich.error.invalid_response': 'Dat adres antwoordde met iets dat geen Dawarich is.',
|
||||
'dawarich.error.too_large': 'Dawarich stuurde meer gegevens dan TREK in één keer leest.',
|
||||
'dawarich.error.not_connected': 'Er is nog geen Dawarich-instantie verbonden.',
|
||||
'dawarich.error.addon_disabled': 'De Dawarich-add-on staat uit voor deze instantie.',
|
||||
'dawarich.error.offline': 'Hiervoor is een verbinding nodig — TREK is nu offline.',
|
||||
'dawarich.error.invalid_url': 'TREK kan dit adres niet gebruiken.',
|
||||
'dawarich.warning.private_ip': 'Dit adres wijst naar een privé-IP ({ip}). Controleer of dat de bedoeling is — de server heeft er mogelijk ALLOW_INTERNAL_NETWORK=true voor nodig.',
|
||||
'dawarich.error.unknown': 'Er ging iets mis in het contact met Dawarich.',
|
||||
|
||||
// ── The recorded route on the map ──────────────────────────────────────────
|
||||
'dawarich.trail.show': 'Opgenomen route tonen',
|
||||
'dawarich.trail.hide': 'Opgenomen route verbergen',
|
||||
'dawarich.trail.loading': 'De opgenomen route wordt geladen…',
|
||||
'dawarich.trail.empty': 'Op deze datums is er niets opgenomen',
|
||||
'dawarich.trail.offline': 'De opgenomen route heeft een verbinding nodig',
|
||||
'dawarich.trail.unavailable': 'De opgenomen route kon niet worden geladen',
|
||||
|
||||
// ── Suggestions ────────────────────────────────────────────────────────────
|
||||
'dawarich.duration.minutes': '{minutes} min',
|
||||
'dawarich.duration.hours': '{hours} u',
|
||||
'dawarich.duration.hoursMinutes': '{hours} u {minutes} min',
|
||||
'dawarich.checkedAgo': 'gecontroleerd {ago}',
|
||||
|
||||
'dawarich.badge.lowConfidence': 'Onzeker',
|
||||
'dawarich.badge.sourceChanged': 'Gewijzigd in Dawarich',
|
||||
'dawarich.badge.sourceMissing': 'Weg uit Dawarich',
|
||||
|
||||
'dawarich.suggestions.title': 'Uit Dawarich',
|
||||
'dawarich.suggestions.pending': '{count} wachten op je',
|
||||
'dawarich.suggestions.loading': 'Dawarich wordt uitgelezen…',
|
||||
'dawarich.suggestions.notConnected': 'Koppel Dawarich in Instellingen om je bezoeken hier te zien.',
|
||||
'dawarich.suggestions.unavailable': 'Dawarich kon niet worden uitgelezen.',
|
||||
'dawarich.suggestions.allHandled': 'Alles wat hier is opgenomen, is afgehandeld.',
|
||||
'dawarich.suggestions.asJournal': 'Een dagboekvermelding schrijven',
|
||||
'dawarich.suggestions.asPlace': 'Als plaats toevoegen',
|
||||
'dawarich.suggestions.dismiss': 'Hier ben ik niet geweest',
|
||||
'dawarich.suggestions.dismissed': 'Weggelegd',
|
||||
'dawarich.suggestions.restore': 'Terugzetten',
|
||||
'dawarich.suggestions.showHandled': '{count} al afgehandelde tonen',
|
||||
'dawarich.suggestions.hideHandled': 'De al afgehandelde verbergen',
|
||||
'dawarich.suggestions.matchesWish': 'Op je wensenlijst: {name}',
|
||||
'dawarich.suggestions.acceptedAs.place': 'Toegevoegd als plaats',
|
||||
'dawarich.suggestions.acceptedAs.journal': 'In het reisverslag',
|
||||
'dawarich.suggestions.acceptedAs.bucket_list': 'Wens afgevinkt',
|
||||
'dawarich.suggestions.sourceChanged':
|
||||
'Dit bezoek is in Dawarich gewijzigd sinds je het hebt gebruikt. Wat je in TREK hebt geschreven, blijft onaangeroerd.',
|
||||
'dawarich.suggestions.sourceMissing':
|
||||
'Dit bezoek bestaat niet meer in Dawarich. Wat je in TREK hebt geschreven, blijft onaangeroerd.',
|
||||
'dawarich.sourceStatus.suggested': 'Herkend, niet bevestigd',
|
||||
'dawarich.confidence.high': 'Zekere herkenning',
|
||||
'dawarich.confidence.medium': 'Redelijk zekere herkenning',
|
||||
'dawarich.confidence.low': 'Onzekere herkenning',
|
||||
|
||||
// ── The review step ────────────────────────────────────────────────────────
|
||||
'dawarich.accept.title.place': 'Dit bezoek als plaats toevoegen',
|
||||
'dawarich.accept.title.journal': 'Een dagboekvermelding schrijven',
|
||||
'dawarich.accept.title.bucket_list': 'Een wens afvinken',
|
||||
'dawarich.accept.confirm.place': 'Plaats toevoegen',
|
||||
'dawarich.accept.confirm.journal': 'Vermelding toevoegen',
|
||||
'dawarich.accept.confirm.bucket_list': 'Afvinken',
|
||||
'dawarich.accept.recorded': 'Opgenomen van {from} tot {to}',
|
||||
'dawarich.accept.duration': '{minutes} min',
|
||||
'dawarich.accept.name': 'Naam',
|
||||
'dawarich.accept.date': 'Datum',
|
||||
'dawarich.accept.from': 'Aangekomen',
|
||||
'dawarich.accept.to': 'Vertrokken',
|
||||
'dawarich.accept.trip': 'Reis',
|
||||
'dawarich.accept.thisTrip': 'Deze reis',
|
||||
'dawarich.accept.pickTrip': 'Kies een reis',
|
||||
'dawarich.accept.day': 'Dag',
|
||||
'dawarich.accept.noDay': 'Nog niet op een dag',
|
||||
'dawarich.accept.journal': 'Reisverslag',
|
||||
'dawarich.accept.pickJournal': 'Kies een reisverslag',
|
||||
'dawarich.accept.notes': 'Notities',
|
||||
'dawarich.accept.story': 'Jouw verhaal',
|
||||
'dawarich.accept.storyPlaceholder': 'Wat is hier gebeurd?',
|
||||
'dawarich.accept.photosHint': "Voeg foto's toe aan de vermelding nadat die is aangemaakt.",
|
||||
|
||||
// ── A place that came out of a recording ──────────────────────────────────
|
||||
'dawarich.place.fromDawarich': 'Toegevoegd uit je Dawarich-opnames',
|
||||
|
||||
// ── Wishlist ───────────────────────────────────────────────────────────────
|
||||
'dawarich.bucket.title': 'Je wensenlijst naast Dawarich leggen',
|
||||
'dawarich.bucket.description':
|
||||
'Doorzoekt je opnames op de plaatsen die je wilde bereiken. Voor een bezoek tellen zowel nabijheid als verblijfsduur — voorbijrijden telt niet.',
|
||||
'dawarich.bucket.scan': 'Wensenlijst controleren',
|
||||
'dawarich.bucket.scanning': 'Bezig met controleren…',
|
||||
'dawarich.bucket.noMatches': 'Niets van je wensenlijst kwam in je opnames voor.',
|
||||
'dawarich.bucket.alreadyVisited': 'Al afgevinkt',
|
||||
'dawarich.bucket.confirm': '{count} afvinken',
|
||||
'dawarich.bucket.confirmed': '{count} wensen afgevinkt',
|
||||
'dawarich.bucket.skipped': '{count} vermeldingen hebben geen coördinaten en konden niet worden gecontroleerd.',
|
||||
'dawarich.bucket.truncated': 'Alleen de eerste vermeldingen zijn gecontroleerd. Voer het opnieuw uit voor de rest.',
|
||||
'dawarich.bucket.visitedFrom': 'Afgevinkt op basis van je Dawarich-opnames',
|
||||
'dawarich.bucket.clearVisit': 'Ongedaan maken',
|
||||
|
||||
// ── Atlas ──────────────────────────────────────────────────────────────────
|
||||
'dawarich.atlas.title': 'Landen uit Dawarich',
|
||||
'dawarich.atlas.description':
|
||||
'Landen waar je volgens je opnames bent geweest. Bevestig degene die je in je Atlas wilt — er wordt niets uit zichzelf toegevoegd, en wat je met de hand hebt aangevinkt, blijft van jou.',
|
||||
'dawarich.atlas.load': 'Naar landen zoeken',
|
||||
'dawarich.atlas.loading': 'Je opnames worden uitgelezen…',
|
||||
'dawarich.atlas.empty': 'Je opnames tonen geen landen die TREK nog niet heeft.',
|
||||
'dawarich.atlas.cities': '{count} steden',
|
||||
'dawarich.atlas.citiesOne': '1 stad',
|
||||
'dawarich.atlas.accept': '{count} landen toevoegen',
|
||||
'dawarich.atlas.accepted': '{count} landen toegevoegd',
|
||||
'dawarich.atlas.unresolved': 'TREK kon deze niet aan een land koppelen: {names}.',
|
||||
'dawarich.atlas.source': 'Uit Dawarich',
|
||||
'dawarich.atlas.range': 'Gekeken van {from} tot {to}',
|
||||
|
||||
'dawarich.atlas.trigger': 'Wensen en landen uit je opnames',
|
||||
'dawarich.atlas.dialogSubtitle': 'Wat je opnames over je Atlas zeggen',
|
||||
'dawarich.atlas.tab.wishes': 'Wensenlijst',
|
||||
'dawarich.atlas.tab.countries': 'Landen',
|
||||
'dawarich.atlas.window': 'De laatste 12 maanden zijn bekeken.',
|
||||
'dawarich.selected': '{count} geselecteerd',
|
||||
'dawarich.again': 'Opnieuw controleren',
|
||||
'dawarich.bucket.metersAway': '{meters} m verderop',
|
||||
'dawarich.bucket.kilometersAway': '{km} km verderop',
|
||||
'dawarich.bucket.rule': 'Een wens telt als bereikt binnen {meters} m en na {minutes} minuten ter plaatse.',
|
||||
|
||||
};
|
||||
|
||||
export default dawarich;
|
||||
@@ -1,4 +1,5 @@
|
||||
import admin from './admin';
|
||||
import dawarich from './dawarich';
|
||||
import airport from './airport';
|
||||
import atlas from './atlas';
|
||||
import backup from './backup';
|
||||
@@ -106,5 +107,6 @@ const locale = {
|
||||
...mobileSettings,
|
||||
...mobileCollections,
|
||||
...storage,
|
||||
...dawarich,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const dawarich: TranslationStrings = {
|
||||
// ── Connection ─────────────────────────────────────────────────────────────
|
||||
'dawarich.title': 'Dawarich',
|
||||
'dawarich.intro':
|
||||
'Połącz własną instancję Dawarich, aby zobaczyć, gdzie naprawdę byłeś. TREK ją odczytuje i proponuje wpisy w dzienniku, miejsca i kraje — nic nie zostanie dodane, dopóki tego nie potwierdzisz, i nic nie jest zapisywane z powrotem do Dawarich.',
|
||||
'dawarich.url': 'Adres instancji',
|
||||
'dawarich.apiKey': 'Klucz API',
|
||||
'dawarich.apiKeyPlaceholder': 'Wklej swój klucz API Dawarich',
|
||||
'dawarich.apiKeyHint': 'Znajdziesz go w Dawarich w Account → API key. Przechowywany w postaci zaszyfrowanej i nigdy więcej niepokazywany.',
|
||||
'dawarich.allowInsecureTls': 'Zezwalaj na certyfikat z własnym podpisem',
|
||||
'dawarich.allowInsecureTlsHint': 'Potrzebne tylko wtedy, gdy Twoja instancja używa certyfikatu, któremu Twój serwer nie ufa.',
|
||||
'dawarich.syncEnabled': 'Sprawdzaj nowe pobyty automatycznie',
|
||||
'dawarich.syncEnabledHint': 'Gdy wyłączone, TREK odczytuje Dawarich tylko na Twoje polecenie.',
|
||||
'dawarich.test.button': 'Testuj połączenie',
|
||||
'dawarich.test.success': 'Połączono. Znaleziono {count} pobytów z ostatnich 30 dni.',
|
||||
'dawarich.test.failed': 'Nie udało się połączyć z Dawarich.',
|
||||
'dawarich.syncNow': 'Sprawdź teraz',
|
||||
'dawarich.connected': 'Połączono',
|
||||
'dawarich.notConnected': 'Nie połączono',
|
||||
'dawarich.disconnect': 'Rozłącz',
|
||||
'dawarich.lastSync': 'Ostatnio sprawdzono {when}',
|
||||
'dawarich.neverSynced': 'Jeszcze nie sprawdzono',
|
||||
'dawarich.syncPartial': 'niektórych podróży nie udało się odczytać',
|
||||
'dawarich.serverVersion': 'Dawarich {version}',
|
||||
|
||||
'dawarich.toast.saved': 'Zapisano połączenie z Dawarich',
|
||||
'dawarich.toast.saveError': 'Nie udało się zapisać połączenia',
|
||||
'dawarich.toast.disconnected': 'Rozłączono Dawarich',
|
||||
'dawarich.toast.synced': 'Znaleziono {count} nowych pobytów',
|
||||
'dawarich.toast.syncError': 'Nie udało się odczytać Dawarich',
|
||||
'dawarich.toast.syncRunning': 'Sprawdzanie już trwa',
|
||||
'dawarich.toast.acceptError': 'Nie udało się tego dodać',
|
||||
'dawarich.toast.updateError': 'Nie udało się zaktualizować tej sugestii',
|
||||
'dawarich.toast.accepted.place': 'Dodano do podróży',
|
||||
'dawarich.toast.accepted.journal': 'Dodano do dziennika',
|
||||
'dawarich.toast.accepted.bucket_list': 'Odhaczono na liście marzeń',
|
||||
|
||||
// ── What the connected instance can do ─────────────────────────────────────
|
||||
'dawarich.capability.visits': 'pobyty',
|
||||
'dawarich.capability.track': 'zapisana trasa',
|
||||
'dawarich.capability.locations': 'dopasowanie do listy marzeń',
|
||||
'dawarich.capability.visitedCities': 'kraje i miasta',
|
||||
'dawarich.capability.missing': 'Ta wersja Dawarich nie udostępnia: {features}.',
|
||||
|
||||
// ── Failure reasons, as sentences the reader can act on ────────────────────
|
||||
'dawarich.error.unreachable': 'TREK nie mógł połączyć się z tym adresem.',
|
||||
'dawarich.error.unauthorized': 'Dawarich odrzucił ten klucz API.',
|
||||
'dawarich.error.forbidden': 'Ten klucz API nie ma prawa tego odczytać.',
|
||||
'dawarich.error.not_found': 'Ta wersja Dawarich nie ma takiego punktu końcowego.',
|
||||
'dawarich.error.rate_limited': 'Dawarich poprosił TREK o zwolnienie tempa. Spróbuj ponownie za chwilę.',
|
||||
'dawarich.error.server_error': 'Dawarich odpowiedział błędem.',
|
||||
'dawarich.error.invalid_response': 'Ten adres odpowiedział czymś, co nie jest Dawarich.',
|
||||
'dawarich.error.too_large': 'Dawarich przysłał więcej danych, niż TREK odczyta za jednym razem.',
|
||||
'dawarich.error.not_connected': 'Nie połączono jeszcze żadnej instancji Dawarich.',
|
||||
'dawarich.error.addon_disabled': 'Dodatek Dawarich jest wyłączony w tej instancji.',
|
||||
'dawarich.error.offline': 'To wymaga połączenia — TREK jest teraz offline.',
|
||||
'dawarich.error.invalid_url': 'TREK nie może użyć tego adresu.',
|
||||
'dawarich.warning.private_ip': 'Ten adres wskazuje na prywatny IP ({ip}). Sprawdź, czy o to chodziło — serwer może potrzebować ALLOW_INTERNAL_NETWORK=true, aby go osiągnąć.',
|
||||
'dawarich.error.unknown': 'Coś poszło nie tak w komunikacji z Dawarich.',
|
||||
|
||||
// ── The recorded route on the map ──────────────────────────────────────────
|
||||
'dawarich.trail.show': 'Pokaż zapisaną trasę',
|
||||
'dawarich.trail.hide': 'Ukryj zapisaną trasę',
|
||||
'dawarich.trail.loading': 'Wczytywanie zapisanej trasy…',
|
||||
'dawarich.trail.empty': 'W tych dniach nic nie zostało zapisane',
|
||||
'dawarich.trail.offline': 'Zapisana trasa wymaga połączenia',
|
||||
'dawarich.trail.unavailable': 'Nie udało się wczytać zapisanej trasy',
|
||||
|
||||
// ── Suggestions ────────────────────────────────────────────────────────────
|
||||
'dawarich.duration.minutes': '{minutes} min',
|
||||
'dawarich.duration.hours': '{hours} godz.',
|
||||
'dawarich.duration.hoursMinutes': '{hours} godz. {minutes} min',
|
||||
'dawarich.checkedAgo': 'sprawdzono {ago}',
|
||||
|
||||
'dawarich.badge.lowConfidence': 'Niepewne',
|
||||
'dawarich.badge.sourceChanged': 'Zmienione w Dawarichu',
|
||||
'dawarich.badge.sourceMissing': 'Zniknęło z Dawaricha',
|
||||
|
||||
'dawarich.suggestions.title': 'Z Dawarich',
|
||||
'dawarich.suggestions.pending': '{count} czeka na Ciebie',
|
||||
'dawarich.suggestions.loading': 'Odczytywanie Dawarich…',
|
||||
'dawarich.suggestions.notConnected': 'Połącz Dawarich w Ustawieniach, aby zobaczyć tutaj swoje pobyty.',
|
||||
'dawarich.suggestions.unavailable': 'Nie udało się odczytać Dawarich.',
|
||||
'dawarich.suggestions.allHandled': 'Wszystko, co tu zapisano, zostało już obsłużone.',
|
||||
'dawarich.suggestions.asJournal': 'Napisz wpis w dzienniku',
|
||||
'dawarich.suggestions.asPlace': 'Dodaj jako miejsce',
|
||||
'dawarich.suggestions.dismiss': 'To nie jest miejsce, w którym byłem',
|
||||
'dawarich.suggestions.dismissed': 'Odrzucono',
|
||||
'dawarich.suggestions.restore': 'Przywróć',
|
||||
'dawarich.suggestions.showHandled': 'Pokaż {count} już obsłużonych',
|
||||
'dawarich.suggestions.hideHandled': 'Ukryj już obsłużone',
|
||||
'dawarich.suggestions.matchesWish': 'Na Twojej liście marzeń: {name}',
|
||||
'dawarich.suggestions.acceptedAs.place': 'Dodano jako miejsce',
|
||||
'dawarich.suggestions.acceptedAs.journal': 'W dzienniku',
|
||||
'dawarich.suggestions.acceptedAs.bucket_list': 'Marzenie odhaczone',
|
||||
'dawarich.suggestions.sourceChanged':
|
||||
'Ten pobyt zmienił się w Dawarich, odkąd go użyłeś. To, co napisałeś w TREK, pozostaje nietknięte.',
|
||||
'dawarich.suggestions.sourceMissing':
|
||||
'Ten pobyt już nie istnieje w Dawarich. To, co napisałeś w TREK, pozostaje nietknięte.',
|
||||
'dawarich.sourceStatus.suggested': 'Wykryte, niepotwierdzone',
|
||||
'dawarich.confidence.high': 'Pewne wykrycie',
|
||||
'dawarich.confidence.medium': 'Dość pewne wykrycie',
|
||||
'dawarich.confidence.low': 'Niepewne wykrycie',
|
||||
|
||||
// ── The review step ────────────────────────────────────────────────────────
|
||||
'dawarich.accept.title.place': 'Dodaj ten pobyt jako miejsce',
|
||||
'dawarich.accept.title.journal': 'Napisz wpis w dzienniku',
|
||||
'dawarich.accept.title.bucket_list': 'Odhacz marzenie',
|
||||
'dawarich.accept.confirm.place': 'Dodaj miejsce',
|
||||
'dawarich.accept.confirm.journal': 'Dodaj wpis',
|
||||
'dawarich.accept.confirm.bucket_list': 'Odhacz',
|
||||
'dawarich.accept.recorded': 'Zapisano od {from} do {to}',
|
||||
'dawarich.accept.duration': '{minutes} min',
|
||||
'dawarich.accept.name': 'Nazwa',
|
||||
'dawarich.accept.date': 'Data',
|
||||
'dawarich.accept.from': 'Przyjazd',
|
||||
'dawarich.accept.to': 'Wyjazd',
|
||||
'dawarich.accept.trip': 'Podróż',
|
||||
'dawarich.accept.thisTrip': 'Ta podróż',
|
||||
'dawarich.accept.pickTrip': 'Wybierz podróż',
|
||||
'dawarich.accept.day': 'Dzień',
|
||||
'dawarich.accept.noDay': 'Jeszcze bez przypisanego dnia',
|
||||
'dawarich.accept.journal': 'Dziennik',
|
||||
'dawarich.accept.pickJournal': 'Wybierz dziennik',
|
||||
'dawarich.accept.notes': 'Notatki',
|
||||
'dawarich.accept.story': 'Twoja historia',
|
||||
'dawarich.accept.storyPlaceholder': 'Co się tu wydarzyło?',
|
||||
'dawarich.accept.photosHint': 'Zdjęcia dodasz do wpisu po jego utworzeniu.',
|
||||
|
||||
// ── A place that came out of a recording ──────────────────────────────────
|
||||
'dawarich.place.fromDawarich': 'Dodane z Twoich zapisów Dawarich',
|
||||
|
||||
// ── Wishlist ───────────────────────────────────────────────────────────────
|
||||
'dawarich.bucket.title': 'Porównaj swoją listę marzeń z Dawarich',
|
||||
'dawarich.bucket.description':
|
||||
'Przeszukuje Twoje zapisy pod kątem miejsc, do których chciałeś dotrzeć. Wizyta wymaga zarówno bliskości, jak i spędzonego czasu — przejazd obok się nie liczy.',
|
||||
'dawarich.bucket.scan': 'Sprawdź listę marzeń',
|
||||
'dawarich.bucket.scanning': 'Sprawdzanie…',
|
||||
'dawarich.bucket.noMatches': 'Nic z Twojej listy marzeń nie pojawiło się w Twoich zapisach.',
|
||||
'dawarich.bucket.alreadyVisited': 'Już odhaczone',
|
||||
'dawarich.bucket.confirm': 'Odhacz {count}',
|
||||
'dawarich.bucket.confirmed': 'Odhaczono marzenia: {count}',
|
||||
'dawarich.bucket.skipped': '{count} pozycji nie ma współrzędnych i nie dało się ich sprawdzić.',
|
||||
'dawarich.bucket.truncated': 'Sprawdzono tylko pierwsze pozycje. Uruchom ponownie, aby sprawdzić resztę.',
|
||||
'dawarich.bucket.visitedFrom': 'Odhaczone na podstawie Twoich zapisów z Dawarich',
|
||||
'dawarich.bucket.clearVisit': 'Cofnij',
|
||||
|
||||
// ── Atlas ──────────────────────────────────────────────────────────────────
|
||||
'dawarich.atlas.title': 'Kraje z Dawarich',
|
||||
'dawarich.atlas.description':
|
||||
'Kraje, w których według Twoich zapisów byłeś. Potwierdź te, które mają trafić do Twojego Atlasu — nic nie jest dodawane samo, a to, co zaznaczyłeś ręcznie, pozostaje Twoje.',
|
||||
'dawarich.atlas.load': 'Szukaj krajów',
|
||||
'dawarich.atlas.loading': 'Odczytywanie Twoich zapisów…',
|
||||
'dawarich.atlas.empty': 'Twoje zapisy nie pokazują krajów, których TREK jeszcze nie ma.',
|
||||
'dawarich.atlas.cities': 'miast: {count}',
|
||||
'dawarich.atlas.citiesOne': '1 miasto',
|
||||
'dawarich.atlas.accept': 'Dodaj kraje: {count}',
|
||||
'dawarich.atlas.accepted': 'Dodano kraje: {count}',
|
||||
'dawarich.atlas.unresolved': 'TREK nie mógł dopasować tych nazw do kraju: {names}.',
|
||||
'dawarich.atlas.source': 'Z Dawarich',
|
||||
'dawarich.atlas.range': 'Sprawdzono od {from} do {to}',
|
||||
|
||||
'dawarich.atlas.trigger': 'Marzenia i kraje z Twoich zapisów',
|
||||
'dawarich.atlas.dialogSubtitle': 'Co Twoje zapisy mówią o Twoim Atlasie',
|
||||
'dawarich.atlas.tab.wishes': 'Lista marzeń',
|
||||
'dawarich.atlas.tab.countries': 'Kraje',
|
||||
'dawarich.atlas.window': 'Sprawdzono ostatnie 12 miesięcy.',
|
||||
'dawarich.selected': 'Wybrano: {count}',
|
||||
'dawarich.again': 'Sprawdź ponownie',
|
||||
'dawarich.bucket.metersAway': '{meters} m stąd',
|
||||
'dawarich.bucket.kilometersAway': '{km} km stąd',
|
||||
'dawarich.bucket.rule': 'Marzenie uznaje się za spełnione w promieniu {meters} m i po {minutes} minutach na miejscu.',
|
||||
|
||||
};
|
||||
|
||||
export default dawarich;
|
||||
@@ -1,4 +1,5 @@
|
||||
import admin from './admin';
|
||||
import dawarich from './dawarich';
|
||||
import airport from './airport';
|
||||
import atlas from './atlas';
|
||||
import backup from './backup';
|
||||
@@ -106,5 +107,6 @@ const locale = {
|
||||
...mobileSettings,
|
||||
...mobileCollections,
|
||||
...storage,
|
||||
...dawarich,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const dawarich: TranslationStrings = {
|
||||
// ── Connection ─────────────────────────────────────────────────────────────
|
||||
'dawarich.title': 'Dawarich',
|
||||
'dawarich.intro':
|
||||
'Подключите свой экземпляр Dawarich, чтобы видеть, где вы действительно побывали. TREK читает его и предлагает записи дневника, места и страны — ничего не добавляется без вашего подтверждения, и в Dawarich ничего не записывается.',
|
||||
'dawarich.url': 'Адрес экземпляра',
|
||||
'dawarich.apiKey': 'Ключ API',
|
||||
'dawarich.apiKeyPlaceholder': 'Вставьте свой ключ API Dawarich',
|
||||
'dawarich.apiKeyHint': 'Находится в Dawarich в разделе «Аккаунт → Ключ API». Хранится в зашифрованном виде и больше не показывается.',
|
||||
'dawarich.allowInsecureTls': 'Разрешить самоподписанный сертификат',
|
||||
'dawarich.allowInsecureTlsHint': 'Нужно только в том случае, если ваш экземпляр использует сертификат, которому ваш сервер не доверяет.',
|
||||
'dawarich.syncEnabled': 'Автоматически проверять новые остановки',
|
||||
'dawarich.syncEnabledHint': 'Если выключено, TREK читает Dawarich только по вашему запросу.',
|
||||
'dawarich.test.button': 'Проверить подключение',
|
||||
'dawarich.test.success': 'Подключено. За последние 30 дней найдено остановок: {count}.',
|
||||
'dawarich.test.failed': 'Не удалось связаться с Dawarich.',
|
||||
'dawarich.syncNow': 'Проверить сейчас',
|
||||
'dawarich.connected': 'Подключено',
|
||||
'dawarich.notConnected': 'Не подключено',
|
||||
'dawarich.disconnect': 'Отключить',
|
||||
'dawarich.lastSync': 'Последняя проверка: {when}',
|
||||
'dawarich.neverSynced': 'Ещё не проверялось',
|
||||
'dawarich.syncPartial': 'некоторые поездки прочитать не удалось',
|
||||
'dawarich.serverVersion': 'Dawarich {version}',
|
||||
|
||||
'dawarich.toast.saved': 'Подключение к Dawarich сохранено',
|
||||
'dawarich.toast.saveError': 'Не удалось сохранить подключение',
|
||||
'dawarich.toast.disconnected': 'Dawarich отключён',
|
||||
'dawarich.toast.synced': 'Найдено новых остановок: {count}',
|
||||
'dawarich.toast.syncError': 'Не удалось прочитать Dawarich',
|
||||
'dawarich.toast.syncRunning': 'Проверка уже выполняется',
|
||||
'dawarich.toast.acceptError': 'Не удалось это добавить',
|
||||
'dawarich.toast.updateError': 'Не удалось обновить это предложение',
|
||||
'dawarich.toast.accepted.place': 'Добавлено в поездку',
|
||||
'dawarich.toast.accepted.journal': 'Добавлено в дневник',
|
||||
'dawarich.toast.accepted.bucket_list': 'Отмечено в списке желаний',
|
||||
|
||||
// ── What the connected instance can do ─────────────────────────────────────
|
||||
'dawarich.capability.visits': 'остановки',
|
||||
'dawarich.capability.track': 'записанный маршрут',
|
||||
'dawarich.capability.locations': 'сопоставление со списком желаний',
|
||||
'dawarich.capability.visitedCities': 'страны и города',
|
||||
'dawarich.capability.missing': 'Эта версия Dawarich не поддерживает: {features}.',
|
||||
|
||||
// ── Failure reasons, as sentences the reader can act on ────────────────────
|
||||
'dawarich.error.unreachable': 'TREK не смог связаться с этим адресом.',
|
||||
'dawarich.error.unauthorized': 'Dawarich отклонил ключ API.',
|
||||
'dawarich.error.forbidden': 'Этому ключу API не разрешено это читать.',
|
||||
'dawarich.error.not_found': 'В этой версии Dawarich нет такой конечной точки.',
|
||||
'dawarich.error.rate_limited': 'Dawarich попросил TREK снизить темп. Повторите попытку чуть позже.',
|
||||
'dawarich.error.server_error': 'Dawarich ответил ошибкой.',
|
||||
'dawarich.error.invalid_response': 'По этому адресу ответило что-то, что не является Dawarich.',
|
||||
'dawarich.error.too_large': 'Dawarich прислал больше данных, чем TREK читает за один раз.',
|
||||
'dawarich.error.not_connected': 'Ни один экземпляр Dawarich пока не подключён.',
|
||||
'dawarich.error.addon_disabled': 'Дополнение Dawarich отключено для этого экземпляра.',
|
||||
'dawarich.error.offline': 'Для этого нужно подключение — TREK сейчас офлайн.',
|
||||
'dawarich.error.invalid_url': 'TREK не может использовать этот адрес.',
|
||||
'dawarich.warning.private_ip': 'Этот адрес указывает на частный IP ({ip}). Проверьте, что так и задумано — серверу может понадобиться ALLOW_INTERNAL_NETWORK=true.',
|
||||
'dawarich.error.unknown': 'При обращении к Dawarich что-то пошло не так.',
|
||||
|
||||
// ── The recorded route on the map ──────────────────────────────────────────
|
||||
'dawarich.trail.show': 'Показать записанный маршрут',
|
||||
'dawarich.trail.hide': 'Скрыть записанный маршрут',
|
||||
'dawarich.trail.loading': 'Загрузка записанного маршрута…',
|
||||
'dawarich.trail.empty': 'За эти даты ничего не записано',
|
||||
'dawarich.trail.offline': 'Для записанного маршрута нужно подключение',
|
||||
'dawarich.trail.unavailable': 'Не удалось загрузить записанный маршрут',
|
||||
|
||||
// ── Suggestions ────────────────────────────────────────────────────────────
|
||||
'dawarich.duration.minutes': '{minutes} мин',
|
||||
'dawarich.duration.hours': '{hours} ч',
|
||||
'dawarich.duration.hoursMinutes': '{hours} ч {minutes} мин',
|
||||
'dawarich.checkedAgo': 'проверено {ago}',
|
||||
|
||||
'dawarich.badge.lowConfidence': 'Неточно',
|
||||
'dawarich.badge.sourceChanged': 'Изменено в Dawarich',
|
||||
'dawarich.badge.sourceMissing': 'Исчезло из Dawarich',
|
||||
|
||||
'dawarich.suggestions.title': 'Из Dawarich',
|
||||
'dawarich.suggestions.pending': 'Ожидает вас: {count}',
|
||||
'dawarich.suggestions.loading': 'Чтение Dawarich…',
|
||||
'dawarich.suggestions.notConnected': 'Подключите Dawarich в настройках, чтобы видеть здесь свои остановки.',
|
||||
'dawarich.suggestions.unavailable': 'Не удалось прочитать Dawarich.',
|
||||
'dawarich.suggestions.allHandled': 'Всё, что здесь записано, уже обработано.',
|
||||
'dawarich.suggestions.asJournal': 'Написать запись в дневник',
|
||||
'dawarich.suggestions.asPlace': 'Добавить как место',
|
||||
'dawarich.suggestions.dismiss': 'Я здесь не был',
|
||||
'dawarich.suggestions.dismissed': 'Отклонено',
|
||||
'dawarich.suggestions.restore': 'Вернуть',
|
||||
'dawarich.suggestions.showHandled': 'Показать уже обработанные: {count}',
|
||||
'dawarich.suggestions.hideHandled': 'Скрыть уже обработанные',
|
||||
'dawarich.suggestions.matchesWish': 'В вашем списке желаний: {name}',
|
||||
'dawarich.suggestions.acceptedAs.place': 'Добавлено как место',
|
||||
'dawarich.suggestions.acceptedAs.journal': 'В дневнике',
|
||||
'dawarich.suggestions.acceptedAs.bucket_list': 'Желание отмечено',
|
||||
'dawarich.suggestions.sourceChanged':
|
||||
'Эта остановка изменилась в Dawarich с тех пор, как вы её использовали. То, что вы написали в TREK, осталось нетронутым.',
|
||||
'dawarich.suggestions.sourceMissing':
|
||||
'Этой остановки больше нет в Dawarich. То, что вы написали в TREK, осталось нетронутым.',
|
||||
'dawarich.sourceStatus.suggested': 'Обнаружено, не подтверждено',
|
||||
'dawarich.confidence.high': 'Уверенное определение',
|
||||
'dawarich.confidence.medium': 'Довольно уверенное определение',
|
||||
'dawarich.confidence.low': 'Неуверенное определение',
|
||||
|
||||
// ── The review step ────────────────────────────────────────────────────────
|
||||
'dawarich.accept.title.place': 'Добавить эту остановку как место',
|
||||
'dawarich.accept.title.journal': 'Написать запись в дневник',
|
||||
'dawarich.accept.title.bucket_list': 'Отметить желание',
|
||||
'dawarich.accept.confirm.place': 'Добавить место',
|
||||
'dawarich.accept.confirm.journal': 'Добавить запись',
|
||||
'dawarich.accept.confirm.bucket_list': 'Отметить',
|
||||
'dawarich.accept.recorded': 'Записано с {from} по {to}',
|
||||
'dawarich.accept.duration': '{minutes} мин',
|
||||
'dawarich.accept.name': 'Название',
|
||||
'dawarich.accept.date': 'Дата',
|
||||
'dawarich.accept.from': 'Прибытие',
|
||||
'dawarich.accept.to': 'Отъезд',
|
||||
'dawarich.accept.trip': 'Поездка',
|
||||
'dawarich.accept.thisTrip': 'Эта поездка',
|
||||
'dawarich.accept.pickTrip': 'Выберите поездку',
|
||||
'dawarich.accept.day': 'День',
|
||||
'dawarich.accept.noDay': 'Пока не привязано ко дню',
|
||||
'dawarich.accept.journal': 'Дневник',
|
||||
'dawarich.accept.pickJournal': 'Выберите дневник',
|
||||
'dawarich.accept.notes': 'Заметки',
|
||||
'dawarich.accept.story': 'Ваша история',
|
||||
'dawarich.accept.storyPlaceholder': 'Что здесь произошло?',
|
||||
'dawarich.accept.photosHint': 'Добавьте фотографии к записи после её создания.',
|
||||
|
||||
// ── A place that came out of a recording ──────────────────────────────────
|
||||
'dawarich.place.fromDawarich': 'Добавлено из ваших записей Dawarich',
|
||||
|
||||
// ── Wishlist ───────────────────────────────────────────────────────────────
|
||||
'dawarich.bucket.title': 'Сверить список желаний с Dawarich',
|
||||
'dawarich.bucket.description':
|
||||
'Просматривает ваши записи в поисках мест, куда вы хотели попасть. Для засчитанного посещения нужны и близость, и проведённое время — проезд мимо не считается.',
|
||||
'dawarich.bucket.scan': 'Проверить список желаний',
|
||||
'dawarich.bucket.scanning': 'Проверка…',
|
||||
'dawarich.bucket.noMatches': 'Ничего из вашего списка желаний в записях не нашлось.',
|
||||
'dawarich.bucket.alreadyVisited': 'Уже отмечено',
|
||||
'dawarich.bucket.confirm': 'Отметить: {count}',
|
||||
'dawarich.bucket.confirmed': 'Отмечено желаний: {count}',
|
||||
'dawarich.bucket.skipped': 'У {count} записей нет координат, их не удалось проверить.',
|
||||
'dawarich.bucket.truncated': 'Проверены только первые записи. Запустите ещё раз для остальных.',
|
||||
'dawarich.bucket.visitedFrom': 'Отмечено по вашим записям Dawarich',
|
||||
'dawarich.bucket.clearVisit': 'Отменить',
|
||||
|
||||
// ── Atlas ──────────────────────────────────────────────────────────────────
|
||||
'dawarich.atlas.title': 'Страны из Dawarich',
|
||||
'dawarich.atlas.description':
|
||||
'Страны, в которых, судя по вашим записям, вы были. Подтвердите те, что хотите видеть в своём Atlas — ничего не добавляется само собой, а отмеченное вручную остаётся вашим.',
|
||||
'dawarich.atlas.load': 'Искать страны',
|
||||
'dawarich.atlas.loading': 'Чтение ваших записей…',
|
||||
'dawarich.atlas.empty': 'В ваших записях нет стран, которых у TREK ещё нет.',
|
||||
'dawarich.atlas.cities': 'городов: {count}',
|
||||
'dawarich.atlas.citiesOne': '1 город',
|
||||
'dawarich.atlas.accept': 'Добавить стран: {count}',
|
||||
'dawarich.atlas.accepted': 'Добавлено стран: {count}',
|
||||
'dawarich.atlas.unresolved': 'TREK не смог сопоставить их со страной: {names}.',
|
||||
'dawarich.atlas.source': 'Из Dawarich',
|
||||
'dawarich.atlas.range': 'Просмотрен период с {from} по {to}',
|
||||
|
||||
'dawarich.atlas.trigger': 'Желания и страны из ваших записей',
|
||||
'dawarich.atlas.dialogSubtitle': 'Что ваши записи говорят об Атласе',
|
||||
'dawarich.atlas.tab.wishes': 'Список желаний',
|
||||
'dawarich.atlas.tab.countries': 'Страны',
|
||||
'dawarich.atlas.window': 'Просмотрены последние 12 месяцев.',
|
||||
'dawarich.selected': 'Выбрано: {count}',
|
||||
'dawarich.again': 'Проверить снова',
|
||||
'dawarich.bucket.metersAway': 'в {meters} м',
|
||||
'dawarich.bucket.kilometersAway': 'в {km} км',
|
||||
'dawarich.bucket.rule': 'Желание считается исполненным в пределах {meters} м и после {minutes} минут на месте.',
|
||||
|
||||
};
|
||||
|
||||
export default dawarich;
|
||||
@@ -1,4 +1,5 @@
|
||||
import admin from './admin';
|
||||
import dawarich from './dawarich';
|
||||
import airport from './airport';
|
||||
import atlas from './atlas';
|
||||
import backup from './backup';
|
||||
@@ -106,5 +107,6 @@ const locale = {
|
||||
...mobileSettings,
|
||||
...mobileCollections,
|
||||
...storage,
|
||||
...dawarich,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const dawarich: TranslationStrings = {
|
||||
// ── Connection ─────────────────────────────────────────────────────────────
|
||||
'dawarich.title': 'Dawarich',
|
||||
'dawarich.intro':
|
||||
'Anslut din egen Dawarich-instans för att se var du faktiskt har varit. TREK läser den och föreslår dagboksinlägg, platser och länder – ingenting läggs till förrän du bekräftar det, och ingenting skrivs tillbaka till Dawarich.',
|
||||
'dawarich.url': 'Instansens adress',
|
||||
'dawarich.apiKey': 'API-nyckel',
|
||||
'dawarich.apiKeyPlaceholder': 'Klistra in din Dawarich API-nyckel',
|
||||
'dawarich.apiKeyHint': 'Finns i Dawarich under Konto → API-nyckel. Lagras krypterad och visas aldrig igen.',
|
||||
'dawarich.allowInsecureTls': 'Tillåt självsignerat certifikat',
|
||||
'dawarich.allowInsecureTlsHint': 'Behövs bara om din instans använder ett certifikat som din server inte litar på.',
|
||||
'dawarich.syncEnabled': 'Leta efter nya vistelser automatiskt',
|
||||
'dawarich.syncEnabledHint': 'Avstängt betyder att TREK bara läser Dawarich när du ber om det.',
|
||||
'dawarich.test.button': 'Testa anslutningen',
|
||||
'dawarich.test.success': 'Ansluten. {count} vistelser hittades de senaste 30 dagarna.',
|
||||
'dawarich.test.failed': 'Kunde inte nå Dawarich.',
|
||||
'dawarich.syncNow': 'Leta nu',
|
||||
'dawarich.connected': 'Ansluten',
|
||||
'dawarich.notConnected': 'Inte ansluten',
|
||||
'dawarich.disconnect': 'Koppla från',
|
||||
'dawarich.lastSync': 'Senast kontrollerad {when}',
|
||||
'dawarich.neverSynced': 'Inte kontrollerad än',
|
||||
'dawarich.syncPartial': 'vissa resor kunde inte läsas',
|
||||
'dawarich.serverVersion': 'Dawarich {version}',
|
||||
|
||||
'dawarich.toast.saved': 'Dawarich-anslutningen sparad',
|
||||
'dawarich.toast.saveError': 'Kunde inte spara anslutningen',
|
||||
'dawarich.toast.disconnected': 'Dawarich frånkopplad',
|
||||
'dawarich.toast.synced': '{count} nya vistelser hittades',
|
||||
'dawarich.toast.syncError': 'Kunde inte läsa Dawarich',
|
||||
'dawarich.toast.syncRunning': 'En kontroll pågår redan',
|
||||
'dawarich.toast.acceptError': 'Kunde inte lägga till detta',
|
||||
'dawarich.toast.updateError': 'Kunde inte uppdatera det här förslaget',
|
||||
'dawarich.toast.accepted.place': 'Tillagd i resan',
|
||||
'dawarich.toast.accepted.journal': 'Tillagd i dagboken',
|
||||
'dawarich.toast.accepted.bucket_list': 'Avbockad från din önskelista',
|
||||
|
||||
// ── What the connected instance can do ─────────────────────────────────────
|
||||
'dawarich.capability.visits': 'vistelser',
|
||||
'dawarich.capability.track': 'inspelad rutt',
|
||||
'dawarich.capability.locations': 'matchning mot önskelistan',
|
||||
'dawarich.capability.visitedCities': 'länder och städer',
|
||||
'dawarich.capability.missing': 'Den här versionen av Dawarich erbjuder inte: {features}.',
|
||||
|
||||
// ── Failure reasons, as sentences the reader can act on ────────────────────
|
||||
'dawarich.error.unreachable': 'TREK kunde inte nå den adressen.',
|
||||
'dawarich.error.unauthorized': 'Dawarich avvisade API-nyckeln.',
|
||||
'dawarich.error.forbidden': 'Den API-nyckeln får inte läsa det här.',
|
||||
'dawarich.error.not_found': 'Den här versionen av Dawarich har inte den ändpunkten.',
|
||||
'dawarich.error.rate_limited': 'Dawarich bad TREK att sakta ner. Försök igen om en stund.',
|
||||
'dawarich.error.server_error': 'Dawarich svarade med ett fel.',
|
||||
'dawarich.error.invalid_response': 'Den adressen svarade med något som inte är Dawarich.',
|
||||
'dawarich.error.too_large': 'Dawarich skickade mer data än TREK läser på en gång.',
|
||||
'dawarich.error.not_connected': 'Ingen Dawarich-instans är ansluten än.',
|
||||
'dawarich.error.addon_disabled': 'Dawarich-tillägget är avstängt för den här instansen.',
|
||||
'dawarich.error.offline': 'Det här kräver en anslutning — TREK är offline just nu.',
|
||||
'dawarich.error.invalid_url': 'TREK kan inte använda den adressen.',
|
||||
'dawarich.warning.private_ip': 'Adressen pekar på en privat IP ({ip}). Kontrollera att det är meningen — servern kan behöva ALLOW_INTERNAL_NETWORK=true för att nå den.',
|
||||
'dawarich.error.unknown': 'Något gick fel i kommunikationen med Dawarich.',
|
||||
|
||||
// ── The recorded route on the map ──────────────────────────────────────────
|
||||
'dawarich.trail.show': 'Visa inspelad rutt',
|
||||
'dawarich.trail.hide': 'Dölj inspelad rutt',
|
||||
'dawarich.trail.loading': 'Laddar den inspelade rutten…',
|
||||
'dawarich.trail.empty': 'Ingenting spelades in de här datumen',
|
||||
'dawarich.trail.offline': 'Den inspelade rutten kräver en anslutning',
|
||||
'dawarich.trail.unavailable': 'Den inspelade rutten kunde inte laddas',
|
||||
|
||||
// ── Suggestions ────────────────────────────────────────────────────────────
|
||||
'dawarich.duration.minutes': '{minutes} min',
|
||||
'dawarich.duration.hours': '{hours} tim',
|
||||
'dawarich.duration.hoursMinutes': '{hours} tim {minutes} min',
|
||||
'dawarich.checkedAgo': 'kontrollerad {ago}',
|
||||
|
||||
'dawarich.badge.lowConfidence': 'Osäker',
|
||||
'dawarich.badge.sourceChanged': 'Ändrad i Dawarich',
|
||||
'dawarich.badge.sourceMissing': 'Borta från Dawarich',
|
||||
|
||||
'dawarich.suggestions.title': 'Från Dawarich',
|
||||
'dawarich.suggestions.pending': '{count} väntar på dig',
|
||||
'dawarich.suggestions.loading': 'Läser Dawarich…',
|
||||
'dawarich.suggestions.notConnected': 'Anslut Dawarich under Inställningar för att se dina vistelser här.',
|
||||
'dawarich.suggestions.unavailable': 'Dawarich kunde inte läsas.',
|
||||
'dawarich.suggestions.allHandled': 'Allt som spelats in här är hanterat.',
|
||||
'dawarich.suggestions.asJournal': 'Skriv ett dagboksinlägg',
|
||||
'dawarich.suggestions.asPlace': 'Lägg till som plats',
|
||||
'dawarich.suggestions.dismiss': 'Ingen plats jag besökt',
|
||||
'dawarich.suggestions.dismissed': 'Avfärdad',
|
||||
'dawarich.suggestions.restore': 'Lägg tillbaka',
|
||||
'dawarich.suggestions.showHandled': 'Visa {count} redan hanterade',
|
||||
'dawarich.suggestions.hideHandled': 'Dölj de som redan är hanterade',
|
||||
'dawarich.suggestions.matchesWish': 'På din önskelista: {name}',
|
||||
'dawarich.suggestions.acceptedAs.place': 'Tillagd som plats',
|
||||
'dawarich.suggestions.acceptedAs.journal': 'I dagboken',
|
||||
'dawarich.suggestions.acceptedAs.bucket_list': 'Önskningen avbockad',
|
||||
'dawarich.suggestions.sourceChanged':
|
||||
'Den här vistelsen har ändrats i Dawarich sedan du använde den. Det du skrev i TREK är orört.',
|
||||
'dawarich.suggestions.sourceMissing':
|
||||
'Den här vistelsen finns inte längre i Dawarich. Det du skrev i TREK är orört.',
|
||||
'dawarich.sourceStatus.suggested': 'Upptäckt, obekräftad',
|
||||
'dawarich.confidence.high': 'Säker upptäckt',
|
||||
'dawarich.confidence.medium': 'Ganska säker upptäckt',
|
||||
'dawarich.confidence.low': 'Osäker upptäckt',
|
||||
|
||||
// ── The review step ────────────────────────────────────────────────────────
|
||||
'dawarich.accept.title.place': 'Lägg till den här vistelsen som en plats',
|
||||
'dawarich.accept.title.journal': 'Skriv ett dagboksinlägg',
|
||||
'dawarich.accept.title.bucket_list': 'Bocka av en önskning',
|
||||
'dawarich.accept.confirm.place': 'Lägg till plats',
|
||||
'dawarich.accept.confirm.journal': 'Lägg till inlägg',
|
||||
'dawarich.accept.confirm.bucket_list': 'Bocka av den',
|
||||
'dawarich.accept.recorded': 'Inspelad {from} till {to}',
|
||||
'dawarich.accept.duration': '{minutes} min',
|
||||
'dawarich.accept.name': 'Namn',
|
||||
'dawarich.accept.date': 'Datum',
|
||||
'dawarich.accept.from': 'Ankom',
|
||||
'dawarich.accept.to': 'Lämnade',
|
||||
'dawarich.accept.trip': 'Resa',
|
||||
'dawarich.accept.thisTrip': 'Den här resan',
|
||||
'dawarich.accept.pickTrip': 'Välj en resa',
|
||||
'dawarich.accept.day': 'Dag',
|
||||
'dawarich.accept.noDay': 'Inte på någon dag än',
|
||||
'dawarich.accept.journal': 'Dagbok',
|
||||
'dawarich.accept.pickJournal': 'Välj en dagbok',
|
||||
'dawarich.accept.notes': 'Anteckningar',
|
||||
'dawarich.accept.story': 'Din berättelse',
|
||||
'dawarich.accept.storyPlaceholder': 'Vad hände här?',
|
||||
'dawarich.accept.photosHint': 'Lägg till foton i inlägget när det har skapats.',
|
||||
|
||||
// ── A place that came out of a recording ──────────────────────────────────
|
||||
'dawarich.place.fromDawarich': 'Tillagd från dina Dawarich-inspelningar',
|
||||
|
||||
// ── Wishlist ───────────────────────────────────────────────────────────────
|
||||
'dawarich.bucket.title': 'Stäm av din önskelista mot Dawarich',
|
||||
'dawarich.bucket.description':
|
||||
'Söker igenom dina inspelningar efter de platser du ville nå. Ett besök kräver både närhet och tid på plats – att köra förbi räknas inte.',
|
||||
'dawarich.bucket.scan': 'Stäm av önskelistan',
|
||||
'dawarich.bucket.scanning': 'Stämmer av…',
|
||||
'dawarich.bucket.noMatches': 'Inget på din önskelista dök upp i dina inspelningar.',
|
||||
'dawarich.bucket.alreadyVisited': 'Redan avbockad',
|
||||
'dawarich.bucket.confirm': 'Bocka av {count}',
|
||||
'dawarich.bucket.confirmed': '{count} önskningar avbockade',
|
||||
'dawarich.bucket.skipped': '{count} poster saknar koordinater och kunde inte kontrolleras.',
|
||||
'dawarich.bucket.truncated': 'Bara de första posterna kontrollerades. Kör igen för resten.',
|
||||
'dawarich.bucket.visitedFrom': 'Avbockad utifrån dina Dawarich-inspelningar',
|
||||
'dawarich.bucket.clearVisit': 'Ångra',
|
||||
|
||||
// ── Atlas ──────────────────────────────────────────────────────────────────
|
||||
'dawarich.atlas.title': 'Länder från Dawarich',
|
||||
'dawarich.atlas.description':
|
||||
'Länder som dina inspelningar säger att du har varit i. Bekräfta dem du vill ha i din Atlas – ingenting läggs till av sig självt, och det du markerat för hand förblir ditt.',
|
||||
'dawarich.atlas.load': 'Leta efter länder',
|
||||
'dawarich.atlas.loading': 'Läser dina inspelningar…',
|
||||
'dawarich.atlas.empty': 'Dina inspelningar visar inga länder som TREK inte redan har.',
|
||||
'dawarich.atlas.cities': '{count} städer',
|
||||
'dawarich.atlas.citiesOne': '1 stad',
|
||||
'dawarich.atlas.accept': 'Lägg till {count} länder',
|
||||
'dawarich.atlas.accepted': '{count} länder tillagda',
|
||||
'dawarich.atlas.unresolved': 'TREK kunde inte matcha dessa mot ett land: {names}.',
|
||||
'dawarich.atlas.source': 'Från Dawarich',
|
||||
'dawarich.atlas.range': 'Tittade på {from} till {to}',
|
||||
|
||||
'dawarich.atlas.trigger': 'Önskningar och länder från dina inspelningar',
|
||||
'dawarich.atlas.dialogSubtitle': 'Vad dina inspelningar säger om din Atlas',
|
||||
'dawarich.atlas.tab.wishes': 'Önskelista',
|
||||
'dawarich.atlas.tab.countries': 'Länder',
|
||||
'dawarich.atlas.window': 'De senaste 12 månaderna har genomsökts.',
|
||||
'dawarich.selected': '{count} valda',
|
||||
'dawarich.again': 'Kontrollera igen',
|
||||
'dawarich.bucket.metersAway': '{meters} m bort',
|
||||
'dawarich.bucket.kilometersAway': '{km} km bort',
|
||||
'dawarich.bucket.rule': 'En önskning räknas som uppfylld inom {meters} m och efter {minutes} minuter på plats.',
|
||||
|
||||
};
|
||||
|
||||
export default dawarich;
|
||||
@@ -1,4 +1,5 @@
|
||||
import admin from './admin';
|
||||
import dawarich from './dawarich';
|
||||
import airport from './airport';
|
||||
import atlas from './atlas';
|
||||
import backup from './backup';
|
||||
@@ -106,5 +107,6 @@ const locale = {
|
||||
...mobileSettings,
|
||||
...mobileCollections,
|
||||
...storage,
|
||||
...dawarich,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const dawarich: TranslationStrings = {
|
||||
// ── Connection ─────────────────────────────────────────────────────────────
|
||||
'dawarich.title': 'Dawarich',
|
||||
'dawarich.intro':
|
||||
'Gerçekte nerelere gittiğinizi görmek için kendi Dawarich sunucunuzu bağlayın. TREK bu kayıtları okur ve günlük kayıtları, yerler ve ülkeler önerir — siz onaylamadan hiçbir şey eklenmez ve Dawarich’e hiçbir şey geri yazılmaz.',
|
||||
'dawarich.url': 'Sunucu adresi',
|
||||
'dawarich.apiKey': 'API anahtarı',
|
||||
'dawarich.apiKeyPlaceholder': 'Dawarich API anahtarınızı yapıştırın',
|
||||
'dawarich.apiKeyHint':
|
||||
'Dawarich’te Hesap → API anahtarı altında bulunur. Şifreli olarak saklanır ve bir daha gösterilmez.',
|
||||
'dawarich.allowInsecureTls': 'Kendinden imzalı sertifikaya izin ver',
|
||||
'dawarich.allowInsecureTlsHint':
|
||||
'Yalnızca sunucunuzun güvenmediği bir sertifika kullanan bir örnek için gerekir.',
|
||||
'dawarich.syncEnabled': 'Yeni konaklamaları otomatik olarak denetle',
|
||||
'dawarich.syncEnabledHint': 'Kapalıyken TREK, Dawarich’i yalnızca siz istediğinizde okur.',
|
||||
'dawarich.test.button': 'Bağlantıyı test et',
|
||||
'dawarich.test.success': 'Bağlandı. Son 30 günde {count} konaklama bulundu.',
|
||||
'dawarich.test.failed': 'Dawarich’e ulaşılamadı.',
|
||||
'dawarich.syncNow': 'Şimdi denetle',
|
||||
'dawarich.connected': 'Bağlandı',
|
||||
'dawarich.notConnected': 'Bağlı değil',
|
||||
'dawarich.disconnect': 'Bağlantıyı kes',
|
||||
'dawarich.lastSync': 'Son denetim {when}',
|
||||
'dawarich.neverSynced': 'Henüz denetlenmedi',
|
||||
'dawarich.syncPartial': 'bazı geziler okunamadı',
|
||||
'dawarich.serverVersion': 'Dawarich {version}',
|
||||
|
||||
'dawarich.toast.saved': 'Dawarich bağlantısı kaydedildi',
|
||||
'dawarich.toast.saveError': 'Bağlantı kaydedilemedi',
|
||||
'dawarich.toast.disconnected': 'Dawarich bağlantısı kesildi',
|
||||
'dawarich.toast.synced': '{count} yeni konaklama bulundu',
|
||||
'dawarich.toast.syncError': 'Dawarich okunamadı',
|
||||
'dawarich.toast.syncRunning': 'Zaten bir kontrol çalışıyor',
|
||||
'dawarich.toast.acceptError': 'Bu eklenemedi',
|
||||
'dawarich.toast.updateError': 'Bu öneri güncellenemedi',
|
||||
'dawarich.toast.accepted.place': 'Geziye eklendi',
|
||||
'dawarich.toast.accepted.journal': 'Günlüğe eklendi',
|
||||
'dawarich.toast.accepted.bucket_list': 'Dilek listenizden işaretlendi',
|
||||
|
||||
// ── What the connected instance can do ─────────────────────────────────────
|
||||
'dawarich.capability.visits': 'konaklamalar',
|
||||
'dawarich.capability.track': 'kaydedilen rota',
|
||||
'dawarich.capability.locations': 'dilek listesi eşleştirme',
|
||||
'dawarich.capability.visitedCities': 'ülkeler ve şehirler',
|
||||
'dawarich.capability.missing': 'Bu Dawarich sürümü şunları sunmuyor: {features}.',
|
||||
|
||||
// ── Failure reasons, as sentences the reader can act on ────────────────────
|
||||
'dawarich.error.unreachable': 'TREK bu adrese ulaşamadı.',
|
||||
'dawarich.error.unauthorized': 'Dawarich API anahtarını reddetti.',
|
||||
'dawarich.error.forbidden': 'Bu API anahtarının bunu okuma izni yok.',
|
||||
'dawarich.error.not_found': 'Bu Dawarich sürümünde böyle bir uç nokta yok.',
|
||||
'dawarich.error.rate_limited': 'Dawarich, TREK’ten yavaşlamasını istedi. Birazdan yeniden deneyin.',
|
||||
'dawarich.error.server_error': 'Dawarich bir hatayla yanıt verdi.',
|
||||
'dawarich.error.invalid_response': 'Bu adres, Dawarich olmayan bir şeyle yanıt verdi.',
|
||||
'dawarich.error.too_large': 'Dawarich, TREK’in bir kerede okuyacağından daha fazla veri gönderdi.',
|
||||
'dawarich.error.not_connected': 'Henüz bağlı bir Dawarich sunucusu yok.',
|
||||
'dawarich.error.addon_disabled': 'Dawarich eklentisi bu örnek için kapalı.',
|
||||
'dawarich.error.offline': 'Bunun için bağlantı gerekiyor — TREK şu anda çevrimdışı.',
|
||||
'dawarich.error.invalid_url': 'TREK bu adresi kullanamıyor.',
|
||||
'dawarich.warning.private_ip': 'Bu adres özel bir IP’ye çıkıyor ({ip}). Böyle olmasını istediğinden emin ol — sunucunun buna erişmesi için ALLOW_INTERNAL_NETWORK=true gerekebilir.',
|
||||
'dawarich.error.unknown': 'Dawarich ile iletişimde bir şeyler ters gitti.',
|
||||
|
||||
// ── The recorded route on the map ──────────────────────────────────────────
|
||||
'dawarich.trail.show': 'Kaydedilen rotayı göster',
|
||||
'dawarich.trail.hide': 'Kaydedilen rotayı gizle',
|
||||
'dawarich.trail.loading': 'Kaydedilen rota yükleniyor…',
|
||||
'dawarich.trail.empty': 'Bu tarihlerde hiçbir şey kaydedilmemiş',
|
||||
'dawarich.trail.offline': 'Kaydedilen rota için bağlantı gerekir',
|
||||
'dawarich.trail.unavailable': 'Kaydedilen rota yüklenemedi',
|
||||
|
||||
// ── Suggestions ────────────────────────────────────────────────────────────
|
||||
'dawarich.duration.minutes': '{minutes} dk',
|
||||
'dawarich.duration.hours': '{hours} sa',
|
||||
'dawarich.duration.hoursMinutes': '{hours} sa {minutes} dk',
|
||||
'dawarich.checkedAgo': '{ago} kontrol edildi',
|
||||
|
||||
'dawarich.badge.lowConfidence': 'Belirsiz',
|
||||
'dawarich.badge.sourceChanged': 'Dawarich’te değişti',
|
||||
'dawarich.badge.sourceMissing': 'Dawarich’ten kayboldu',
|
||||
|
||||
'dawarich.suggestions.title': 'Dawarich’ten',
|
||||
'dawarich.suggestions.pending': '{count} tanesi sizi bekliyor',
|
||||
'dawarich.suggestions.loading': 'Dawarich okunuyor…',
|
||||
'dawarich.suggestions.notConnected':
|
||||
'Konaklamalarınızı burada görmek için Ayarlar’dan Dawarich’i bağlayın.',
|
||||
'dawarich.suggestions.unavailable': 'Dawarich okunamadı.',
|
||||
'dawarich.suggestions.allHandled': 'Burada kaydedilen her şey ele alındı.',
|
||||
'dawarich.suggestions.asJournal': 'Günlük kaydı yaz',
|
||||
'dawarich.suggestions.asPlace': 'Yer olarak ekle',
|
||||
'dawarich.suggestions.dismiss': 'Ziyaret ettiğim bir yer değil',
|
||||
'dawarich.suggestions.dismissed': 'Yok sayıldı',
|
||||
'dawarich.suggestions.restore': 'Geri koy',
|
||||
'dawarich.suggestions.showHandled': 'Ele alınmış {count} tanesini göster',
|
||||
'dawarich.suggestions.hideHandled': 'Ele alınmış olanları gizle',
|
||||
'dawarich.suggestions.matchesWish': 'Dilek listenizde: {name}',
|
||||
'dawarich.suggestions.acceptedAs.place': 'Yer olarak eklendi',
|
||||
'dawarich.suggestions.acceptedAs.journal': 'Günlükte',
|
||||
'dawarich.suggestions.acceptedAs.bucket_list': 'Dilek işaretlendi',
|
||||
'dawarich.suggestions.sourceChanged':
|
||||
'Bu konaklama, siz kullandıktan sonra Dawarich’te değişti. TREK’te yazdıklarınıza dokunulmadı.',
|
||||
'dawarich.suggestions.sourceMissing':
|
||||
'Bu konaklama artık Dawarich’te yok. TREK’te yazdıklarınıza dokunulmadı.',
|
||||
'dawarich.sourceStatus.suggested': 'Algılandı, onaylanmadı',
|
||||
'dawarich.confidence.high': 'Kesine yakın algılama',
|
||||
'dawarich.confidence.medium': 'Oldukça güvenilir algılama',
|
||||
'dawarich.confidence.low': 'Belirsiz algılama',
|
||||
|
||||
// ── The review step ────────────────────────────────────────────────────────
|
||||
'dawarich.accept.title.place': 'Bu konaklamayı yer olarak ekle',
|
||||
'dawarich.accept.title.journal': 'Günlük kaydı yaz',
|
||||
'dawarich.accept.title.bucket_list': 'Bir dileği işaretle',
|
||||
'dawarich.accept.confirm.place': 'Yer ekle',
|
||||
'dawarich.accept.confirm.journal': 'Kayıt ekle',
|
||||
'dawarich.accept.confirm.bucket_list': 'İşaretle',
|
||||
'dawarich.accept.recorded': '{from} ile {to} arasında kaydedildi',
|
||||
'dawarich.accept.duration': '{minutes} dk',
|
||||
'dawarich.accept.name': 'Ad',
|
||||
'dawarich.accept.date': 'Tarih',
|
||||
'dawarich.accept.from': 'Varış',
|
||||
'dawarich.accept.to': 'Ayrılış',
|
||||
'dawarich.accept.trip': 'Gezi',
|
||||
'dawarich.accept.thisTrip': 'Bu gezi',
|
||||
'dawarich.accept.pickTrip': 'Bir gezi seçin',
|
||||
'dawarich.accept.day': 'Gün',
|
||||
'dawarich.accept.noDay': 'Henüz bir güne bağlı değil',
|
||||
'dawarich.accept.journal': 'Günlük',
|
||||
'dawarich.accept.pickJournal': 'Bir günlük seçin',
|
||||
'dawarich.accept.notes': 'Notlar',
|
||||
'dawarich.accept.story': 'Hikâyeniz',
|
||||
'dawarich.accept.storyPlaceholder': 'Burada neler oldu?',
|
||||
'dawarich.accept.photosHint': 'Kayıt oluşturulduktan sonra fotoğraf ekleyebilirsiniz.',
|
||||
|
||||
// ── A place that came out of a recording ──────────────────────────────────
|
||||
'dawarich.place.fromDawarich': 'Dawarich kayıtlarından eklendi',
|
||||
|
||||
// ── Wishlist ───────────────────────────────────────────────────────────────
|
||||
'dawarich.bucket.title': 'Dilek listenizi Dawarich ile karşılaştırın',
|
||||
'dawarich.bucket.description':
|
||||
'Ulaşmak istediğiniz yerler için kayıtlarınıza bakar. Bir ziyaret hem yakınlık hem de orada geçirilen süre ister — yanından geçmek sayılmaz.',
|
||||
'dawarich.bucket.scan': 'Dilek listesini denetle',
|
||||
'dawarich.bucket.scanning': 'Denetleniyor…',
|
||||
'dawarich.bucket.noMatches': 'Dilek listenizden hiçbir şey kayıtlarınızda çıkmadı.',
|
||||
'dawarich.bucket.alreadyVisited': 'Zaten işaretlenmiş',
|
||||
'dawarich.bucket.confirm': '{count} tanesini işaretle',
|
||||
'dawarich.bucket.confirmed': '{count} dilek işaretlendi',
|
||||
'dawarich.bucket.skipped': '{count} kaydın koordinatı yok, denetlenemedi.',
|
||||
'dawarich.bucket.truncated': 'Yalnızca ilk kayıtlar denetlendi. Kalanı için yeniden çalıştırın.',
|
||||
'dawarich.bucket.visitedFrom': 'Dawarich kayıtlarınızdan işaretlendi',
|
||||
'dawarich.bucket.clearVisit': 'Geri al',
|
||||
|
||||
// ── Atlas ──────────────────────────────────────────────────────────────────
|
||||
'dawarich.atlas.title': 'Dawarich’ten gelen ülkeler',
|
||||
'dawarich.atlas.description':
|
||||
'Kayıtlarınıza göre bulunduğunuz ülkeler. Atlas’ınızda görmek istediklerinizi onaylayın — hiçbir şey kendiliğinden eklenmez ve elle işaretlediğiniz her şey size ait kalır.',
|
||||
'dawarich.atlas.load': 'Ülkeleri ara',
|
||||
'dawarich.atlas.loading': 'Kayıtlarınız okunuyor…',
|
||||
'dawarich.atlas.empty': 'Kayıtlarınız, TREK’te halihazırda olmayan bir ülke göstermiyor.',
|
||||
'dawarich.atlas.cities': '{count} şehir',
|
||||
'dawarich.atlas.citiesOne': '1 şehir',
|
||||
'dawarich.atlas.accept': '{count} ülke ekle',
|
||||
'dawarich.atlas.accepted': '{count} ülke eklendi',
|
||||
'dawarich.atlas.unresolved': 'TREK bunları bir ülkeyle eşleştiremedi: {names}.',
|
||||
'dawarich.atlas.source': 'Dawarich’ten',
|
||||
'dawarich.atlas.range': '{from} ile {to} arası incelendi',
|
||||
|
||||
'dawarich.atlas.trigger': 'Kayıtlarından gelen dilekler ve ülkeler',
|
||||
'dawarich.atlas.dialogSubtitle': 'Kayıtların Atlas’ın hakkında ne söylüyor',
|
||||
'dawarich.atlas.tab.wishes': 'Dilek listesi',
|
||||
'dawarich.atlas.tab.countries': 'Ülkeler',
|
||||
'dawarich.atlas.window': 'Son 12 ay incelendi.',
|
||||
'dawarich.selected': '{count} seçildi',
|
||||
'dawarich.again': 'Yeniden kontrol et',
|
||||
'dawarich.bucket.metersAway': '{meters} m uzakta',
|
||||
'dawarich.bucket.kilometersAway': '{km} km uzakta',
|
||||
'dawarich.bucket.rule': 'Bir dilek {meters} m yakınlıkta ve yerinde {minutes} dakika sonra ulaşılmış sayılır.',
|
||||
|
||||
};
|
||||
|
||||
export default dawarich;
|
||||
@@ -1,4 +1,5 @@
|
||||
import admin from './admin';
|
||||
import dawarich from './dawarich';
|
||||
import airport from './airport';
|
||||
import atlas from './atlas';
|
||||
import backup from './backup';
|
||||
@@ -106,5 +107,6 @@ const locale = {
|
||||
...mobileSettings,
|
||||
...mobileCollections,
|
||||
...storage,
|
||||
...dawarich,
|
||||
};
|
||||
export default locale;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user