i18n: the English left in Costs, Studio and the mobile timeline

Three surfaces still had English written straight into JSX in a codebase that
gates on key parity: the reworked expense modal, the Studio quickbar and
rotation handles, and the leg-mode menu on the mobile day plan. Keyed, and
translated into all 23 locales rather than pasted.

Also reworded the plugin geolocation permission: it told the user their
browser would ask, and it never does - TREK answers the plugin from the
page's own location permission.
This commit is contained in:
Maurice
2026-08-23 10:38:11 +02:00
committed by Maurice
parent 27be764164
commit e72338bbbc
77 changed files with 620 additions and 66 deletions
@@ -10,7 +10,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { usePermissionsStore } from '../../store/permissionsStore'
import { clearExchangeRateCache } from '../../hooks/useExchangeRates'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { buildUser, buildTrip, buildBudgetItem } from '../../../tests/helpers/factories'
import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories'
import type { BudgetItem } from '../../types'
import CostsPanel, { ExpenseModal } from './CostsPanel'
import { splitEqualShares, calculateTicketShares, type TicketItem } from './CostsPanel.helpers'
@@ -594,7 +594,7 @@ describe('CostsPanel — settlements in the ledger', () => {
expect(screen.getByDisplayValue('100,00')).toBeDisabled()
expect(screen.getByText('Individual Shares Summary')).toBeInTheDocument()
expect(screen.getByText('Individual shares')).toBeInTheDocument()
expect(screen.getByText(/75\.00/)).toBeInTheDocument()
expect(screen.getByText(/25\.00/)).toBeInTheDocument()
@@ -916,6 +916,50 @@ describe('CostsPanel — settle up', () => {
delete window.__addToast
})
it('FE-W5COSTS-059: a settle-up that fails midway still reloads the transfers that went through', async () => {
const addToast = vi.fn()
window.__addToast = addToast as unknown as typeof window.__addToast
let posted = 0
let reads = 0
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => {
reads += 1
return HttpResponse.json({ balances: [], flows: posted > 0 ? [flows[1]] : flows, settlements: [] })
}),
http.post('/api/trips/1/budget/settlements', () => {
posted += 1
return posted === 1 ? HttpResponse.json({ settlement: { id: 1 } }) : HttpResponse.json({ error: 'no' }, { status: 500 })
}),
)
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
const settleAll = await screen.findByRole('button', { name: 'Settle up' })
await waitFor(() => expect(settleAll).not.toBeDisabled())
const readsBefore = reads
fireEvent.click(settleAll)
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined))
// The failed second transfer must not swallow the refresh: bob's 45 € was
// recorded, and leaving it listed invites a second, doubled settle-up.
await waitFor(() => expect(reads).toBeGreaterThan(readsBefore))
expect(posted).toBe(2)
delete window.__addToast
})
it('FE-W5COSTS-060: a settlement read that fails says so instead of claiming everyone is square', async () => {
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ error: 'no' }, { status: 500 })),
)
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await screen.findByText('Balances')
// Settle up and Balances both hang off the one failed request.
await waitFor(() => expect(screen.getAllByText('Unknown error')).toHaveLength(2))
expect(screen.queryByText("Everyone's square")).toBeNull()
})
it('FE-W5COSTS-013: the "you owe" card lists who I still have to pay', async () => {
mount([], { flows: [{ from: { user_id: 1, username: 'alice' }, to: { user_id: 2, username: 'bob' }, amount: 20 }] })
@@ -1447,6 +1491,26 @@ describe('CostsPanel — remaining paths', () => {
createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore()
})
it('FE-W5COSTS-061: the CSV export neutralises names a spreadsheet would run as a formula', async () => {
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR', title: 'Rome' }) })
let exported: Blob | null = null
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockImplementation(b => { exported = b as Blob; return 'blob:mock' })
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
mount([
expense({ id: 204, name: '=HYPERLINK("http://evil","click")', category: 'food', total_price: 12, expense_date: '2025-06-15', note: '@SUM(A1:A9)' }),
expense({ id: 205, name: '-5 refund', category: 'misc', total_price: 5, expense_date: '2025-06-16', note: null }),
])
await screen.findByText('-5 refund')
fireEvent.click(screen.getByTitle('Export CSV'))
const lines = (await exported!.text()).replace(/^\uFEFF/, '').split('\r\n')
expect(lines[1]).toBe('06/15/2025;"\'=HYPERLINK(""http://evil"",""click"")";Food & drink;12.00;EUR;12.00;\'@SUM(A1:A9)')
expect(lines[2]).toBe('06/16/2025;\'-5 refund;Other;5.00;EUR;5.00;')
createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore()
})
it('FE-W5COSTS-043: deleting an expense drops it from the ledger', async () => {
let deleted = false
server.use(http.delete('/api/trips/1/budget/101', () => { deleted = true; return HttpResponse.json({ success: true }) }))
@@ -1615,7 +1679,7 @@ describe('CostsPanel — remaining paths', () => {
await user.click(bob)
await user.click(bob)
// 10.99 across two people leaves the odd cent with the lower user id.
expect(screen.getByText('Individual Shares Summary')).toBeInTheDocument()
expect(screen.getByText('Individual shares')).toBeInTheDocument()
expect(screen.getByText('€5.50')).toBeInTheDocument()
expect(screen.getByText('€5.49')).toBeInTheDocument()
})
@@ -1741,6 +1805,21 @@ describe('CostsPanel — mobile extras', () => {
expect(await screen.findByPlaceholderText('0.00')).toBeInTheDocument()
})
it('FE-W5COSTS-062: the mobile search box keeps the caret across keystrokes', async () => {
const user = userEvent.setup()
mount([dinner(), taxi()])
await screen.findByText('Taxi')
const box = screen.getByPlaceholderText('Search expenses…')
await user.type(box, 'din')
// The whole word has to land: if the body is remounted per keystroke the input
// loses focus and everything after the first character goes nowhere.
expect((box as HTMLInputElement).value).toBe('din')
expect(document.activeElement).toBe(box)
expect(screen.queryByText('Taxi')).not.toBeInTheDocument()
})
it('FE-W5COSTS-054: an unknown currency degrades to a plain amount on mobile too', async () => {
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: '' } })
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'XX' }) })
@@ -1774,3 +1853,58 @@ describe('CostsPanel — split maths', () => {
expect(calculateTicketShares(items)).toEqual({ shares: { 1: 7.5, 2: 7.5 }, total: 15 })
})
})
// The reworked expense modal shipped with English written straight into the JSX,
// so the receipt panel and the split summary stayed English on a German phone
// while everything around them translated.
describe('CostsPanel — expense modal in another language', () => {
beforeEach(() => {
seedAlice()
seedStore(useSettingsStore, { settings: buildSettings({ language: 'de', default_currency: 'EUR' }) })
clearExchangeRateCache()
})
afterEach(clearExchangeRateCache)
const openModal = () => render(
<ExpenseModal tripId={1} base="EUR" people={tripMembers} me={1} editing={null}
onClose={() => {}} onSaved={vi.fn()} />
)
it('FE-W5COSTS-063: the receipt panel is translated', async () => {
const user = userEvent.setup()
openModal()
// The locale bundle is fetched, so the first paint is still English.
await user.click(await screen.findByRole('button', { name: 'Beleg' }))
await user.click(screen.getByRole('button', { name: /Artikel hinzufügen/ }))
expect(screen.getByPlaceholderText('Artikelname')).toBeInTheDocument()
expect(screen.getByText('Aufteilen auf:')).toBeInTheDocument()
expect(screen.getByText('Anteil pro Person')).toBeInTheDocument()
expect(screen.queryByText('Individual shares')).not.toBeInTheDocument()
})
it('FE-W5COSTS-064: the split summary and the excluded marker are translated', async () => {
const user = userEvent.setup()
openModal()
await user.type(await screen.findByPlaceholderText('0,00'), '90')
await user.click(screen.getByRole('button', { name: 'Individuell' }))
// Nothing entered yet, so the whole 90 is still unaccounted for.
expect(screen.getByText('Summe der Anteile: €0.00 von €90.00 (es fehlen €90.00)')).toBeInTheDocument()
const shares = screen.getAllByPlaceholderText('45,00')
await user.type(shares[0], '90')
expect(screen.getByText('Aufteilung passt zur Summe')).toBeInTheDocument()
await user.type(shares[1], '10')
expect(screen.getByText('Summe der Anteile: €100.00 von €90.00 (€10.00 zu viel)')).toBeInTheDocument()
// Back to the equal split: a member taken out of it is marked, not dropped.
await user.click(screen.getByRole('button', { name: 'Gleichmäßig' }))
await user.click(screen.getByRole('button', { name: /bob/i }))
expect(screen.getByText('Nicht dabei')).toBeInTheDocument()
expect(screen.queryByText('Excluded')).not.toBeInTheDocument()
})
})
+49 -24
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { Fragment, useState, useEffect, useMemo, useCallback } from 'react'
import { useSearchParams } from 'react-router'
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download, StickyNote, ChevronDown } from 'lucide-react'
import { useTripStore } from '../../store/tripStore'
@@ -74,6 +74,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const { convert } = useExchangeRates(base)
const curOf = useCallback((e: BudgetItem) => (e.currency || tripCurrency), [tripCurrency])
const [settlement, setSettlement] = useState<SettlementData | null>(null)
// A failed settlement read leaves `settlement` null, which the empty views would
// otherwise present as "everyone is square", a balance claim we cannot make.
const [settlementError, setSettlementError] = useState(false)
const [filter, setFilter] = useState<'all' | 'mine' | 'owed'>('all')
const [search, setSearch] = useState('')
const [catFilter, setCatFilter] = useState('') // '' = all categories
@@ -99,7 +102,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const fmt0 = useCallback((v: number, c = base) => formatMoney(v, c, locale, { decimals: 0 }), [base, locale])
const loadSettlement = useCallback(() => {
budgetApi.settlement(tripId, base).then(setSettlement).catch(() => {})
budgetApi.settlement(tripId, base)
.then(s => { setSettlement(s); setSettlementError(false) })
.catch(() => setSettlementError(true))
}, [tripId, base])
useEffect(() => { loadBudgetItems(tripId); loadSettlement() }, [tripId])
@@ -219,8 +224,10 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
if (!flows.length) return
try {
for (const f of flows) await budgetApi.createSettlement(tripId, { from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount, currency: base })
loadSettlement()
} catch { toast.error(t('common.unknownError')) }
// Refresh even when one transfer failed: the ones created before it are real,
// and leaving them in the flow list invites a second, doubled settle-up.
finally { loadSettlement() }
}
const dateMeta = useMemo(() => {
@@ -241,7 +248,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
// Costs rework (#1500). One row per expense, oldest first.
const handleExportCsv = () => {
const sep = ';'
const esc = (v: unknown) => { const s = String(v ?? ''); return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s }
// A cell starting with =, +, -, @, TAB or CR is evaluated as a formula by Excel
// and Sheets, and the name/note columns are free text any trip member can write.
const esc = (v: unknown) => {
let s = String(v ?? '')
if (/^[=+\-@\t\r]/.test(s)) s = "'" + s
return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s
}
const fmtDate = (iso: string) => { if (!iso) return ''; try { return new Date(iso + 'T00:00:00Z').toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) } catch { return iso } }
const header = ['Date', 'Name', 'Category', 'Amount', 'Currency', 'Amount (' + base + ')', 'Note']
@@ -310,7 +323,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
return (
<div className="costs-root" style={{ minHeight: '100%', background: 'var(--c-bg)', padding: isMobile ? '6px 14px 28px' : '40px 24px 48px' }}>
{isMobile ? <MobileBody /> : (
{isMobile ? MobileBody() : (
<div style={{ maxWidth: '100%', margin: '0 auto' }}>
{/* ── Header bar ── */}
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 24, marginBottom: 28, flexWrap: 'wrap' }}>
@@ -422,8 +435,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{g.entries.map(en => en.kind === 'expense'
? <ExpenseRow key={'e' + en.e.id} e={en.e} />
: <SettlementRow key={'s' + en.s.id} s={en.s} />)}
? <Fragment key={'e' + en.e.id}>{ExpenseRow({ e: en.e })}</Fragment>
: <Fragment key={'s' + en.s.id}>{SettlementRow({ s: en.s })}</Fragment>)}
</div>
</div>
)
@@ -444,19 +457,19 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</button>
)}
</div>
<SettleFlows />
{SettleFlows()}
</div>
{/* balances */}
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.balances')}</div>
<BalancesList balances={settlement?.balances || []} />
{BalancesList({ balances: settlement?.balances || [] })}
</div>
{/* by category */}
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.byCategory')}</div>
<CategoryBreakdown />
{CategoryBreakdown()}
</div>
</div>
</div>
@@ -520,8 +533,15 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</div>
)
// Settle-up and Balances both come from the one settlement request, so they
// share the notice that says the numbers are missing rather than zero.
function loadFailed() {
return <div className="text-content-muted" style={{ textAlign: 'center', padding: '14px 8px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))' }}>{t('common.unknownError')}</div>
}
// ── shared settle-flow list ──────────────────────────────────────────────
function SettleFlows() {
if (settlementError) return loadFailed()
const flows = settlement?.flows || []
if (flows.length === 0) return (
<div style={{ textAlign: 'center', padding: '14px 8px' }}>
@@ -602,7 +622,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<button onClick={() => setAddingPayment(true)} className="text-content-muted bg-surface-card border border-edge" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 9, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><Plus size={13} /> {t('costs.addPayment')}</button>
)}
</div>
<SettleFlows />
{SettleFlows()}
</div>
{/* Expenses */}
@@ -637,8 +657,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div key={g.day} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{!dayFilter && <div className={labelCls} style={{ display: 'flex', alignItems: 'center', padding: '0 2px' }}>{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))' }}>{t('costs.spent', { amount: fmt(dtot) })}</span></div>}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{g.entries.map(en => en.kind === 'expense'
? <ExpenseRow key={'e' + en.e.id} e={en.e} />
: <SettlementRow key={'s' + en.s.id} s={en.s} />)}</div>
? <Fragment key={'e' + en.e.id}>{ExpenseRow({ e: en.e })}</Fragment>
: <Fragment key={'s' + en.s.id}>{SettlementRow({ s: en.s })}</Fragment>)}</div>
</div>
)
})}
@@ -647,13 +667,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{/* Balances */}
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.balances')}</div>
<BalancesList balances={settlement?.balances || []} />
{BalancesList({ balances: settlement?.balances || [] })}
</div>
{/* By category */}
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.byCategory')}</div>
<CategoryBreakdown />
{CategoryBreakdown()}
</div>
</div>
)
@@ -775,7 +795,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</div>
</div>
</div>
{canEdit && <RowActions onEdit={() => { setEditing(e); setModalOpen(true) }} onDelete={() => handleDelete(e.id)} deleteLabel={t('common.delete')} />}
{canEdit && RowActions({ onEdit: () => { setEditing(e); setModalOpen(true) }, onDelete: () => handleDelete(e.id), deleteLabel: t('common.delete') })}
</div>
)
}
@@ -817,6 +837,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
}
function BalancesList({ balances }: { balances: SettlementData['balances'] }) {
if (settlementError) return loadFailed()
const rows = people.map(p => balances.find(b => b.user_id === p.id) || { user_id: p.id, username: p.username, avatar_url: null, balance: 0 })
const max = Math.max(1, ...rows.map(r => Math.abs(r.balance)))
return (
@@ -1445,7 +1466,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 130px auto', gap: 8, alignItems: 'center' }}>
<input
type="text"
placeholder="Item name"
placeholder={t('costs.ticketItemName')}
value={item.name}
onChange={e => handleUpdateItemName(item.id, e.target.value)}
className="bg-surface-input border border-edge text-content"
@@ -1468,7 +1489,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, alignItems: 'center' }}>
<span className="text-content-faint" style={{ fontSize: 10.5, fontWeight: 600, textTransform: 'uppercase', marginRight: 4 }}>Splitting:</span>
<span className="text-content-faint" style={{ fontSize: 10.5, fontWeight: 600, textTransform: 'uppercase', marginRight: 4 }}>{t('costs.ticketSplitting')}</span>
{people.map((p, pIdx) => {
const active = item.participants.has(p.id)
return (
@@ -1492,12 +1513,12 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
</div>
<button type="button" onClick={handleAddEmptyItem} className="border border-dashed border-edge text-content-muted" style={{ padding: '8px 12px', borderRadius: 10, background: 'none', fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
<Plus size={14} /> Add item
<Plus size={14} /> {t('costs.ticketAddItem')}
</button>
{ticketItems.length > 0 && (
<div className="bg-surface-secondary border border-edge" style={{ padding: 12, borderRadius: 10 }}>
<div className="text-content" style={{ fontSize: 12, fontWeight: 600, marginBottom: 8 }}>Individual Shares Summary</div>
<div className="text-content" style={{ fontSize: 12, fontWeight: 600, marginBottom: 8 }}>{t('costs.ticketShares')}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{people.map(p => {
const share = ticketInfo.shares[p.id] || 0
@@ -1532,7 +1553,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
{sym(currency)}{(equalShares[p.id] || 0).toFixed(2)}
</span>
) : (
<span className="text-content-faint" style={{ fontSize: 12, textAlign: 'right', paddingRight: 10 }}>Excluded</span>
<span className="text-content-faint" style={{ fontSize: 12, textAlign: 'right', paddingRight: 10 }}>{t('costs.excluded')}</span>
)
) : (
on ? (
@@ -1557,9 +1578,13 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
</span>
) : (
<span style={{ fontWeight: 600, color: customBalanced ? '#16a34a' : '#dc2626' }}>
{customBalanced
? 'Split matches total'
: `Sum of splits: ${sym(currency)}${splitSum.toFixed(2)} of ${sym(currency)}${totalNum.toFixed(2)} (${(totalNum - splitSum) > 0 ? 'under by' : 'over by'} ${sym(currency)}${Math.abs(totalNum - splitSum).toFixed(2)})`}
{customBalanced
? t('costs.splitBalanced')
: t(totalNum - splitSum > 0 ? 'costs.splitSumUnder' : 'costs.splitSumOver', {
sum: sym(currency) + splitSum.toFixed(2),
total: sym(currency) + totalNum.toFixed(2),
diff: sym(currency) + Math.abs(totalNum - splitSum).toFixed(2),
})}
</span>
)}
</div>
+15 -1
View File
@@ -1,4 +1,4 @@
import { AlertTriangle, Check, CloudOff, RefreshCw } from 'lucide-react'
import { AlertTriangle, Check, CloudOff, Eye, RefreshCw } from 'lucide-react'
import type { BookRecord } from '@trek/shared'
import type { SaveState } from './useBookStore'
@@ -43,6 +43,20 @@ export function SaveIndicator({
)
}
if (state.status === 'readonly') {
/*
* Not an error and not retryable: this person was invited to read the
* journey, not to write it. Said once, plainly, and left standing — the
* alternative is an editor that looks like it is saving and is not.
*/
return (
<div className="st-save is-error" role="status">
<Eye size={13} />
<span>{t('journey.studio.saveReadOnly')}</span>
</div>
)
}
if (state.status === 'error') {
return (
<button className="st-save is-error" onClick={onRetry} title={t('journey.studio.saveRetry')}>
+28 -10
View File
@@ -7,9 +7,21 @@ import type { PeerCursor } from './useBookPresence'
import { FONT_STACKS } from './bookRender'
import { useSpreadInteraction, type HandleId } from './useSpreadInteraction'
import { useStudioStore } from '../../store/studioStore'
import { useTranslation } from '../../i18n'
const HANDLES: HandleId[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']
/**
* What a delete may actually take out of the selection.
*
* A locked element stays selectable on purpose, so the lock has to be honoured
* here the way the drag, resize and rotate gestures honour it. The store keeps
* removing whatever it is handed: the import and repair paths remove locked
* elements deliberately.
*/
const deletable = (spread: BookSpread | undefined, selection: string[]): string[] =>
(spread?.elements ?? []).filter(e => selection.includes(e.id) && !e.locked).map(e => e.id)
/**
* Where the four rotation handles sit, as percentages of the selection box.
*
@@ -66,6 +78,7 @@ export function StudioCanvas({
/** Where this one is, in the spread's millimetres. Null once it leaves. */
onCursor?: (x: number | null, y: number | null) => void
}) {
const { t } = useTranslation()
const selection = useStudioStore(s => s.selection)
const select = useStudioStore(s => s.select)
const removeElements = useStudioStore(s => s.removeElements)
@@ -123,12 +136,13 @@ export function StudioCanvas({
if (tag === 'INPUT' || tag === 'TEXTAREA') return
if ((e.key === 'Delete' || e.key === 'Backspace') && selection.length) {
e.preventDefault()
removeElements(spreadIndex, selection)
const ids = deletable(spread, selection)
if (ids.length) removeElements(spreadIndex, ids)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [selection, spreadIndex, removeElements])
}, [selection, spread, spreadIndex, removeElements])
if (!spread) return null
@@ -346,7 +360,7 @@ export function StudioCanvas({
className={`st-rotate is-${c.id}`}
style={{ left: c.left, top: c.top }}
onPointerDown={startRotate}
title="Rotate"
title={t('journey.studio.rotate')}
>
<RotateCw size={11} />
</div>
@@ -380,15 +394,15 @@ export function StudioCanvas({
style={{ left: (x0 + x1) / 2, top: Math.max(6, y0 - 12) }}
onPointerDown={e => e.stopPropagation()}
>
<button onClick={() => duplicate(spreadIndex, selection)} title="Duplicate">
<button onClick={() => duplicate(spreadIndex, selection)} title={t('journey.studio.duplicate')}>
<Copy size={14} />
</button>
{sel.length === 1 && (
<>
<button onClick={() => raise(spreadIndex, sel[0].id, 'front')} title="Bring to front">
<button onClick={() => raise(spreadIndex, sel[0].id, 'front')} title={t('journey.studio.toFront')}>
<ChevronsUp size={14} />
</button>
<button onClick={() => raise(spreadIndex, sel[0].id, 'back')} title="Send to back">
<button onClick={() => raise(spreadIndex, sel[0].id, 'back')} title={t('journey.studio.toBack')}>
<ChevronsDown size={14} />
</button>
</>
@@ -405,13 +419,13 @@ export function StudioCanvas({
*/}
<button
onClick={e => rotateBy(e.shiftKey ? -1 : -15)}
title="Rotate left"
title={t('journey.studio.rotateLeft')}
>
<RotateCcw size={14} />
</button>
<button
onClick={e => rotateBy(e.shiftKey ? 1 : 15)}
title="Rotate right"
title={t('journey.studio.rotateRight')}
>
<RotateCw size={14} />
</button>
@@ -424,12 +438,16 @@ export function StudioCanvas({
elements: sp.elements.map(e => (selection.includes(e.id) ? { ...e, locked: !locked } : e)),
})),
}))}
title={locked ? 'Unlock' : 'Lock'}
title={t(locked ? 'journey.studio.unlock' : 'journey.studio.lock')}
>
{locked ? <Unlock size={14} /> : <Lock size={14} />}
</button>
<span className="st-quickbar-sep" />
<button className="is-danger" onClick={() => removeElements(spreadIndex, selection)} title="Delete">
<button
className="is-danger"
onClick={() => { const ids = deletable(spread, selection); if (ids.length) removeElements(spreadIndex, ids) }}
title={t('journey.studio.delete')}
>
<Trash2 size={14} />
</button>
</div>
@@ -347,12 +347,12 @@ export function useMPlanTimeline(planner: TripPlanner) {
const activePlugins = usePluginStore(s => s.plugins)
const routeModeOptions = useMemo(() => {
const opts: Array<{ key: string; label: string }> = [
{ key: 'driving', label: 'Driving' },
{ key: 'walking', label: 'Walking' },
{ key: 'driving', label: t('mobileTrip.profileDriving') },
{ key: 'walking', label: t('mobileTrip.profileWalking') },
]
for (const p of activePlugins) for (const prof of p.routeProfiles ?? []) opts.push({ key: `plugin:${p.id}/${prof.id}`, label: prof.label })
return opts
}, [activePlugins])
}, [activePlugins, t])
// Set the mode of the leg leaving a stop — optimistic, then persisted; null clears
// the override back to the day default. Sticky against the whole-day picker.
@@ -607,6 +607,9 @@ describe('useMPlanTimeline', () => {
expect(planner.handleUpdateDayTitle).toHaveBeenCalledWith(2, 'Old Town')
})
// The two built-in modes were the only labels on this menu written in English
// instead of looked up, so a German reader got "Driving"/"Walking" between
// translated rows — and next to plugin profiles carrying their own label.
it('FE-MOB-PLTL-040: offers the built-in travel modes plus every plugin profile', async () => {
usePluginStore.setState({
plugins: [
@@ -616,8 +619,8 @@ describe('useMPlanTimeline', () => {
})
const { result } = await renderTimeline(makePlanner())
expect(result.current.routeModeOptions).toEqual([
{ key: 'driving', label: 'Driving' },
{ key: 'walking', label: 'Walking' },
{ key: 'driving', label: 'mobileTrip.profileDriving' },
{ key: 'walking', label: 'mobileTrip.profileWalking' },
{ key: 'plugin:ev/eco', label: 'Eco' },
{ key: 'plugin:ev/fast', label: 'Fast' },
])
@@ -0,0 +1,57 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen, act } from '../../helpers/render'
import { bookPageSetupSchema, bookSpreadSchema } from '@trek/shared'
import { seedStore } from '../../helpers/store'
import { buildSettings } from '../../helpers/factories'
import { useSettingsStore } from '../../../src/store/settingsStore'
import { useStudioStore } from '../../../src/store/studioStore'
import { StudioCanvas } from '../../../src/components/Studio/StudioCanvas'
const page = bookPageSetupSchema.parse({})
const spreadWith = (locked: boolean) => bookSpreadSchema.parse({
id: 'sp-1',
elements: [{ id: 'el-1', kind: 'shape', frame: { x: 10, y: 10, w: 40, h: 30 }, locked }],
})
/** Mount, then select the one element — the chrome only exists for a selection. */
const mountSelected = (locked = false) => {
render(
<StudioCanvas spread={spreadWith(locked)} spreadIndex={0} page={page} zoom={1} pxPerMm={2}
bookView={false} dropLabel="drop" />
)
act(() => { useStudioStore.setState({ selection: ['el-1'] }) })
}
describe('StudioCanvas', () => {
beforeEach(() => {
useStudioStore.setState({ selection: [] })
})
// The quickbar and the rotation handles were the last chrome in Studio with
// English written into the JSX, so they stayed English while the panels
// around them followed the reader's language.
it('FE-COMP-STUDIOCANVAS-001: the quickbar and the rotation handles are translated', async () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'de' }) })
mountSelected()
// The locale bundle is fetched, so the first paint is still English.
expect(await screen.findByTitle('Duplizieren')).toBeInTheDocument()
expect(screen.getAllByTitle('Drehen')).toHaveLength(4)
expect(screen.getByTitle('Nach links drehen')).toBeInTheDocument()
expect(screen.getByTitle('Nach rechts drehen')).toBeInTheDocument()
expect(screen.getByTitle('Ganz nach vorn')).toBeInTheDocument()
expect(screen.getByTitle('Ganz nach hinten')).toBeInTheDocument()
expect(screen.getByTitle('Sperren')).toBeInTheDocument()
expect(screen.getByTitle('Löschen')).toBeInTheDocument()
expect(screen.queryByTitle('Rotate')).not.toBeInTheDocument()
})
it('FE-COMP-STUDIOCANVAS-002: the lock button names the press that follows, in the same language', async () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'de' }) })
mountSelected(true)
expect(await screen.findByTitle('Entsperren')).toBeInTheDocument()
// A locked element keeps its handles hidden, so nothing rotates it by accident.
expect(screen.queryByTitle('Drehen')).not.toBeInTheDocument()
})
})
+1 -1
View File
@@ -339,7 +339,7 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:day-tint-provider':
'تلوين الأيام في خطة اليوم (مثلاً تحديد المرحلة التي ينتمي إليها كل يوم)',
'admin.plugins.perm.geolocation:read':
'الاستعلام عن موقعك المباشر ما دامت إحدى واجهاته مفتوحة (سيطلب متصفحك الإذن أيضًا)',
'الاستعلام عن موقعك المباشر ما دامت إحدى واجهاته مفتوحة (يقرأه TREK بإذن الموقع الممنوح لهذا الموقع الإلكتروني، لا بإذن خاص بالإضافة)',
'admin.plugins.perm.hook:pdf-section-provider': 'إضافة أقسام نصية إلى تصدير PDF للرحلة',
'admin.plugins.perm.hook:atlas-layer-provider':
'إبراز دول على خريطة أطلس العالمية (مثل قوائم الأمنيات أو تحذيرات السفر)',
+8
View File
@@ -138,5 +138,13 @@ const budget: TranslationStrings = {
'costs.filter.allCategories': 'كل الفئات',
'costs.filter.allDays': 'كل الأيام',
'costs.expensesCount': '{count} مصروف',
'costs.ticketItemName': 'اسم العنصر',
'costs.ticketSplitting': 'التقسيم:',
'costs.ticketAddItem': 'إضافة عنصر',
'costs.ticketShares': 'حصة كل شخص',
'costs.excluded': 'مستبعد',
'costs.splitBalanced': 'التقسيم يطابق الإجمالي',
'costs.splitSumUnder': 'مجموع الحصص: {sum} من {total} (ناقص {diff})',
'costs.splitSumOver': 'مجموع الحصص: {sum} من {total} (زائد {diff})',
};
export default budget;
+4
View File
@@ -588,5 +588,9 @@ const journey: TranslationStrings = {
'journey.studio.saveConflict': 'حفظ شخص آخر هذا الكتاب',
'journey.studio.saveTakeTheirs': 'نسختهم',
'journey.studio.saveKeepMine': 'نسختي',
'journey.studio.rotate': 'تدوير',
'journey.studio.rotateLeft': 'تدوير لليسار',
'journey.studio.rotateRight': 'تدوير لليمين',
'journey.studio.saveReadOnly': 'للقراءة فقط، لا يتم الحفظ',
};
export default journey;
+1 -1
View File
@@ -325,7 +325,7 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:day-tint-provider':
'Colorir os dias no plano do dia (por exemplo, a qual trecho da viagem um dia pertence)',
'admin.plugins.perm.geolocation:read':
'Consultar sua localização em tempo real enquanto uma das visualizações dele estiver aberta (seu navegador ainda pedirá permissão)',
'Consultar sua localização em tempo real enquanto uma das visualizações dele estiver aberta (o TREK a lê com a permissão de localização deste site, não com uma permissão própria do plugin)',
'admin.plugins.perm.hook:pdf-section-provider': 'Anexar seções de texto à exportação em PDF da viagem',
'admin.plugins.perm.hook:atlas-layer-provider':
'Destacar países no mapa-múndi do Atlas (ex. listas de desejos ou avisos de viagem)',
+8
View File
@@ -138,5 +138,13 @@ const budget: TranslationStrings = {
'costs.filter.allCategories': 'Todas as categorias',
'costs.filter.allDays': 'Todos os dias',
'costs.expensesCount': '{count} despesas',
'costs.ticketItemName': 'Nome do item',
'costs.ticketSplitting': 'Dividindo entre:',
'costs.ticketAddItem': 'Adicionar item',
'costs.ticketShares': 'Parte de cada um',
'costs.excluded': 'Fora',
'costs.splitBalanced': 'A divisão bate com o total',
'costs.splitSumUnder': 'Soma das partes: {sum} de {total} (faltam {diff})',
'costs.splitSumOver': 'Soma das partes: {sum} de {total} (sobram {diff})',
};
export default budget;
+4
View File
@@ -589,5 +589,9 @@ const journey: TranslationStrings = {
'journey.studio.saveConflict': 'Outra pessoa salvou este livro',
'journey.studio.saveTakeTheirs': 'A dela',
'journey.studio.saveKeepMine': 'A minha',
'journey.studio.rotate': 'Girar',
'journey.studio.rotateLeft': 'Girar à esquerda',
'journey.studio.rotateRight': 'Girar à direita',
'journey.studio.saveReadOnly': 'Somente leitura, nada é salvo',
};
export default journey;
+1 -1
View File
@@ -469,7 +469,7 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:day-tint-provider':
'Acolorir els dies del pla del dia (per exemple, a quin tram del viatge pertany un dia)',
'admin.plugins.perm.geolocation:read':
"Consultar la teva ubicació en temps real mentre una de les seves vistes és oberta (el teu navegador també t'ho demanarà)",
"Consultar la teva ubicació en temps real mentre una de les seves vistes és oberta (el TREK la llegeix amb el permís d'ubicació d'aquest lloc, no amb un permís propi del connector)",
'admin.plugins.updateConsentTitle': 'Aquesta actualització necessita permisos nous',
'admin.plugins.updateConsentBody':
"{name} v{version} demana permisos que encara no has concedit. La versió nova està instal·lada, però roman desactivada fins que l'aprovis.",
+8
View File
@@ -138,6 +138,14 @@ const budget: TranslationStrings = {
'costs.payersUnbalanced': 'Els imports dels pagadors han de sumar {amount}',
'costs.confirm.deleteTitle': 'Vols eliminar la despesa?',
'costs.confirm.deleteBody': '"{name}" quedarà eliminat permanentment.',
'costs.ticketItemName': "Nom de l'article",
'costs.ticketSplitting': 'Repartit entre:',
'costs.ticketAddItem': 'Afegeix un article',
'costs.ticketShares': 'Part de cadascú',
'costs.excluded': 'Exclòs',
'costs.splitBalanced': 'El repartiment quadra amb el total',
'costs.splitSumUnder': 'Suma de les parts: {sum} de {total} (en falten {diff})',
'costs.splitSumOver': 'Suma de les parts: {sum} de {total} ({diff} de més)',
};
export default budget;
+4
View File
@@ -589,5 +589,9 @@ const journey: TranslationStrings = {
'journey.studio.saveConflict': 'Una altra persona ha desat aquest llibre',
'journey.studio.saveTakeTheirs': 'La seva',
'journey.studio.saveKeepMine': 'La meva',
'journey.studio.rotate': 'Gira',
'journey.studio.rotateLeft': "Gira cap a l'esquerra",
'journey.studio.rotateRight': 'Gira cap a la dreta',
'journey.studio.saveReadOnly': 'Només lectura, no es desa',
};
export default journey;
+1 -1
View File
@@ -326,7 +326,7 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:day-tint-provider':
'Barevně odlišovat dny v denním plánu (například ke které části cesty den patří)',
'admin.plugins.perm.geolocation:read':
'Zjišťovat vaši aktuální polohu, dokud je otevřené některé z jeho zobrazení (váš prohlížeč se navíc zeptá sám)',
'Zjišťovat vaši aktuální polohu, dokud je otevřené některé z jeho zobrazení (TREK ji čte pod oprávněním k poloze pro tento web, ne pod vlastním oprávněním doplňku)',
'admin.plugins.perm.hook:pdf-section-provider': 'Připojovat textové sekce k PDF exportu cesty',
'admin.plugins.perm.hook:atlas-layer-provider':
'Zvýrazňovat země na mapě světa v Atlasu (např. seznamy přání nebo cestovní upozornění)',
+8
View File
@@ -138,5 +138,13 @@ const budget: TranslationStrings = {
'costs.filter.allCategories': 'Všechny kategorie',
'costs.filter.allDays': 'Všechny dny',
'costs.expensesCount': '{count} výdajů',
'costs.ticketItemName': 'Název položky',
'costs.ticketSplitting': 'Dělí se mezi:',
'costs.ticketAddItem': 'Přidat položku',
'costs.ticketShares': 'Podíl každého',
'costs.excluded': 'Nezahrnuto',
'costs.splitBalanced': 'Rozdělení sedí s celkem',
'costs.splitSumUnder': 'Součet podílů: {sum} z {total} (chybí {diff})',
'costs.splitSumOver': 'Součet podílů: {sum} z {total} (o {diff} více)',
};
export default budget;
+4
View File
@@ -589,5 +589,9 @@ const journey: TranslationStrings = {
'journey.studio.saveConflict': 'Tuto knihu uložil někdo jiný',
'journey.studio.saveTakeTheirs': 'Jejich verzi',
'journey.studio.saveKeepMine': 'Moji verzi',
'journey.studio.rotate': 'Otočit',
'journey.studio.rotateLeft': 'Otočit doleva',
'journey.studio.rotateRight': 'Otočit doprava',
'journey.studio.saveReadOnly': 'Jen ke čtení, neukládá se',
};
export default journey;
+1 -1
View File
@@ -331,7 +331,7 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:day-tint-provider':
'Tage im Tagesplan farblich markieren (z. B. zu welchem Reiseabschnitt ein Tag gehört)',
'admin.plugins.perm.geolocation:read':
'Deinen Live-Standort abfragen, solange eine seiner Ansichten offen ist (dein Browser fragt zusätzlich nach)',
'Deinen Live-Standort abfragen, solange eine seiner Ansichten offen ist (TREK liest ihn mit der Standortfreigabe dieser Seite, nicht mit einer eigenen des Plugins)',
'admin.plugins.perm.hook:pdf-section-provider': 'Textabschnitte an den PDF-Export einer Reise anhängen',
'admin.plugins.perm.hook:atlas-layer-provider':
'Länder auf der Atlas-Weltkarte hervorheben (z. B. Wunschlisten oder Reisehinweise)',
+8
View File
@@ -138,5 +138,13 @@ const budget: TranslationStrings = {
'costs.splitEqually': 'Gleichmäßig',
'costs.splitCustom': 'Individuell',
'costs.splitTicket': 'Beleg',
'costs.ticketItemName': 'Artikelname',
'costs.ticketSplitting': 'Aufteilen auf:',
'costs.ticketAddItem': 'Artikel hinzufügen',
'costs.ticketShares': 'Anteil pro Person',
'costs.excluded': 'Nicht dabei',
'costs.splitBalanced': 'Aufteilung passt zur Summe',
'costs.splitSumUnder': 'Summe der Anteile: {sum} von {total} (es fehlen {diff})',
'costs.splitSumOver': 'Summe der Anteile: {sum} von {total} ({diff} zu viel)',
};
export default budget;
+4
View File
@@ -589,5 +589,9 @@ const journey: TranslationStrings = {
'journey.studio.saveConflict': 'Jemand anderes hat dieses Buch gespeichert',
'journey.studio.saveTakeTheirs': 'Deren Fassung',
'journey.studio.saveKeepMine': 'Meine behalten',
'journey.studio.rotate': 'Drehen',
'journey.studio.rotateLeft': 'Nach links drehen',
'journey.studio.rotateRight': 'Nach rechts drehen',
'journey.studio.saveReadOnly': 'Nur lesen, nichts wird gespeichert',
};
export default journey;
+1 -1
View File
@@ -369,7 +369,7 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:day-tint-provider':
'Colour-code days in the day plan (e.g. which leg of the trip a day belongs to)',
'admin.plugins.perm.geolocation:read':
'Ask for your live position while one of its views is open (your browser will still ask first)',
"Ask for your live position while one of its views is open (TREK reads it under this site's location permission, not the plugin's own)",
'admin.plugins.perm.hook:pdf-section-provider': 'Append text sections to the trip PDF export',
'admin.plugins.perm.hook:atlas-layer-provider':
'Highlight countries on the Atlas world map (e.g. wishlists or travel advisories)',
+8
View File
@@ -138,6 +138,14 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': 'Total only — not settled yet',
'costs.tapToInclude': 'Tap to include',
'costs.amount': 'Amount',
'costs.ticketItemName': 'Item name',
'costs.ticketSplitting': 'Splitting:',
'costs.ticketAddItem': 'Add item',
'costs.ticketShares': 'Individual shares',
'costs.excluded': 'Excluded',
'costs.splitBalanced': 'Split matches total',
'costs.splitSumUnder': 'Sum of splits: {sum} of {total} (under by {diff})',
'costs.splitSumOver': 'Sum of splits: {sum} of {total} (over by {diff})',
};
export default budget;
+4
View File
@@ -589,5 +589,9 @@ const journey: TranslationStrings = {
'journey.studio.saveConflict': 'Someone else saved this book',
'journey.studio.saveTakeTheirs': 'Take theirs',
'journey.studio.saveKeepMine': 'Keep mine',
'journey.studio.rotate': 'Rotate',
'journey.studio.rotateLeft': 'Rotate left',
'journey.studio.rotateRight': 'Rotate right',
'journey.studio.saveReadOnly': 'Read-only, not saved',
};
export default journey;
+1 -1
View File
@@ -334,7 +334,7 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:day-tint-provider':
'Colorear los días del plan del día (por ejemplo, a qué tramo del viaje pertenece un día)',
'admin.plugins.perm.geolocation:read':
'Consultar tu ubicación en tiempo real mientras una de sus vistas esté abierta (tu navegador también pedirá permiso)',
'Consultar tu ubicación en tiempo real mientras una de sus vistas esté abierta (TREK la lee con el permiso de ubicación de este sitio, no con uno propio del plugin)',
'admin.plugins.perm.hook:pdf-section-provider': 'Añadir secciones de texto a la exportación PDF del viaje',
'admin.plugins.perm.hook:atlas-layer-provider':
'Resaltar países en el mapamundi del Atlas (p. ej. listas de deseos o avisos de viaje)',
+8
View File
@@ -138,5 +138,13 @@ const budget: TranslationStrings = {
'costs.filter.allCategories': 'Todas las categorías',
'costs.filter.allDays': 'Todos los días',
'costs.expensesCount': '{count} gastos',
'costs.ticketItemName': 'Nombre del artículo',
'costs.ticketSplitting': 'Se reparte entre:',
'costs.ticketAddItem': 'Añadir artículo',
'costs.ticketShares': 'Parte de cada uno',
'costs.excluded': 'Excluido',
'costs.splitBalanced': 'El reparto cuadra con el total',
'costs.splitSumUnder': 'Suma de las partes: {sum} de {total} (faltan {diff})',
'costs.splitSumOver': 'Suma de las partes: {sum} de {total} (sobran {diff})',
};
export default budget;
+4
View File
@@ -589,5 +589,9 @@ const journey: TranslationStrings = {
'journey.studio.saveConflict': 'Otra persona ha guardado este libro',
'journey.studio.saveTakeTheirs': 'La suya',
'journey.studio.saveKeepMine': 'La mía',
'journey.studio.rotate': 'Girar',
'journey.studio.rotateLeft': 'Girar a la izquierda',
'journey.studio.rotateRight': 'Girar a la derecha',
'journey.studio.saveReadOnly': 'Solo lectura, no se guarda',
};
export default journey;
+1 -1
View File
@@ -336,7 +336,7 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:day-tint-provider':
'Colorer les jours du plan de la journée (par exemple, à quelle étape du voyage appartient un jour)',
'admin.plugins.perm.geolocation:read':
"Demander votre position en temps réel tant que l'une de ses vues est ouverte (votre navigateur vous demandera d'abord l'autorisation)",
"Demander votre position en temps réel tant que l'une de ses vues est ouverte (TREK la lit avec l'autorisation de localisation de ce site, pas avec une autorisation propre à l'extension)",
'admin.plugins.perm.hook:pdf-section-provider': "Ajouter des sections de texte à l'export PDF du voyage",
'admin.plugins.perm.hook:atlas-layer-provider':
"Mettre en évidence des pays sur la carte du monde Atlas (p. ex. listes d'envies ou avis aux voyageurs)",
+8
View File
@@ -138,5 +138,13 @@ const budget: TranslationStrings = {
'costs.filter.allCategories': 'Toutes les catégories',
'costs.filter.allDays': 'Tous les jours',
'costs.expensesCount': '{count} dépenses',
'costs.ticketItemName': "Nom de l'article",
'costs.ticketSplitting': 'Partagé entre :',
'costs.ticketAddItem': 'Ajouter un article',
'costs.ticketShares': 'Part de chacun',
'costs.excluded': 'Exclu',
'costs.splitBalanced': 'La répartition correspond au total',
'costs.splitSumUnder': 'Somme des parts : {sum} sur {total} (il manque {diff})',
'costs.splitSumOver': 'Somme des parts : {sum} sur {total} ({diff} de trop)',
};
export default budget;
+4
View File
@@ -590,5 +590,9 @@ const journey: TranslationStrings = {
'journey.studio.saveConflict': 'Quelqu’un d’autre a enregistré ce livre',
'journey.studio.saveTakeTheirs': 'La leur',
'journey.studio.saveKeepMine': 'La mienne',
'journey.studio.rotate': 'Pivoter',
'journey.studio.rotateLeft': 'Pivoter à gauche',
'journey.studio.rotateRight': 'Pivoter à droite',
'journey.studio.saveReadOnly': "Lecture seule, rien n'est enregistré",
};
export default journey;
+1 -1
View File
@@ -384,7 +384,7 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:day-tint-provider':
'Χρωματική σήμανση ημερών στο πλάνο της ημέρας (π.χ. σε ποιο σκέλος του ταξιδιού ανήκει μια ημέρα)',
'admin.plugins.perm.geolocation:read':
'Αίτημα της τρέχουσας τοποθεσίας σας όσο μία από τις προβολές του είναι ανοιχτή (το πρόγραμμα περιήγησής σας θα ζητήσει επιπλέον επιβεβαίωση)',
'Αίτημα της τρέχουσας τοποθεσίας σας όσο μία από τις προβολές του είναι ανοιχτή (το TREK τη διαβάζει με την άδεια τοποθεσίας αυτού του ιστότοπου, όχι με δική του άδεια του πρόσθετου)',
'admin.plugins.perm.hook:pdf-section-provider': 'Προσθήκη ενοτήτων κειμένου στην εξαγωγή PDF του ταξιδιού',
'admin.plugins.perm.hook:atlas-layer-provider':
'Επισήμανση χωρών στον παγκόσμιο χάρτη του Άτλαντα (π.χ. λίστες επιθυμιών ή ταξιδιωτικές οδηγίες)',
+8
View File
@@ -139,5 +139,13 @@ const budget: TranslationStrings = {
'costs.filter.allCategories': 'Όλες οι κατηγορίες',
'costs.filter.allDays': 'Όλες οι ημέρες',
'costs.expensesCount': '{count} έξοδα',
'costs.ticketItemName': 'Όνομα είδους',
'costs.ticketSplitting': 'Μοιράζεται σε:',
'costs.ticketAddItem': 'Προσθήκη είδους',
'costs.ticketShares': 'Μερίδιο ανά άτομο',
'costs.excluded': 'Εκτός',
'costs.splitBalanced': 'Ο διαμοιρασμός ταιριάζει με το σύνολο',
'costs.splitSumUnder': 'Άθροισμα μεριδίων: {sum} από {total} (λείπουν {diff})',
'costs.splitSumOver': 'Άθροισμα μεριδίων: {sum} από {total} ({diff} παραπάνω)',
};
export default budget;
+4
View File
@@ -591,5 +591,9 @@ const journey: TranslationStrings = {
'journey.studio.saveConflict': 'Κάποιος άλλος αποθήκευσε αυτό το βιβλίο',
'journey.studio.saveTakeTheirs': 'Τη δική τους',
'journey.studio.saveKeepMine': 'Τη δική μου',
'journey.studio.rotate': 'Περιστροφή',
'journey.studio.rotateLeft': 'Περιστροφή αριστερά',
'journey.studio.rotateRight': 'Περιστροφή δεξιά',
'journey.studio.saveReadOnly': 'Μόνο για ανάγνωση, δεν αποθηκεύεται',
};
export default journey;
+1 -1
View File
@@ -337,7 +337,7 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:day-tint-provider':
'A napok színezése a napi tervben (például hogy az út melyik szakaszához tartozik egy nap)',
'admin.plugins.perm.geolocation:read':
'Az élő tartózkodási helyed lekérdezése, amíg valamelyik nézete nyitva van (a böngésződ ilyenkor is külön engedélyt kér)',
'Az élő tartózkodási helyed lekérdezése, amíg valamelyik nézete nyitva van (a TREK az oldal helymeghatározási engedélyével olvassa ki, nem a bővítmény saját engedélyével)',
'admin.plugins.perm.hook:pdf-section-provider': 'Szöveges szakaszok hozzáfűzése az utazás PDF-exportjához',
'admin.plugins.perm.hook:atlas-layer-provider':
'Országok kiemelése az Atlas világtérképén (pl. kívánságlisták vagy utazási figyelmeztetések)',
+8
View File
@@ -138,5 +138,13 @@ const budget: TranslationStrings = {
'costs.filter.allCategories': 'Összes kategória',
'costs.filter.allDays': 'Összes nap',
'costs.expensesCount': '{count} kiadás',
'costs.ticketItemName': 'Tétel neve',
'costs.ticketSplitting': 'Megosztva:',
'costs.ticketAddItem': 'Tétel hozzáadása',
'costs.ticketShares': 'Egyéni részek',
'costs.excluded': 'Kihagyva',
'costs.splitBalanced': 'A felosztás megegyezik a végösszeggel',
'costs.splitSumUnder': 'A részek összege: {sum} / {total} (hiányzik {diff})',
'costs.splitSumOver': 'A részek összege: {sum} / {total} ({diff} a többlet)',
};
export default budget;
+4
View File
@@ -589,5 +589,9 @@ const journey: TranslationStrings = {
'journey.studio.saveConflict': 'Ezt a könyvet valaki más mentette',
'journey.studio.saveTakeTheirs': 'Az övék',
'journey.studio.saveKeepMine': 'Az enyém',
'journey.studio.rotate': 'Forgatás',
'journey.studio.rotateLeft': 'Forgatás balra',
'journey.studio.rotateRight': 'Forgatás jobbra',
'journey.studio.saveReadOnly': 'Csak olvasható, nem mentődik',
};
export default journey;
@@ -0,0 +1,27 @@
/**
* The permission list has to describe what the install actually does.
*
* `geolocation:read` promised that the browser would ask before a plugin saw a
* position. It does not: the host reads the position on TREK's own origin, so
* whatever the reader already granted this site covers the plugin too, and the
* prompt they were told to wait for never comes. A consent screen that oversells
* the guard standing behind it is worse than one that says nothing.
*
* Scoped to `en/`, like the self-host guard next door: it is the source every
* translation is made from, and a translation still carrying the older phrasing
* is a translation lagging behind rather than a broken build.
*/
import { describe, it, expect } from 'vitest';
import admin from './en/admin';
describe('canonical permission wording', () => {
it('I18N-PERM-001: the geolocation permission does not promise a browser prompt', () => {
const copy = admin['admin.plugins.perm.geolocation:read'];
expect(copy).toBeTruthy();
expect(copy).not.toMatch(/browser will|ask first|still ask/i);
// It has to say where the position does come from, or it explains nothing.
expect(copy).toMatch(/permission/i);
});
});
+1 -1
View File
@@ -381,7 +381,7 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:day-tint-provider':
'Memberi warna pada hari di rencana hari (misalnya bagian perjalanan mana yang memuat suatu hari)',
'admin.plugins.perm.geolocation:read':
'Meminta lokasi terkinimu selama salah satu tampilannya terbuka (browser-mu tetap akan meminta izin terlebih dahulu)',
'Meminta lokasi terkinimu selama salah satu tampilannya terbuka (TREK membacanya dengan izin lokasi situs ini, bukan izin milik plugin sendiri)',
'admin.plugins.perm.hook:pdf-section-provider': 'Menambahkan bagian teks ke ekspor PDF perjalanan',
'admin.plugins.perm.hook:atlas-layer-provider':
'Menyorot negara di peta dunia Atlas (mis. daftar keinginan atau peringatan perjalanan)',
+8
View File
@@ -138,5 +138,13 @@ const budget: TranslationStrings = {
'costs.filter.allCategories': 'Semua kategori',
'costs.filter.allDays': 'Semua hari',
'costs.expensesCount': '{count} pengeluaran',
'costs.ticketItemName': 'Nama item',
'costs.ticketSplitting': 'Dibagi dengan:',
'costs.ticketAddItem': 'Tambah item',
'costs.ticketShares': 'Bagian tiap orang',
'costs.excluded': 'Tidak termasuk',
'costs.splitBalanced': 'Pembagian sudah pas dengan total',
'costs.splitSumUnder': 'Jumlah bagian: {sum} dari {total} (kurang {diff})',
'costs.splitSumOver': 'Jumlah bagian: {sum} dari {total} (lebih {diff})',
};
export default budget;
+4
View File
@@ -588,5 +588,9 @@ const journey: TranslationStrings = {
'journey.studio.saveConflict': 'Orang lain menyimpan buku ini',
'journey.studio.saveTakeTheirs': 'Punya mereka',
'journey.studio.saveKeepMine': 'Punya saya',
'journey.studio.rotate': 'Putar',
'journey.studio.rotateLeft': 'Putar ke kiri',
'journey.studio.rotateRight': 'Putar ke kanan',
'journey.studio.saveReadOnly': 'Hanya baca, tidak disimpan',
};
export default journey;
+1 -1
View File
@@ -333,7 +333,7 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:day-tint-provider':
'Colorare i giorni nel piano della giornata (ad esempio a quale tratta del viaggio appartiene un giorno)',
'admin.plugins.perm.geolocation:read':
'Richiedere la tua posizione in tempo reale mentre una delle sue viste è aperta (il tuo browser chiederà comunque conferma)',
'Richiedere la tua posizione in tempo reale mentre una delle sue viste è aperta (TREK la legge con il permesso di posizione di questo sito, non con uno proprio del plugin)',
'admin.plugins.perm.hook:pdf-section-provider': "Aggiungere sezioni di testo all'esportazione PDF del viaggio",
'admin.plugins.perm.hook:atlas-layer-provider':
'Evidenziare paesi sulla mappa del mondo di Atlas (es. liste dei desideri o avvisi di viaggio)',
+8
View File
@@ -138,5 +138,13 @@ const budget: TranslationStrings = {
'costs.filter.allCategories': 'Tutte le categorie',
'costs.filter.allDays': 'Tutti i giorni',
'costs.expensesCount': '{count} spese',
'costs.ticketItemName': "Nome dell'articolo",
'costs.ticketSplitting': 'Diviso tra:',
'costs.ticketAddItem': 'Aggiungi articolo',
'costs.ticketShares': 'Quota di ciascuno',
'costs.excluded': 'Escluso',
'costs.splitBalanced': 'La divisione corrisponde al totale',
'costs.splitSumUnder': 'Somma delle quote: {sum} di {total} (mancano {diff})',
'costs.splitSumOver': 'Somma delle quote: {sum} di {total} ({diff} in più)',
};
export default budget;
+4
View File
@@ -589,5 +589,9 @@ const journey: TranslationStrings = {
'journey.studio.saveConflict': 'Qualcun altro ha salvato questo libro',
'journey.studio.saveTakeTheirs': 'La loro',
'journey.studio.saveKeepMine': 'La mia',
'journey.studio.rotate': 'Ruota',
'journey.studio.rotateLeft': 'Ruota a sinistra',
'journey.studio.rotateRight': 'Ruota a destra',
'journey.studio.saveReadOnly': 'Sola lettura, non si salva',
};
export default journey;
+1 -1
View File
@@ -348,7 +348,7 @@ const admin: TranslationStrings = {
'日程プランに時間エントリーを追加(充電ストップ、保安検査のバッファ)',
'admin.plugins.perm.hook:day-tint-provider': '日程プランの日付を色分け(例:その日が旅程のどの区間に属するか)',
'admin.plugins.perm.geolocation:read':
'このプラグインのビューが開いている間、あなたの現在地を取得(ブラウザでも改めて確認が求められます)',
'このプラグインのビューが開いている間、あなたの現在地を取得(TREK がこのサイトの位置情報の許可を使って読み取ります。プラグイン専用の許可は求められません)',
'admin.plugins.perm.hook:pdf-section-provider': '旅行の PDF エクスポートにテキストセクションを追加',
'admin.plugins.perm.hook:atlas-layer-provider': 'Atlas の世界地図で国をハイライト(例: ウィッシュリストや渡航情報)',
'admin.plugins.perm.hook:journal-entry-provider': '日記のエントリーに追加の行(リンク、統計)を提供',
+8
View File
@@ -138,5 +138,13 @@ const budget: TranslationStrings = {
'costs.filter.allCategories': 'すべてのカテゴリー',
'costs.filter.allDays': 'すべての日',
'costs.expensesCount': '{count}件の支出',
'costs.ticketItemName': '品目名',
'costs.ticketSplitting': '分割:',
'costs.ticketAddItem': '品目を追加',
'costs.ticketShares': '一人あたりの負担',
'costs.excluded': '対象外',
'costs.splitBalanced': '分割は合計と一致しています',
'costs.splitSumUnder': '分割の合計: {total} のうち {sum}({diff} 不足)',
'costs.splitSumOver': '分割の合計: {total} のうち {sum}({diff} 超過)',
};
export default budget;
+4
View File
@@ -590,5 +590,9 @@ const journey: TranslationStrings = {
'journey.studio.saveConflict': 'ほかの人がこの本を保存しました',
'journey.studio.saveTakeTheirs': '相手の版',
'journey.studio.saveKeepMine': '自分の版',
'journey.studio.rotate': '回転',
'journey.studio.rotateLeft': '左に回転',
'journey.studio.rotateRight': '右に回転',
'journey.studio.saveReadOnly': '閲覧のみ・保存されません',
};
export default journey;
+1 -1
View File
@@ -349,7 +349,7 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:day-schedule-provider': '일자 계획에 시간 항목 추가(충전 정차, 보안 검색 여유 시간)',
'admin.plugins.perm.hook:day-tint-provider': '일자 계획에서 날짜를 색으로 구분(예: 해당 날짜가 여행의 어느 구간인지)',
'admin.plugins.perm.geolocation:read':
'해당 플러그인 화면이 열려 있는 동안 실시간 위치 요청 (브라우저에서 추가로 확인함)',
'해당 플러그인 화면이 열려 있는 동안 실시간 위치 요청 (TREK이 이 사이트에 부여된 위치 권한으로 읽으며, 플러그인용 권한을 따로 묻지 않음)',
'admin.plugins.perm.hook:pdf-section-provider': '여행 PDF 내보내기에 텍스트 섹션 추가',
'admin.plugins.perm.hook:atlas-layer-provider': 'Atlas 세계 지도에서 국가 강조 표시(예: 위시리스트 또는 여행 경보)',
'admin.plugins.perm.hook:journal-entry-provider': '저널 항목에 추가 행(링크, 통계) 제공',
+8
View File
@@ -138,5 +138,13 @@ const budget: TranslationStrings = {
'costs.filter.allCategories': '모든 카테고리',
'costs.filter.allDays': '모든 날짜',
'costs.expensesCount': '지출 {count}건',
'costs.ticketItemName': '항목 이름',
'costs.ticketSplitting': '나눔:',
'costs.ticketAddItem': '항목 추가',
'costs.ticketShares': '개인별 부담',
'costs.excluded': '제외됨',
'costs.splitBalanced': '분할 금액이 합계와 일치합니다',
'costs.splitSumUnder': '분할 합계: {total} 중 {sum} ({diff} 부족)',
'costs.splitSumOver': '분할 합계: {total} 중 {sum} ({diff} 초과)',
};
export default budget;
+4
View File
@@ -589,5 +589,9 @@ const journey: TranslationStrings = {
'journey.studio.saveConflict': '다른 사람이 이 책을 저장했습니다',
'journey.studio.saveTakeTheirs': '상대 버전',
'journey.studio.saveKeepMine': '내 버전',
'journey.studio.rotate': '회전',
'journey.studio.rotateLeft': '왼쪽으로 회전',
'journey.studio.rotateRight': '오른쪽으로 회전',
'journey.studio.saveReadOnly': '읽기 전용, 저장되지 않음',
};
export default journey;
+1 -1
View File
@@ -332,7 +332,7 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:day-tint-provider':
'Dagen in het dagplan een kleur geven (bijvoorbeeld bij welk deel van de reis een dag hoort)',
'admin.plugins.perm.geolocation:read':
'Je live locatie opvragen zolang een van zijn weergaven open is (je browser vraagt zelf ook nog om toestemming)',
'Je live locatie opvragen zolang een van zijn weergaven open is (TREK leest die met de locatietoestemming van deze site, niet met een eigen toestemming van de plug-in)',
'admin.plugins.perm.hook:pdf-section-provider': 'Tekstsecties toevoegen aan de PDF-export van de reis',
'admin.plugins.perm.hook:atlas-layer-provider':
'Landen markeren op de Atlas-wereldkaart (bijv. wensenlijsten of reisadviezen)',
+8
View File
@@ -138,5 +138,13 @@ const budget: TranslationStrings = {
'costs.filter.allCategories': 'Alle categorieën',
'costs.filter.allDays': 'Alle dagen',
'costs.expensesCount': '{count} uitgaven',
'costs.ticketItemName': 'Naam van het item',
'costs.ticketSplitting': 'Verdeeld over:',
'costs.ticketAddItem': 'Item toevoegen',
'costs.ticketShares': 'Aandeel per persoon',
'costs.excluded': 'Niet inbegrepen',
'costs.splitBalanced': 'Verdeling klopt met het totaal',
'costs.splitSumUnder': 'Som van de delen: {sum} van {total} ({diff} te weinig)',
'costs.splitSumOver': 'Som van de delen: {sum} van {total} ({diff} te veel)',
};
export default budget;
+4
View File
@@ -590,5 +590,9 @@ const journey: TranslationStrings = {
'journey.studio.saveConflict': 'Iemand anders heeft dit boek opgeslagen',
'journey.studio.saveTakeTheirs': 'De hunne',
'journey.studio.saveKeepMine': 'De mijne',
'journey.studio.rotate': 'Draaien',
'journey.studio.rotateLeft': 'Naar links draaien',
'journey.studio.rotateRight': 'Naar rechts draaien',
'journey.studio.saveReadOnly': 'Alleen lezen, wordt niet opgeslagen',
};
export default journey;
+1 -1
View File
@@ -328,7 +328,7 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:day-tint-provider':
'Oznaczanie dni w planie dnia kolorami (na przykład do którego etapu podróży należy dany dzień)',
'admin.plugins.perm.geolocation:read':
'Odczytywanie Twojej aktualnej lokalizacji, gdy otwarty jest któryś z jego widoków (przeglądarka i tak zapyta o zgodę)',
'Odczytywanie Twojej aktualnej lokalizacji, gdy otwarty jest któryś z jego widoków (TREK odczytuje ją z uprawnieniem do lokalizacji tej witryny, a nie z własnym uprawnieniem wtyczki)',
'admin.plugins.perm.hook:pdf-section-provider': 'Dołączanie sekcji tekstowych do eksportu PDF podróży',
'admin.plugins.perm.hook:atlas-layer-provider':
'Wyróżnianie krajów na mapie świata Atlasu (np. listy życzeń lub ostrzeżenia dla podróżnych)',
+8
View File
@@ -138,5 +138,13 @@ const budget: TranslationStrings = {
'costs.filter.allCategories': 'Wszystkie kategorie',
'costs.filter.allDays': 'Wszystkie dni',
'costs.expensesCount': '{count} wydatków',
'costs.ticketItemName': 'Nazwa pozycji',
'costs.ticketSplitting': 'Dzielone między:',
'costs.ticketAddItem': 'Dodaj pozycję',
'costs.ticketShares': 'Udział każdej osoby',
'costs.excluded': 'Pominięty',
'costs.splitBalanced': 'Podział zgadza się z sumą',
'costs.splitSumUnder': 'Suma podziałów: {sum} z {total} (brakuje {diff})',
'costs.splitSumOver': 'Suma podziałów: {sum} z {total} (o {diff} za dużo)',
};
export default budget;
+4
View File
@@ -589,5 +589,9 @@ const journey: TranslationStrings = {
'journey.studio.saveConflict': 'Ktoś inny zapisał tę książkę',
'journey.studio.saveTakeTheirs': 'Ich wersję',
'journey.studio.saveKeepMine': 'Moją wersję',
'journey.studio.rotate': 'Obróć',
'journey.studio.rotateLeft': 'Obróć w lewo',
'journey.studio.rotateRight': 'Obróć w prawo',
'journey.studio.saveReadOnly': 'Tylko do odczytu, nie zapisuje się',
};
export default journey;
+1 -1
View File
@@ -333,7 +333,7 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:day-tint-provider':
'Отмечать дни в плане цветом (например, к какому этапу поездки относится день)',
'admin.plugins.perm.geolocation:read':
'Запрашивать ваше местоположение в реальном времени, пока открыто одно из его представлений (ваш браузер всё равно спросит разрешение)',
'Запрашивать ваше местоположение в реальном времени, пока открыто одно из его представлений (TREK читает его с разрешением на геолокацию для этого сайта, а не с отдельным разрешением плагина)',
'admin.plugins.perm.hook:pdf-section-provider': 'Добавлять текстовые разделы в PDF-экспорт поездки',
'admin.plugins.perm.hook:atlas-layer-provider':
'Выделять страны на карте мира в Атласе (например, списки желаний или предупреждения для путешественников)',
+8
View File
@@ -138,5 +138,13 @@ const budget: TranslationStrings = {
'costs.filter.allCategories': 'Все категории',
'costs.filter.allDays': 'Все дни',
'costs.expensesCount': 'Расходов: {count}',
'costs.ticketItemName': 'Название позиции',
'costs.ticketSplitting': 'Делится между:',
'costs.ticketAddItem': 'Добавить позицию',
'costs.ticketShares': 'Доля каждого',
'costs.excluded': 'Не участвует',
'costs.splitBalanced': 'Разделение совпадает с итогом',
'costs.splitSumUnder': 'Сумма долей: {sum} из {total} (не хватает {diff})',
'costs.splitSumOver': 'Сумма долей: {sum} из {total} (больше на {diff})',
};
export default budget;
+4
View File
@@ -589,5 +589,9 @@ const journey: TranslationStrings = {
'journey.studio.saveConflict': 'Эту книгу сохранил кто-то ещё',
'journey.studio.saveTakeTheirs': 'Их версию',
'journey.studio.saveKeepMine': 'Мою версию',
'journey.studio.rotate': 'Повернуть',
'journey.studio.rotateLeft': 'Повернуть влево',
'journey.studio.rotateRight': 'Повернуть вправо',
'journey.studio.saveReadOnly': 'Только чтение, не сохраняется',
};
export default journey;
+1 -1
View File
@@ -387,7 +387,7 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:day-tint-provider':
'Färgkoda dagar i dagsplanen (till exempel vilken etapp av resan en dag tillhör)',
'admin.plugins.perm.geolocation:read':
'Fråga efter din position i realtid medan en av dess vyer är öppen (din webbläsare frågar dessutom först)',
'Fråga efter din position i realtid medan en av dess vyer är öppen (TREK läser den med den här webbplatsens platsbehörighet, inte med en egen behörighet för tillägget)',
'admin.plugins.perm.hook:pdf-section-provider': 'Lägga till textavsnitt i resans PDF-export',
'admin.plugins.perm.hook:atlas-layer-provider':
'Markera länder på Atlas-världskartan (t.ex. önskelistor eller resevarningar)',
+8
View File
@@ -138,5 +138,13 @@ const budget: TranslationStrings = {
'costs.filter.allCategories': 'Alla kategorier',
'costs.filter.allDays': 'Alla dagar',
'costs.expensesCount': '{count} utgifter',
'costs.ticketItemName': 'Artikelnamn',
'costs.ticketSplitting': 'Delas mellan:',
'costs.ticketAddItem': 'Lägg till artikel',
'costs.ticketShares': 'Andel per person',
'costs.excluded': 'Inte med',
'costs.splitBalanced': 'Fördelningen stämmer med totalen',
'costs.splitSumUnder': 'Summan av delarna: {sum} av {total} ({diff} saknas)',
'costs.splitSumOver': 'Summan av delarna: {sum} av {total} ({diff} för mycket)',
};
export default budget;
+4
View File
@@ -589,5 +589,9 @@ const journey: TranslationStrings = {
'journey.studio.saveConflict': 'Någon annan har sparat den här boken',
'journey.studio.saveTakeTheirs': 'Deras',
'journey.studio.saveKeepMine': 'Min',
'journey.studio.rotate': 'Rotera',
'journey.studio.rotateLeft': 'Rotera åt vänster',
'journey.studio.rotateRight': 'Rotera åt höger',
'journey.studio.saveReadOnly': 'Skrivskyddad, sparas inte',
};
export default journey;
+1 -1
View File
@@ -377,7 +377,7 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:day-tint-provider':
'Gün planındaki günleri renklendirir (örneğin bir günün yolculuğun hangi etabına ait olduğu)',
'admin.plugins.perm.geolocation:read':
'Görünümlerinden biri açıkken canlı konumunu sorar (tarayıcın ayrıca izin ister)',
'Görünümlerinden biri açıkken canlı konumunu sorar (TREK konumu bu sitenin konum izniyle okur, eklenti için ayrıca izin sorulmaz)',
'admin.plugins.perm.hook:pdf-section-provider': 'Seyahat PDF dışa aktarımına metin bölümleri ekler',
'admin.plugins.perm.hook:atlas-layer-provider':
'Atlas dünya haritasında ülkeleri vurgular (ör. istek listeleri veya seyahat uyarıları)',
+8
View File
@@ -138,5 +138,13 @@ const budget: TranslationStrings = {
'costs.filter.allCategories': 'Tüm kategoriler',
'costs.filter.allDays': 'Tüm günler',
'costs.expensesCount': '{count} harcama',
'costs.ticketItemName': 'Kalem adı',
'costs.ticketSplitting': 'Bölüşülüyor:',
'costs.ticketAddItem': 'Kalem ekle',
'costs.ticketShares': 'Kişi başı pay',
'costs.excluded': 'Dahil değil',
'costs.splitBalanced': 'Bölüşüm toplamla eşleşiyor',
'costs.splitSumUnder': 'Payların toplamı: {total} tutarın {sum} kadarı ({diff} eksik)',
'costs.splitSumOver': 'Payların toplamı: {total} tutarın {sum} kadarı ({diff} fazla)',
};
export default budget;
+4
View File
@@ -589,5 +589,9 @@ const journey: TranslationStrings = {
'journey.studio.saveConflict': 'Bu kitabı başka biri kaydetti',
'journey.studio.saveTakeTheirs': 'Onlarınki',
'journey.studio.saveKeepMine': 'Benimki',
'journey.studio.rotate': 'Döndür',
'journey.studio.rotateLeft': 'Sola döndür',
'journey.studio.rotateRight': 'Sağa döndür',
'journey.studio.saveReadOnly': 'Salt okunur, kaydedilmiyor',
};
export default journey;
+1 -1
View File
@@ -330,7 +330,7 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:day-tint-provider':
'Позначати дні в плані дня кольором (наприклад, до якого етапу подорожі належить день)',
'admin.plugins.perm.geolocation:read':
'Запитувати ваше місцезнаходження в реальному часі, поки відкрито одне з його подань (браузер додатково запитає дозвіл)',
'Запитувати ваше місцезнаходження в реальному часі, поки відкрито одне з його подань (TREK зчитує його з дозволом на геолокацію для цього сайту, а не з окремим дозволом розширення)',
'admin.plugins.perm.hook:pdf-section-provider': 'Додавати текстові розділи до PDF-експорту подорожі',
'admin.plugins.perm.hook:atlas-layer-provider':
'Виділяти країни на карті світу в Атласі (наприклад, списки бажань або попередження для мандрівників)',
+8
View File
@@ -138,5 +138,13 @@ const budget: TranslationStrings = {
'costs.filter.allCategories': 'Усі категорії',
'costs.filter.allDays': 'Усі дні',
'costs.expensesCount': '{count} витрат',
'costs.ticketItemName': 'Назва позиції',
'costs.ticketSplitting': 'Ділиться між:',
'costs.ticketAddItem': 'Додати позицію',
'costs.ticketShares': 'Частка кожного',
'costs.excluded': 'Не бере участі',
'costs.splitBalanced': 'Розподіл збігається із сумою',
'costs.splitSumUnder': 'Сума часток: {sum} з {total} (бракує {diff})',
'costs.splitSumOver': 'Сума часток: {sum} з {total} (більше на {diff})',
};
export default budget;
+4
View File
@@ -590,5 +590,9 @@ const journey: TranslationStrings = {
'journey.studio.saveConflict': 'Цю книгу зберіг хтось інший',
'journey.studio.saveTakeTheirs': 'Їхню версію',
'journey.studio.saveKeepMine': 'Мою версію',
'journey.studio.rotate': 'Повернути',
'journey.studio.rotateLeft': 'Повернути ліворуч',
'journey.studio.rotateRight': 'Повернути праворуч',
'journey.studio.saveReadOnly': 'Лише для читання, не зберігається',
};
export default journey;
+1 -1
View File
@@ -330,7 +330,7 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:day-tint-provider':
'Tô màu các ngày trong kế hoạch ngày (ví dụ ngày đó thuộc chặng nào của chuyến đi)',
'admin.plugins.perm.geolocation:read':
'Hỏi vị trí trực tiếp của bạn khi một trong các chế độ xem của nó đang mở (trình duyệt của bạn vẫn sẽ hỏi trước)',
'Hỏi vị trí trực tiếp của bạn khi một trong các chế độ xem của nó đang mở (TREK đọc vị trí bằng quyền vị trí của trang này, không phải quyền riêng của plugin)',
'admin.plugins.perm.hook:pdf-section-provider': 'Thêm các phần văn bản vào bản xuất PDF của chuyến đi',
'admin.plugins.perm.hook:atlas-layer-provider':
'Làm nổi bật các quốc gia trên bản đồ thế giới Atlas (vd. danh sách mong muốn hoặc cảnh báo du lịch)',
+8
View File
@@ -138,6 +138,14 @@ const budget: TranslationStrings = {
'costs.filter.allCategories': 'Tất cả danh mục',
'costs.filter.allDays': 'Tất cả các ngày',
'costs.expensesCount': '{count} chi phí',
'costs.ticketItemName': 'Tên món',
'costs.ticketSplitting': 'Chia cho:',
'costs.ticketAddItem': 'Thêm món',
'costs.ticketShares': 'Phần của từng người',
'costs.excluded': 'Không tính',
'costs.splitBalanced': 'Phần chia khớp với tổng',
'costs.splitSumUnder': 'Tổng các phần: {sum} trên {total} (thiếu {diff})',
'costs.splitSumOver': 'Tổng các phần: {sum} trên {total} (thừa {diff})',
};
export default budget;
+4
View File
@@ -589,5 +589,9 @@ const journey: TranslationStrings = {
'journey.studio.saveConflict': 'Người khác đã lưu cuốn sách này',
'journey.studio.saveTakeTheirs': 'Bản của họ',
'journey.studio.saveKeepMine': 'Bản của tôi',
'journey.studio.rotate': 'Xoay',
'journey.studio.rotateLeft': 'Xoay trái',
'journey.studio.rotateRight': 'Xoay phải',
'journey.studio.saveReadOnly': 'Chỉ đọc, không lưu',
};
export default journey;
+1 -1
View File
@@ -335,7 +335,7 @@ const admin: TranslationStrings = {
'提供路線規劃設定檔,供規劃工具計算每日路線(例如含充電停靠站的電動車路線規劃)',
'admin.plugins.perm.hook:day-schedule-provider': '向每日行程附加時間項目(充電停靠站、緩衝時間)',
'admin.plugins.perm.hook:day-tint-provider': '為每日行程中的日期標示顏色(例如某天屬於旅程的哪一段)',
'admin.plugins.perm.geolocation:read': '在其任一檢視開啟時查詢你的即時位置(你的瀏覽器仍會先詢問)',
'admin.plugins.perm.geolocation:read': '在其任一檢視開啟時查詢你的即時位置(由 TREK 使用本站台既有的位置權限讀取,不會另外為外掛詢問)',
'admin.plugins.perm.hook:pdf-section-provider': '向行程 PDF 匯出附加文字段落',
'admin.plugins.perm.hook:atlas-layer-provider': '在 Atlas 世界地圖上標示國家(例如願望清單或旅遊警示)',
'admin.plugins.perm.hook:journal-entry-provider': '向日誌條目新增額外列(連結、統計)',
+8
View File
@@ -137,5 +137,13 @@ const budget: TranslationStrings = {
'costs.filter.allCategories': '所有類別',
'costs.filter.allDays': '所有日期',
'costs.expensesCount': '{count} 筆支出',
'costs.ticketItemName': '品項名稱',
'costs.ticketSplitting': '分攤:',
'costs.ticketAddItem': '新增品項',
'costs.ticketShares': '每人分攤額',
'costs.excluded': '不參與',
'costs.splitBalanced': '分攤金額與總額相符',
'costs.splitSumUnder': '分攤合計:{total} 中的 {sum}(少 {diff})',
'costs.splitSumOver': '分攤合計:{total} 中的 {sum}(多 {diff})',
};
export default budget;
+4
View File
@@ -587,5 +587,9 @@ const journey: TranslationStrings = {
'journey.studio.saveConflict': '其他人儲存了這本書',
'journey.studio.saveTakeTheirs': '用他們的',
'journey.studio.saveKeepMine': '用我的',
'journey.studio.rotate': '旋轉',
'journey.studio.rotateLeft': '向左旋轉',
'journey.studio.rotateRight': '向右旋轉',
'journey.studio.saveReadOnly': '唯讀,不會儲存',
};
export default journey;
+1 -1
View File
@@ -293,7 +293,7 @@ const admin: TranslationStrings = {
'admin.plugins.perm.hook:route-provider': '提供路线规划方案,供规划器计算每日路线(例如带充电停靠点的电动车路线)',
'admin.plugins.perm.hook:day-schedule-provider': '向当日计划添加时间条目(充电停靠点、安全缓冲时间)',
'admin.plugins.perm.hook:day-tint-provider': '为当日计划中的日期标注颜色(例如某天属于行程的哪一段)',
'admin.plugins.perm.geolocation:read': '在其某个视图打开时请求你的实时位置(你的浏览器仍会先询问)',
'admin.plugins.perm.geolocation:read': '在其某个视图打开时请求你的实时位置(由 TREK 使用本站点已有的位置权限读取,不会单独为插件询问)',
'admin.plugins.perm.hook:pdf-section-provider': '向行程 PDF 导出追加文本段落',
'admin.plugins.perm.hook:atlas-layer-provider': '在 Atlas 世界地图上高亮国家(例如愿望清单或旅行提醒)',
'admin.plugins.perm.hook:journal-entry-provider': '向日志条目添加额外行(链接、统计)',
+8
View File
@@ -137,5 +137,13 @@ const budget: TranslationStrings = {
'costs.filter.allCategories': '所有类别',
'costs.filter.allDays': '所有日期',
'costs.expensesCount': '{count} 笔支出',
'costs.ticketItemName': '项目名称',
'costs.ticketSplitting': '分摊:',
'costs.ticketAddItem': '添加项目',
'costs.ticketShares': '每人份额',
'costs.excluded': '不参与',
'costs.splitBalanced': '分摊金额与总额相符',
'costs.splitSumUnder': '分摊合计:{total} 中的 {sum}(少 {diff})',
'costs.splitSumOver': '分摊合计:{total} 中的 {sum}(多 {diff})',
};
export default budget;
+4
View File
@@ -587,5 +587,9 @@ const journey: TranslationStrings = {
'journey.studio.saveConflict': '其他人保存了这本书',
'journey.studio.saveTakeTheirs': '用他们的',
'journey.studio.saveKeepMine': '用我的',
'journey.studio.rotate': '旋转',
'journey.studio.rotateLeft': '向左旋转',
'journey.studio.rotateRight': '向右旋转',
'journey.studio.saveReadOnly': '只读,不会保存',
};
export default journey;