mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(i18n): every language says one file and five files its own way
Finishes the plural work the previous wip commit started. t() picks a count string's form by the language's own Intl.PluralRules, and every locale now spells out the forms its language reaches with whole numbers, about 160 count strings in all: one, few and many in Russian, Ukrainian and Polish, one and few in Czech and Slovak, five forms in Arabic, one in the others, none in Japanese, Korean, Chinese, Thai, Vietnamese and Indonesian. Each language was translated and then reviewed on its own. English no longer reads "1 files", "1 updates available" or "photo(s)". Count labels shown next to a number now take that number too: the atlas and admin statistics, the trip cards, journey and shared trip pills, packing templates, receipts, storage usage, the Studio export note. A heading that shared its key with a counted label keeps a key of its own (the boarding pass heading, the Bookings tab), so languages that put a noun after a number in the singular keep a plural heading. The plugin hosts chip passed its count through .replace() after t() and never got a form; three toasts sent the count as text. Mail and push texts for shared photos and suppressed storage failures pick their form in code through pluralForm. A whole number sent as text counts too, which is how in-app notification params arrive, and the plural rule follows the language of the strings on screen while another one still loads. The parity check knows which forms each language needs and refuses missing and impossible ones. It also reads keys that contain a colon now: it skipped them, which hid 58 missing plugin permission texts in Catalan and 35 in Vietnamese, both translated. The client parity test covers every language of the registry instead of a hand-kept list of 19, and the placeholder spec lets a form spell its number out only where its category is that one number. Brazilian Portuguese error screens were in Breton, and an Arabic dashboard label held Hebrew text.
This commit is contained in:
@@ -85,6 +85,7 @@ Offline-first, with a layered data flow. A Page never owns state directly:
|
||||
|
||||
- A route is "done" only once its contract lives in `shared/` and both sides import the inferred types. Edit the Zod schema, rebuild shared, then server (validation + DTO types) and client (typed requests) pick it up.
|
||||
- Locale files live in `shared/src/i18n/<locale>/`, one file per domain; `en/` is canonical. When you add or change a key, **add a real translation to every locale** — parity fails CI on missing keys, and an English placeholder is not acceptable in a non-`en` locale.
|
||||
- Count-bearing strings are plural groups: the key holds the general form and `key.one`, `.few` … the forms the language's `Intl.PluralRules` selects. Pass `{ count }` as a number and never choose between two keys with `count === 1`; details in `shared/CLAUDE.md`.
|
||||
|
||||
## Project direction
|
||||
|
||||
|
||||
@@ -1178,7 +1178,7 @@ describe('AdminPluginsPanel — compatible updates only', () => {
|
||||
render(<AdminPluginsPanel />)
|
||||
|
||||
expect(await screen.findByRole('button', { name: /update → v2\.0\.0/i })).toBeInTheDocument()
|
||||
expect(screen.getByText('1 updates available for your plugins.')).toBeInTheDocument()
|
||||
expect(screen.getByText('1 update available for your plugins.')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /update → v3\.0\.0/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -1254,7 +1254,7 @@ describe('AdminPluginsPanel — update hold', () => {
|
||||
}))
|
||||
withToast()
|
||||
|
||||
expect(await screen.findByText('1 updates available for your plugins.')).toBeInTheDocument()
|
||||
expect(await screen.findByText('1 update available for your plugins.')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: /update all/i }))
|
||||
|
||||
await waitFor(() => expect(updated).toEqual(['trek-ntfy']))
|
||||
|
||||
@@ -1285,7 +1285,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIn
|
||||
className={`${CHIP} ${hostCount > 0 ? CHIP_NET : CHIP_WARN} hover:opacity-80`} style={fs(11)}>
|
||||
<Globe size={12} />
|
||||
{hostCount > 0
|
||||
? t('admin.plugins.allowedHosts.count').replace('{n}', String(hostCount))
|
||||
? t('admin.plugins.allowedHosts.count', { n: hostCount })
|
||||
: t('admin.plugins.allowedHosts.add')}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
@@ -243,7 +243,7 @@ export default function PackingTemplateManager() {
|
||||
<button type="button" onClick={() => toggleExpand(tmpl.id)} className="min-w-0 flex-1 truncate text-start font-semibold text-content" style={fs(13, 'body')}>{tmpl.name}</button>
|
||||
)}
|
||||
<span className="max-sm:hidden">
|
||||
<StatusPill>{tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count} {t('admin.packingTemplates.items')}</StatusPill>
|
||||
<StatusPill>{tmpl.category_count} {t('admin.packingTemplates.categories', { count: tmpl.category_count })} · {tmpl.item_count} {t('admin.packingTemplates.items', { count: tmpl.item_count })}</StatusPill>
|
||||
</span>
|
||||
<Tooltip label={t('common.rename')}>
|
||||
<button type="button" onClick={() => { setEditingTemplate(tmpl.id); setEditTemplateName(tmpl.name) }}
|
||||
|
||||
@@ -434,7 +434,7 @@ describe('AdminStoragePanel', () => {
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Compute now' }));
|
||||
await screen.findByText(/Usage computed/);
|
||||
expect(within(categoryRow('files')).getByText(/1 objects · 2\.0 KB/)).toBeInTheDocument();
|
||||
expect(within(categoryRow('files')).getByText(/1 object · 2\.0 KB/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STOR-020: Sync now runs the backfill — running line with counts, then the done line (50ms test poll)', async () => {
|
||||
|
||||
@@ -506,7 +506,7 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
<div className={DETAILS}>
|
||||
{rowUsage && (
|
||||
<p className={`${META} font-geist tabular-nums`} style={fs(11.5)}>
|
||||
{t('storage.usage.line', { objects: String(rowUsage.objects), size: formatBytes(rowUsage.bytes) })}
|
||||
{t('storage.usage.line', { count: rowUsage.objects, size: formatBytes(rowUsage.bytes) })}
|
||||
{row.name === 'uploads-local' && state.usage!.legacyPhotos.objects > 0
|
||||
? ` (${t('storage.usage.legacyNote')})`
|
||||
: ''}
|
||||
@@ -763,7 +763,7 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
{state.usage?.categories[category] && (
|
||||
<p className={`${META} font-geist tabular-nums`} style={fs(11.5)}>
|
||||
{t('storage.usage.line', {
|
||||
objects: String(state.usage.categories[category]!.objects),
|
||||
count: state.usage.categories[category]!.objects,
|
||||
size: formatBytes(state.usage.categories[category]!.bytes),
|
||||
})}
|
||||
</p>
|
||||
|
||||
@@ -1150,7 +1150,7 @@ describe('CostsPanel — filtering the ledger', () => {
|
||||
fireEvent.click(within(screen.getByRole('menu')).getByRole('button', { name: 'Sun, Jun 15' }))
|
||||
|
||||
expect(screen.getByText('Sunday, June 15')).toBeInTheDocument()
|
||||
expect(screen.getByText('1 expenses')).toBeInTheDocument()
|
||||
expect(screen.getByText('1 expense')).toBeInTheDocument()
|
||||
expect(screen.getByText('Dinner')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Taxi')).not.toBeInTheDocument()
|
||||
// The 16th carries the payment, so it is filtered out with the day too.
|
||||
|
||||
@@ -871,7 +871,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
}}
|
||||
>
|
||||
<Receipt size={12} className="text-content-muted" />
|
||||
<span>{t('costs.receipts') || 'Beleg'}{e.receipts!.length > 1 ? ` (${e.receipts!.length})` : ''}</span>
|
||||
<span>{t('costs.receipts', { count: e.receipts!.length })}{e.receipts!.length > 1 ? ` (${e.receipts!.length})` : ''}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -171,8 +171,8 @@ describe('CopyToTripModal', () => {
|
||||
renderModal({ onCopy });
|
||||
|
||||
fireEvent.click(await screen.findByText('Rome 2026'));
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Skipped 1 duplicates', 'info', undefined));
|
||||
expect(addToast).toHaveBeenCalledWith('Copied 1 places', 'success', undefined);
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Skipped 1 duplicate', 'info', undefined));
|
||||
expect(addToast).toHaveBeenCalledWith('Copied 1 place', 'success', undefined);
|
||||
});
|
||||
|
||||
it('FE-COMP-COPYTRIP-010: a no-op copy says so instead of staying silent', async () => {
|
||||
|
||||
@@ -126,7 +126,7 @@ describe('ImportFromTripModal', () => {
|
||||
expect([...body.source_place_ids].sort()).toEqual([11, 12]);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('2 places added')).toBeInTheDocument());
|
||||
expect(screen.getByText(/1 were already on the list/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 was already on the list and stayed as it was/)).toBeInTheDocument();
|
||||
expect(onImported).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ describe('SaveTripPlacesToListModal', () => {
|
||||
renderModal();
|
||||
|
||||
fireEvent.click(await screen.findByText('Favorites'));
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Skipped 1 duplicates', 'info', undefined));
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Skipped 1 duplicate', 'info', undefined));
|
||||
expect(addToast).toHaveBeenCalledWith('Saved 1 to Favorites', 'success', undefined);
|
||||
});
|
||||
|
||||
|
||||
@@ -256,7 +256,7 @@ describe('DawarichAtlasDialog', () => {
|
||||
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/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add 1 country/ }))
|
||||
|
||||
await waitFor(() => expect(api.acceptAtlasCountries).toHaveBeenCalledWith(['NL']))
|
||||
})
|
||||
@@ -351,7 +351,7 @@ describe('DawarichAtlasDialog', () => {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Your recordings show no countries TREK does not already have.')).toBeInTheDocument(),
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: /Add \d+ countries/ })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /Add \d+ countr/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-015: wishes reached on one day travel in one request, another day in its own', async () => {
|
||||
@@ -430,7 +430,7 @@ describe('DawarichAtlasDialog', () => {
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
|
||||
|
||||
setForcedOffline(true)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add 1 country/ }))
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('TREK is offline right now'))
|
||||
expect(api.acceptAtlasCountries).not.toHaveBeenCalled()
|
||||
@@ -463,7 +463,7 @@ describe('DawarichAtlasDialog', () => {
|
||||
|
||||
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/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add 1 country/ }))
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Something went wrong talking to Dawarich.'))
|
||||
expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toHaveAttribute('aria-checked', 'true')
|
||||
@@ -572,9 +572,9 @@ describe('DawarichAtlasDialog', () => {
|
||||
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/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add 1 country/ }))
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 countries added'))
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 country added'))
|
||||
// Marked in place rather than re-read: the reading costs an upstream request
|
||||
// and the only thing that changed is what the reader just pressed. The row
|
||||
// leaving the list is what says the write landed.
|
||||
@@ -582,7 +582,7 @@ describe('DawarichAtlasDialog', () => {
|
||||
expect(screen.getByText('Your recordings show no countries TREK does not already have.')).toBeInTheDocument(),
|
||||
)
|
||||
expect(screen.queryByRole('checkbox', { name: 'Netherlands' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /Add \d+ countries/ })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /Add \d+ countr/ })).toBeNull()
|
||||
expect(repo.atlasSuggestions).toHaveBeenCalledTimes(1)
|
||||
// The Atlas keeps its own map and its own counts; without this the country
|
||||
// it was just told about only appears there after a reload.
|
||||
@@ -626,11 +626,11 @@ describe('DawarichAtlasDialog', () => {
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'France' }))
|
||||
expect(screen.getByRole('checkbox', { name: 'France' })).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add 1 country/ }))
|
||||
|
||||
// Belgium was never sent, so nothing has to be undone in the Atlas after.
|
||||
await waitFor(() => expect(api.acceptAtlasCountries).toHaveBeenCalledWith(['FR']))
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 countries added'))
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 country added'))
|
||||
// And it is still on offer, still unticked, for the trip it does belong to.
|
||||
expect(screen.getByRole('checkbox', { name: 'Belgium' })).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
@@ -295,9 +295,7 @@ export default function DawarichAtlasDialog({
|
||||
{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 })}
|
||||
{t('dawarich.atlas.cities', { count: country.cities.length })}
|
||||
</DawarichBadge>
|
||||
<span className="min-w-0 truncate text-caption text-content-muted">
|
||||
{cityLine(country.cities)}
|
||||
|
||||
@@ -345,7 +345,7 @@ describe('GalleryView', () => {
|
||||
|
||||
await waitFor(() => expect(onRefresh).toHaveBeenCalledTimes(1))
|
||||
expect(uploadGalleryPhotos).toHaveBeenCalledTimes(1)
|
||||
expect(toastSpy).toHaveBeenCalledWith('1 photos uploaded', 'success', undefined)
|
||||
expect(toastSpy).toHaveBeenCalledWith('1 photo uploaded', 'success', undefined)
|
||||
expect(input.value).toBe('')
|
||||
})
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
||||
if (failed.length > 0) {
|
||||
toast.error(t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(normalized.length) }))
|
||||
} else {
|
||||
toast.success(t('journey.photosUploaded', { count: String(files.length) }))
|
||||
toast.success(t('journey.photosUploaded', { count: files.length }))
|
||||
}
|
||||
onRefresh()
|
||||
} catch (err) {
|
||||
@@ -120,7 +120,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
||||
{/* Header — the provider buttons live in the page header next to Upload */}
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-[0.07em]" style={{ background: 'var(--vg-surf2)', color: 'var(--vg-ink3)' }}>
|
||||
<Camera size={11} /> {allPhotos.length} {t('journey.detail.photos')}
|
||||
<Camera size={11} /> {allPhotos.length} {t('journey.detail.photos', { count: allPhotos.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -382,7 +382,7 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
||||
<span className="h-8 w-8 flex-none rounded-[9px]" style={{ background: pickGradient(trip.trip_id) }} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-semibold text-content" style={fs(13, 'body')}>{trip.title}</div>
|
||||
<div className="text-content-faint" style={fs(11.5)}>{trip.place_count || 0} {t('journey.synced.places')}</div>
|
||||
<div className="text-content-faint" style={fs(11.5)}>{trip.place_count || 0} {t('journey.synced.places', { count: trip.place_count || 0 })}</div>
|
||||
</div>
|
||||
<RowAction label={t('journey.trips.unlinkTrip')} onClick={() => setUnlinkTarget({ trip_id: trip.trip_id, title: trip.title })}>
|
||||
<Trash2 size={14} />
|
||||
|
||||
@@ -915,11 +915,11 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
<div class="cover-stats">
|
||||
<div>
|
||||
<div class="cover-stat-num">${sorted.length}</div>
|
||||
<div class="cover-stat-lbl">${escHtml(tr('dashboard.days'))}</div>
|
||||
<div class="cover-stat-lbl">${escHtml(tr('dashboard.days', { count: sorted.length }))}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="cover-stat-num">${places?.length || 0}</div>
|
||||
<div class="cover-stat-lbl">${escHtml(tr('dashboard.places'))}</div>
|
||||
<div class="cover-stat-lbl">${escHtml(tr('dashboard.places', { count: places?.length || 0 }))}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="cover-stat-num">${totalAssigned}</div>
|
||||
|
||||
@@ -92,7 +92,7 @@ export default function ApplyTemplateButton({ tripId, visibility, style, classNa
|
||||
<div style={{ flex: 1, textAlign: 'start' }}>
|
||||
<div style={{ fontWeight: 600 }}>{tmpl.name}</div>
|
||||
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>
|
||||
{tmpl.item_count} {t('admin.packingTemplates.items')}
|
||||
{tmpl.item_count} {t('admin.packingTemplates.items', { count: tmpl.item_count })}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -125,7 +125,7 @@ export function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers,
|
||||
<div style={{ height: '100%', borderRadius: 99, background: bag.color, width: `${pct}%`, transition: 'width 0.3s' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginTop: 5 }}>
|
||||
<span style={{ fontSize: sz.count, color: 'var(--text-faint)' }}>{bagItems.length} {t('admin.packingTemplates.items')}</span>
|
||||
<span style={{ fontSize: sz.count, color: 'var(--text-faint)' }}>{bagItems.length} {t('admin.packingTemplates.items', { count: bagItems.length })}</span>
|
||||
<span style={{ fontSize: sz.weight, color: 'var(--text-muted)', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 3, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
|
||||
{editingLimit && canEdit ? (
|
||||
|
||||
@@ -59,7 +59,7 @@ export function BagModal(S: PackingState) {
|
||||
{unassignedWeight >= 1000 ? `${(unassignedWeight / 1000).toFixed(1)} kg` : `${unassignedWeight} g`}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{unassigned.length} {t('admin.packingTemplates.items')}</div>
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{unassigned.length} {t('admin.packingTemplates.items', { count: unassigned.length })}</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
@@ -85,7 +85,7 @@ export function BagSidebar(S: PackingState) {
|
||||
<div style={{ ...hairline(bags.length === 0), display: 'flex', alignItems: 'center', gap: 6, padding: '11px 0' }}>
|
||||
<span style={{ width: 10, height: 10, borderRadius: '50%', border: '1.5px dashed var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-muted)' }}>{t('packing.noBag')}</span>
|
||||
<span title={`${unassigned.length} ${t('admin.packingTemplates.items')}`} style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, padding: '1px 6px', borderRadius: 99, minWidth: 18, textAlign: 'center', background: 'var(--bg-tertiary)', color: 'var(--text-faint)', fontVariantNumeric: 'tabular-nums' }}>{unassigned.length}</span>
|
||||
<span title={`${unassigned.length} ${t('admin.packingTemplates.items', { count: unassigned.length })}`} style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, padding: '1px 6px', borderRadius: 99, minWidth: 18, textAlign: 'center', background: 'var(--bg-tertiary)', color: 'var(--text-faint)', fontVariantNumeric: 'tabular-nums' }}>{unassigned.length}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', fontVariantNumeric: 'tabular-nums' }}>{kgOrG(unassignedWeight)}</span>
|
||||
</div>
|
||||
|
||||
@@ -76,7 +76,7 @@ export function PackingHeader(S: PackingState) {
|
||||
<Package size={13} className="text-content-faint" />
|
||||
<div style={{ flex: 1, textAlign: 'start' }}>
|
||||
<div style={{ fontWeight: 600 }}>{tmpl.name}</div>
|
||||
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{tmpl.item_count} {t('admin.packingTemplates.items')}</div>
|
||||
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{tmpl.item_count} {t('admin.packingTemplates.items', { count: tmpl.item_count })}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -621,7 +621,7 @@ describe('usePackingList — templates, import and signals', () => {
|
||||
await act(async () => { await result.current.handleApplyTemplate(2) })
|
||||
|
||||
expect(useTripStore.getState().packingItems).toContainEqual(fresh)
|
||||
expect(toastSpy).toHaveBeenCalledWith('1 items added from template', 'success', undefined)
|
||||
expect(toastSpy).toHaveBeenCalledWith('1 item added from template', 'success', undefined)
|
||||
expect(result.current.showTemplateDropdown).toBe(false)
|
||||
expect(result.current.applyingTemplate).toBe(false)
|
||||
})
|
||||
@@ -700,7 +700,7 @@ describe('usePackingList — templates, import and signals', () => {
|
||||
await act(async () => { await result.current.handleBulkImport() })
|
||||
|
||||
expect(useTripStore.getState().packingItems).toContainEqual(imported)
|
||||
expect(toastSpy).toHaveBeenCalledWith('1 items imported', 'success', undefined)
|
||||
expect(toastSpy).toHaveBeenCalledWith('1 item imported', 'success', undefined)
|
||||
expect(result.current.importText).toBe('')
|
||||
expect(result.current.showImportModal).toBe(false)
|
||||
})
|
||||
|
||||
@@ -527,7 +527,7 @@ describe('DayPlanSidebar', () => {
|
||||
// title uses an arrow icon, so its parts are separate text nodes.
|
||||
expect(screen.getByText('U2')).toBeInTheDocument()
|
||||
// Transfer counts stay out of the compact row — the chips say it all.
|
||||
expect(screen.queryByText(/1 transfers/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/1 transfer/)).not.toBeInTheDocument()
|
||||
// Clicking the row opens the journey view — not the edit form.
|
||||
await user.click(screen.getByText('Fernsehturm'))
|
||||
expect(onEditTransport).not.toHaveBeenCalled()
|
||||
|
||||
@@ -1119,7 +1119,7 @@ describe('PlaceInspector', () => {
|
||||
const { openFile } = await import('../../utils/fileDownload');
|
||||
const spy = vi.spyOn({ openFile }, 'openFile');
|
||||
render(<PlaceInspector {...defaultProps} files={[placeFile()] as any} />);
|
||||
fireEvent.click(screen.getByText('1 files'));
|
||||
fireEvent.click(screen.getByText('1 file'));
|
||||
const link = await screen.findByText('map.pdf');
|
||||
fireEvent.click(link);
|
||||
// The click is handled without throwing; the row stays in the list.
|
||||
|
||||
@@ -262,7 +262,7 @@ function CategoryFilter(S: SidebarState) {
|
||||
if (categoryFilters.has(UNCATEGORIZED)) label = t('places.noCategory')
|
||||
else if (only) label = only.name
|
||||
} else if (categoryFilters.size > 1) {
|
||||
label = `${categoryFilters.size} ${t('places.categoriesSelected')}`
|
||||
label = `${categoryFilters.size} ${t('places.categoriesSelected', { count: categoryFilters.size })}`
|
||||
}
|
||||
return (
|
||||
<FilterDropdown name={t('categories.title')} label={label} active={categoryFilters.size > 0} badge={categoryFilters.size}
|
||||
|
||||
@@ -107,7 +107,7 @@ describe('TransitSearchPanel', () => {
|
||||
// Local Berlin times, U2 badge, 1 transfer.
|
||||
expect(await screen.findByText(/08:30 – 09:00/)).toBeInTheDocument()
|
||||
expect(screen.getByText('U2')).toBeInTheDocument()
|
||||
expect(screen.getByText('1 transfers')).toBeInTheDocument()
|
||||
expect(screen.getByText('1 transfer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSIT-003: adding a route builds a transport payload with local times + endpoints', async () => {
|
||||
|
||||
@@ -611,7 +611,7 @@ describe('usePlacesSidebar list import', () => {
|
||||
await act(async () => { await S.handleListImport(); });
|
||||
|
||||
expect(sentEnrich).toBe(true);
|
||||
expect(addToast).toHaveBeenCalledWith('1 places imported from "Seoul"', 'success', undefined);
|
||||
expect(addToast).toHaveBeenCalledWith('1 place imported from "Seoul"', 'success', undefined);
|
||||
expect(pushUndo).toHaveBeenCalledWith('Naver Maps import', expect.any(Function));
|
||||
});
|
||||
|
||||
|
||||
@@ -326,7 +326,7 @@ describe('RoadtripCorridorPanel', () => {
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
// The difference between "no fuel here" and "nobody looked here".
|
||||
expect(screen.getByText('2 stretches could not be searched — the place search did not answer.')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 stretches could not be searched. The place search did not answer.')).toBeInTheDocument()
|
||||
expect(screen.getByText('Aral')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ export function StudioExport({
|
||||
|
||||
<p className="m-0 text-content-muted" style={fs(12, 'body')}>
|
||||
{t('journey.studio.exportNote', {
|
||||
sheets: sheets.length,
|
||||
count: sheets.length,
|
||||
width: round1(box.width),
|
||||
height: round1(box.height),
|
||||
})}
|
||||
|
||||
@@ -193,7 +193,7 @@ describe('TripMembersModal', () => {
|
||||
)
|
||||
);
|
||||
render(<TripMembersModal {...defaultProps} />);
|
||||
expect(await screen.findByText(/2 persons/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/2 people/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMBERS-012: shows "you" label next to current user', async () => {
|
||||
@@ -526,7 +526,7 @@ describe('TripMembersModal', () => {
|
||||
// The guest carries a "Guest" badge.
|
||||
expect(screen.getAllByText('Guest').length).toBeGreaterThan(0);
|
||||
// The head band counts the owner and the real member (2), not the guest.
|
||||
expect(screen.getByText('2 persons')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 people')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Avatars and load failures (028-031) ───────────────────────────────────
|
||||
@@ -553,7 +553,7 @@ describe('TripMembersModal', () => {
|
||||
render(<TripMembersModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to load members', 'error', undefined));
|
||||
expect(screen.getByText('0 persons')).toBeInTheDocument();
|
||||
expect(screen.getByText('0 people')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMBERS-031: a failing share-link load still offers link creation', async () => {
|
||||
|
||||
@@ -112,7 +112,10 @@ interface TranslationProviderProps {
|
||||
|
||||
export function TranslationProvider({ children }: TranslationProviderProps) {
|
||||
const language = useSettingsStore((s) => s.settings.language) || 'en'
|
||||
const [strings, setStrings] = useState<TranslationStrings>(en)
|
||||
// The catalogue together with the language it belongs to: while another
|
||||
// language loads, or when its chunk fails, the strings on screen are still
|
||||
// the previous ones, and their plural forms follow that language's rule.
|
||||
const [catalogue, setCatalogue] = useState<{ language: string; strings: TranslationStrings }>({ language: 'en', strings: en })
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = getIntlLanguage(language)
|
||||
@@ -125,7 +128,11 @@ export function TranslationProvider({ children }: TranslationProviderProps) {
|
||||
|
||||
let cancelled = false
|
||||
importChunk(loader).then(mod => {
|
||||
if (!cancelled) setStrings(mod.default)
|
||||
// The same table for the same language keeps the state, and with it t():
|
||||
// English arrives as the very table the provider started with.
|
||||
if (!cancelled) {
|
||||
setCatalogue(prev => (prev.language === language && prev.strings === mod.default ? prev : { language, strings: mod.default }))
|
||||
}
|
||||
}).catch(err => {
|
||||
// The locale chunk can be gone after a deploy. Keep the strings we have —
|
||||
// an untranslated UI beats an unhandled rejection and a blank screen.
|
||||
@@ -135,9 +142,9 @@ export function TranslationProvider({ children }: TranslationProviderProps) {
|
||||
}, [language])
|
||||
|
||||
const value = useMemo((): TranslationContextValue => {
|
||||
const intlLanguage = getIntlLanguage(language)
|
||||
const intlLanguage = getIntlLanguage(catalogue.language)
|
||||
const template = (key: string, params?: Record<string, string | number>): string =>
|
||||
resolveTemplate(strings, intlLanguage, key, params)
|
||||
resolveTemplate(catalogue.strings, intlLanguage, key, params)
|
||||
|
||||
function t(key: string, params?: Record<string, string | number>): string {
|
||||
let val = template(key, params)
|
||||
@@ -167,7 +174,7 @@ export function TranslationProvider({ children }: TranslationProviderProps) {
|
||||
}
|
||||
|
||||
return { t, tHtml, language, locale: getLocaleForLanguage(language) }
|
||||
}, [strings, language])
|
||||
}, [catalogue, language])
|
||||
|
||||
return <TranslationContext value={value}>{children}</TranslationContext>
|
||||
}
|
||||
|
||||
@@ -23,6 +23,12 @@ describe('resolveTemplate', () => {
|
||||
expect(resolveTemplate(ja, 'ja', 'x.places', { count: 1 })).toBe('{count}件の場所')
|
||||
const ruWithoutMany = { 'x.places': '{count} места', 'x.places.one': '{count} место' } as unknown as TranslationStrings
|
||||
expect(resolveTemplate(ruWithoutMany, 'ru', 'x.places', { count: 5 })).toBe('{count} места')
|
||||
// A key English has forms for: count 1 is "one" in English too, and still
|
||||
// the locale's own general form wins over English's .one.
|
||||
const jaPlaces = { 'places.count': '{count}件' } as unknown as TranslationStrings
|
||||
expect(resolveTemplate(jaPlaces, 'ja', 'places.count', { count: 1 })).toBe('{count}件')
|
||||
const ruPlaces = { 'places.count': '{count} мест' } as unknown as TranslationStrings
|
||||
expect(resolveTemplate(ruPlaces, 'ru', 'places.count', { count: 1 })).toBe('{count} мест')
|
||||
})
|
||||
|
||||
it('a key the locale lacks resolves in English with English rules', () => {
|
||||
@@ -30,14 +36,25 @@ describe('resolveTemplate', () => {
|
||||
const empty = {} as TranslationStrings
|
||||
expect(resolveTemplate(empty, 'ru', 'places.count', { count: 1 })).toBe('{count} place')
|
||||
expect(resolveTemplate(empty, 'ru', 'places.count', { count: 2 })).toBe('{count} places')
|
||||
// Where the locale's rule and English's differ: Russian puts 21 and French
|
||||
// puts 0 in "one", English puts both in "other".
|
||||
expect(resolveTemplate(empty, 'ru', 'places.count', { count: 21 })).toBe('{count} places')
|
||||
expect(resolveTemplate(empty, 'fr', 'places.count', { count: 0 })).toBe('{count} places')
|
||||
})
|
||||
|
||||
it('reads n when there is no count, and ignores a count that is not a number', () => {
|
||||
expect(resolveTemplate(ru, 'ru', 'x.places', { n: 5 })).toBe('{count} мест')
|
||||
expect(resolveTemplate(ru, 'ru', 'x.places', { count: 'five' })).toBe('{count} места')
|
||||
expect(resolveTemplate(ru, 'ru', 'x.places', { count: '1,5' })).toBe('{count} места')
|
||||
expect(resolveTemplate(ru, 'ru', 'x.places', { count: '' })).toBe('{count} места')
|
||||
expect(resolveTemplate(ru, 'ru', 'x.places', { count: Number.NaN })).toBe('{count} места')
|
||||
})
|
||||
|
||||
it('a whole number sent as text still picks its form, as in-app notification params arrive', () => {
|
||||
expect(resolveTemplate(ru, 'ru', 'x.places', { count: '21' })).toBe('{count} место')
|
||||
expect(resolveTemplate(ru, 'ru', 'x.places', { count: '5' })).toBe('{count} мест')
|
||||
})
|
||||
|
||||
it('a group with only category keys still resolves, and an unknown key is the key', () => {
|
||||
const groupOnly = { 'x.stays.one': 'one stay', 'x.stays.other': '{count} stays' } as unknown as TranslationStrings
|
||||
expect(resolveTemplate(groupOnly, 'en', 'x.stays', { count: 1 })).toBe('one stay')
|
||||
|
||||
@@ -7,7 +7,9 @@ import type { TranslationStrings } from '@trek/shared/i18n'
|
||||
* form the language's own rule selects (see `@trek/shared` plural helpers):
|
||||
* `places.count.one`, `.few`, ... and the key itself as the general form.
|
||||
* The active locale is asked with its rule first; English, with English's,
|
||||
* only when the locale has none of the key's forms.
|
||||
* only when the locale has none of the key's forms. A count that arrives as
|
||||
* text (in-app notification params are strings) counts when it is a plain
|
||||
* whole number.
|
||||
*/
|
||||
export function resolveTemplate(
|
||||
strings: TranslationStrings,
|
||||
@@ -15,8 +17,7 @@ export function resolveTemplate(
|
||||
key: string,
|
||||
params?: Record<string, string | number>,
|
||||
): string {
|
||||
const raw = params?.count ?? params?.n
|
||||
const count = typeof raw === 'number' && Number.isFinite(raw) ? raw : undefined
|
||||
const count = asCount(params?.count ?? params?.n)
|
||||
if (count !== undefined) {
|
||||
const own = resolvePluralKey(strings, key, count, intlLanguage)
|
||||
if (own) return strings[own] as string
|
||||
@@ -26,3 +27,9 @@ export function resolveTemplate(
|
||||
}
|
||||
return (strings[key] ?? en[key] ?? key) as string
|
||||
}
|
||||
|
||||
function asCount(raw: string | number | undefined): number | undefined {
|
||||
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : undefined
|
||||
if (typeof raw === 'string' && /^\d+$/.test(raw)) return Number(raw)
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -199,10 +199,10 @@ export default function MAdmin() {
|
||||
{/* Stats grid (§6.3) */}
|
||||
{stats && (
|
||||
<div className="mb-3 grid grid-cols-2 gap-[10px]">
|
||||
<MAdminStat label={t('admin.stats.users')} value={stats.totalUsers} icon={Users} />
|
||||
<MAdminStat label={t('admin.stats.trips')} value={stats.totalTrips} icon={Briefcase} />
|
||||
<MAdminStat label={t('admin.stats.places')} value={stats.totalPlaces} icon={Map} />
|
||||
<MAdminStat label={t('admin.stats.files')} value={stats.totalFiles || 0} icon={FileText} />
|
||||
<MAdminStat label={t('admin.stats.users', { count: stats.totalUsers })} value={stats.totalUsers} icon={Users} />
|
||||
<MAdminStat label={t('admin.stats.trips', { count: stats.totalTrips })} value={stats.totalTrips} icon={Briefcase} />
|
||||
<MAdminStat label={t('admin.stats.places', { count: stats.totalPlaces })} value={stats.totalPlaces} icon={Map} />
|
||||
<MAdminStat label={t('admin.stats.files', { count: stats.totalFiles || 0 })} value={stats.totalFiles || 0} icon={FileText} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -261,7 +261,7 @@ export default function MAdminPackingTemplateManager() {
|
||||
<button type="button" className="min-w-0 flex-1 text-start" onClick={() => toggleExpand(tmpl.id)}>
|
||||
<div className="truncate text-[0.8125rem] font-bold text-m-ink">{tmpl.name}</div>
|
||||
<div className="mt-[1px] font-geist text-[0.59375rem] text-m-faint">
|
||||
{tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count} {t('admin.packingTemplates.items')}
|
||||
{tmpl.category_count} {t('admin.packingTemplates.categories', { count: tmpl.category_count })} · {tmpl.item_count} {t('admin.packingTemplates.items', { count: tmpl.item_count })}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -1409,7 +1409,7 @@ function InstalledRow({ p, t, busy, hasUpdate, latestVer, newerIncompatible, blo
|
||||
>
|
||||
<Globe size={12} className={p.egressHostCount > 0 ? 'text-[color:var(--m-st-info)]' : 'text-[color:var(--m-st-pending)]'} />
|
||||
{p.egressHostCount > 0
|
||||
? t('admin.plugins.allowedHosts.count').replace('{n}', String(p.egressHostCount))
|
||||
? t('admin.plugins.allowedHosts.count', { n: p.egressHostCount })
|
||||
: t('admin.plugins.allowedHosts.add')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -510,7 +510,7 @@ export default function MAdminStoragePanel(): React.ReactElement {
|
||||
</p>
|
||||
{rowUsage && (
|
||||
<p className="mt-1 font-geist text-[0.625rem] text-m-muted">
|
||||
{t('storage.usage.line', { objects: String(rowUsage.objects), size: formatBytes(rowUsage.bytes) })}
|
||||
{t('storage.usage.line', { count: rowUsage.objects, size: formatBytes(rowUsage.bytes) })}
|
||||
{row.name === 'uploads-local' && state.usage!.legacyPhotos.objects > 0
|
||||
? ` (${t('storage.usage.legacyNote')})`
|
||||
: ''}
|
||||
@@ -772,7 +772,7 @@ export default function MAdminStoragePanel(): React.ReactElement {
|
||||
{state.usage?.categories[category] && (
|
||||
<p className="mb-1 font-geist text-[0.625rem] text-m-muted">
|
||||
{t('storage.usage.line', {
|
||||
objects: String(state.usage.categories[category]!.objects),
|
||||
count: state.usage.categories[category]!.objects,
|
||||
size: formatBytes(state.usage.categories[category]!.bytes),
|
||||
})}
|
||||
</p>
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function MAdminUsersSection({ admin, t, locale }: MAdminUsersSect
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="text-[0.875rem] font-extrabold text-m-ink">{t('admin.tabs.users')}</span>
|
||||
<span className="font-geist text-[0.625rem] font-bold text-m-faint">
|
||||
{users.length} {t('admin.stats.users').toLowerCase()}
|
||||
{users.length} {t('admin.stats.users', { count: users.length })}
|
||||
</span>
|
||||
<MAdminButton className="ms-auto" onClick={() => setShowCreateUser(true)}>
|
||||
<UserPlus size={12} strokeWidth={2.2} />
|
||||
|
||||
@@ -193,7 +193,7 @@ export default function MAtlas() {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[1.0625rem] font-extrabold text-m-ink">{resolveName(selectedCountry)}</div>
|
||||
<div className="mt-[4px] flex flex-wrap gap-1">
|
||||
{[[countryDetail.places.length, t('atlas.places')], [countryDetail.trips.length, t('atlas.trips')]].map(([n, label]) => (
|
||||
{[[countryDetail.places.length, t('atlas.places', { count: countryDetail.places.length })], [countryDetail.trips.length, t('atlas.trips', { count: countryDetail.trips.length })]].map(([n, label]) => (
|
||||
<span key={label} className="rounded-full bg-[color:var(--m-ic)] px-2 py-[1px] font-geist text-[0.625rem] font-bold tabular-nums text-m-muted">
|
||||
{n} {label}
|
||||
</span>
|
||||
|
||||
@@ -13,11 +13,11 @@ interface MAtlasStatsCardProps {
|
||||
export default function MAtlasStatsCard({ stats }: MAtlasStatsCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const cols: [number, string][] = [
|
||||
[stats.totalCountries, t('atlas.countries')],
|
||||
[stats.totalTrips, t('atlas.trips')],
|
||||
[stats.totalPlaces, t('atlas.places')],
|
||||
[stats.totalCities || 0, t('atlas.cities')],
|
||||
[stats.totalDays, t('atlas.days')],
|
||||
[stats.totalCountries, t('atlas.countries', { count: stats.totalCountries })],
|
||||
[stats.totalTrips, t('atlas.trips', { count: stats.totalTrips })],
|
||||
[stats.totalPlaces, t('atlas.places', { count: stats.totalPlaces })],
|
||||
[stats.totalCities || 0, t('atlas.cities', { count: stats.totalCities || 0 })],
|
||||
[stats.totalDays, t('atlas.days', { count: stats.totalDays })],
|
||||
]
|
||||
// All five columns are spoken for, so the planned count rides along with the country
|
||||
// number as a superscript rather than claiming a sixth column.
|
||||
|
||||
@@ -562,8 +562,8 @@ function MTripListCard({ trip, locale, t, badge, pluginBadges, actions, onOpen }
|
||||
</div>
|
||||
<div className="my-[13px] h-px bg-[color:var(--m-rowbr)]" />
|
||||
<div className="flex text-center">
|
||||
<ListStat value={trip.day_count ?? 0} label={t('dashboard.days')} />
|
||||
<ListStat value={trip.place_count ?? 0} label={t('dashboard.places')} />
|
||||
<ListStat value={trip.day_count ?? 0} label={t('dashboard.days', { count: trip.day_count ?? 0 })} />
|
||||
<ListStat value={trip.place_count ?? 0} label={t('dashboard.places', { count: trip.place_count ?? 0 })} />
|
||||
<ListStat value={trip.shared_count ?? 0} label={t('dashboard.card.buddies', { count: trip.shared_count ?? 0 })} />
|
||||
</div>
|
||||
<MTripBadges items={pluginBadges} />
|
||||
|
||||
@@ -299,7 +299,7 @@ export default function MJourneyDetail() {
|
||||
if (failed.length > 0) {
|
||||
toast.error(t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(normalized.length) }))
|
||||
} else {
|
||||
toast.success(t('journey.photosUploaded', { count: String(files.length) }))
|
||||
toast.success(t('journey.photosUploaded', { count: files.length }))
|
||||
}
|
||||
loadJourney(Number(id))
|
||||
} catch (err) {
|
||||
|
||||
@@ -95,7 +95,7 @@ export default function MBagsSheet({
|
||||
<span className="font-geist text-[0.71875rem] text-m-faint">{formatWeight(unassignedWeight)}</span>
|
||||
</div>
|
||||
<div className="font-geist text-[0.65625rem] text-m-faint">
|
||||
{unassigned.length} {t('admin.packingTemplates.items')}
|
||||
{unassigned.length} {t('admin.packingTemplates.items', { count: unassigned.length })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -307,7 +307,7 @@ function BagRow({ planner, bag, itemCount, weight, pct, tripMembers, canEdit, on
|
||||
<div className="h-[7px] overflow-hidden rounded-full bg-[color:var(--m-ic)]">
|
||||
<div className="h-full rounded-full" style={{ width: `${pct}%`, background: bag.color }} />
|
||||
</div>
|
||||
<div className="mt-[3px] font-geist text-[0.65625rem] text-m-faint">{itemCount} {t('admin.packingTemplates.items')}</div>
|
||||
<div className="mt-[3px] font-geist text-[0.65625rem] text-m-faint">{itemCount} {t('admin.packingTemplates.items', { count: itemCount })}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -670,7 +670,7 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT
|
||||
className="inline-flex items-center gap-1 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-2 py-0.5 text-[0.625rem] font-semibold text-m-muted active:scale-95 transition-all"
|
||||
>
|
||||
<Receipt size={11} className="text-m-faint" />
|
||||
<span>{t('costs.receipts') || 'Beleg'}{item.receipts!.length > 1 ? ` (${item.receipts!.length})` : ''}</span>
|
||||
<span>{t('costs.receipts', { count: item.receipts!.length })}{item.receipts!.length > 1 ? ` (${item.receipts!.length})` : ''}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -374,7 +374,7 @@ export default function MPackingListTab({ planner }: { planner: TripPlanner }) {
|
||||
<Package size={14} strokeWidth={2} className="flex-none text-m-muted" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[0.78125rem] font-semibold text-m-ink">{tmpl.name}</span>
|
||||
<span className="block font-geist text-[0.625rem] text-m-faint">{tmpl.item_count} {t('admin.packingTemplates.items')}</span>
|
||||
<span className="block font-geist text-[0.625rem] text-m-faint">{tmpl.item_count} {t('admin.packingTemplates.items', { count: tmpl.item_count })}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -97,10 +97,10 @@ function AdminPageDesktop(): React.ReactElement {
|
||||
actions={stats ? (
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{[
|
||||
{ label: t('admin.stats.users'), value: stats.totalUsers, icon: Users },
|
||||
{ label: t('admin.stats.trips'), value: stats.totalTrips, icon: Briefcase },
|
||||
{ label: t('admin.stats.places'), value: stats.totalPlaces, icon: Map },
|
||||
{ label: t('admin.stats.files'), value: stats.totalFiles || 0, icon: FileText },
|
||||
{ label: t('admin.stats.users', { count: stats.totalUsers }), value: stats.totalUsers, icon: Users },
|
||||
{ label: t('admin.stats.trips', { count: stats.totalTrips }), value: stats.totalTrips, icon: Briefcase },
|
||||
{ label: t('admin.stats.places', { count: stats.totalPlaces }), value: stats.totalPlaces, icon: Map },
|
||||
{ label: t('admin.stats.files', { count: stats.totalFiles || 0 }), value: stats.totalFiles || 0, icon: FileText },
|
||||
].map(({ label, value, icon: Icon }) => (
|
||||
<AdminStatCard key={label} label={label} value={value} icon={Icon} />
|
||||
))}
|
||||
|
||||
@@ -256,7 +256,7 @@ describe('AtlasPage', () => {
|
||||
// totalCountries = 1 — appears in both mobile bar and desktop panel
|
||||
expect(screen.getAllByText('1').length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -278,7 +278,7 @@ describe('AtlasPage', () => {
|
||||
it('keeps the trip out of the stats bar even when the API still returns one', async () => {
|
||||
render(<AtlasPage />);
|
||||
// The row is stats only now; the trip lives in the country detail below.
|
||||
await waitFor(() => expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0));
|
||||
await waitFor(() => expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0));
|
||||
expect(screen.queryByText('Paris Trip')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -289,7 +289,7 @@ describe('AtlasPage', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
// Both "Countries" labels (mobile + desktop) should be present
|
||||
expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/trips/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -335,7 +335,7 @@ describe('AtlasPage', () => {
|
||||
await user.click(screen.getByText('Stats'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -362,7 +362,7 @@ describe('AtlasPage', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
// Mobile bar always renders; check for the stats labels
|
||||
const countryLabels = screen.getAllByText(/countries/i);
|
||||
const countryLabels = screen.getAllByText(/countr(y|ies)/i);
|
||||
expect(countryLabels.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -505,7 +505,7 @@ describe('AtlasPage', () => {
|
||||
render(<AtlasPage />);
|
||||
|
||||
// Wait for data to load so geoData is set and search input is rendered
|
||||
await waitFor(() => expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0));
|
||||
await waitFor(() => expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0));
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/search a country/i);
|
||||
await user.type(searchInput, 'fr');
|
||||
@@ -552,7 +552,7 @@ describe('AtlasPage', () => {
|
||||
render(<AtlasPage />);
|
||||
|
||||
// Wait for both atlas data and geoData to load (search input renders after load)
|
||||
await waitFor(() => expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0));
|
||||
await waitFor(() => expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0));
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/search a country/i);
|
||||
|
||||
@@ -589,7 +589,7 @@ describe('AtlasPage', () => {
|
||||
|
||||
// Eventually loads
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -598,7 +598,7 @@ describe('AtlasPage', () => {
|
||||
it('mouseMove and mouseLeave events on the desktop panel work without errors', async () => {
|
||||
render(<AtlasPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0));
|
||||
await waitFor(() => expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0));
|
||||
|
||||
// Find the desktop panel container and fire events
|
||||
const panel = document.querySelector('.hidden.md\\:flex') as HTMLElement | null;
|
||||
@@ -608,7 +608,7 @@ describe('AtlasPage', () => {
|
||||
}
|
||||
|
||||
// No error thrown; DOM is still intact
|
||||
expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -622,7 +622,7 @@ describe('AtlasPage', () => {
|
||||
render(<AtlasPage />);
|
||||
|
||||
// Wait for data and search input to be ready
|
||||
await waitFor(() => expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0));
|
||||
await waitFor(() => expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0));
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/search a country/i);
|
||||
await user.type(searchInput, 'fr');
|
||||
@@ -922,7 +922,7 @@ describe('AtlasPage', () => {
|
||||
render(<AtlasPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/trips/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/places/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/days/i).length).toBeGreaterThan(0);
|
||||
@@ -1005,11 +1005,11 @@ describe('AtlasPage', () => {
|
||||
// FR is in atlasStatsResponse.countries → visited branch
|
||||
// DE is not → unvisited else branch in onEachFeature
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Both branches covered via Leaflet mock calling onEachFeature for each feature
|
||||
expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1406,11 +1406,11 @@ describe('AtlasPage', () => {
|
||||
render(<AtlasPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// XK is not in A2_TO_A3_BASE, so the geoJSON loop covers the `A2_TO_A3[a2] = a3` line
|
||||
expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1595,7 +1595,7 @@ describe('AtlasPage', () => {
|
||||
|
||||
// Wait for initial data to load and geoJSON layer to be built
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Change dark mode setting — this re-triggers the map init useEffect [dark]
|
||||
@@ -1605,7 +1605,7 @@ describe('AtlasPage', () => {
|
||||
|
||||
// After dark mode change, the page re-renders and map re-initializes
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1726,7 +1726,7 @@ describe('AtlasPage', () => {
|
||||
}, { timeout: 3000 }).catch(() => {});
|
||||
|
||||
// Page is still rendered
|
||||
expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1760,7 +1760,7 @@ describe('AtlasPage', () => {
|
||||
// mode re-initializes the map, re-registers zoomend, and by then FR's country layer
|
||||
// already exists, so loadRegionsForViewport actually fetches /regions/geo this time.
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: true }) });
|
||||
|
||||
@@ -1781,7 +1781,7 @@ describe('AtlasPage', () => {
|
||||
expect(screen.queryAllByRole('button').some((b) => b.textContent?.trim() === 'Remove')).toBe(false);
|
||||
}, { timeout: 3000 }).catch(() => {});
|
||||
|
||||
expect(screen.getAllByText(/countries/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/countr(y|ies)/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@ function AtlasPageDesktop(): React.ReactElement {
|
||||
labelId="atlas-places-title"
|
||||
onClose={() => setPlacesOpen(false)}
|
||||
title={resolveName(selectedCountry)}
|
||||
sub={`${countryDetail.places.length} ${t('atlas.places')}`}
|
||||
sub={`${countryDetail.places.length} ${t('atlas.places', { count: countryDetail.places.length })}`}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
@@ -161,9 +161,9 @@ function AtlasPageDesktop(): React.ReactElement {
|
||||
{/* Countries highlighted */}
|
||||
<div className="text-center px-3 py-1.5 rounded-xl" style={{ background: dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)' }}>
|
||||
<p className="text-3xl font-black tabular-nums leading-none text-content">{stats.totalCountries}</p>
|
||||
<p className="text-[9px] font-semibold uppercase tracking-wide mt-1 text-content-faint">{t('atlas.countries')}</p>
|
||||
<p className="text-[9px] font-semibold uppercase tracking-wide mt-1 text-content-faint">{t('atlas.countries', { count: stats.totalCountries })}</p>
|
||||
</div>
|
||||
{[[regionsVisited, t('atlas.regions')], [stats.totalTrips, t('atlas.trips')], [stats.totalPlaces, t('atlas.places')], [stats.totalCities || 0, t('atlas.cities')], [stats.totalDays, t('atlas.days')]].map(([v, l], i) => (
|
||||
{[[regionsVisited, t('atlas.regions', { count: regionsVisited })], [stats.totalTrips, t('atlas.trips', { count: stats.totalTrips })], [stats.totalPlaces, t('atlas.places', { count: stats.totalPlaces })], [stats.totalCities || 0, t('atlas.cities', { count: stats.totalCities || 0 })], [stats.totalDays, t('atlas.days', { count: stats.totalDays })]].map(([v, l], i) => (
|
||||
<div key={i} className="text-center px-1">
|
||||
<p className="text-xl font-black tabular-nums leading-none text-content">{v}</p>
|
||||
<p className="text-[9px] font-semibold uppercase tracking-wide mt-1 text-content-faint">{l}</p>
|
||||
@@ -752,7 +752,7 @@ function SidebarContent({ data, stats, regionsVisited, countryRegions, onOpenPla
|
||||
<div className="flex flex-col justify-center px-5 py-4 mx-2 my-2 rounded-xl" style={{ background: bg(0.08) }}>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-5xl font-black tabular-nums leading-none" style={{ color: tp }}>{stats.totalCountries}</span>
|
||||
<span className="text-sm font-medium" style={{ color: tm }}>{t('atlas.countries')}</span>
|
||||
<span className="text-sm font-medium" style={{ color: tm }}>{t('atlas.countries', { count: stats.totalCountries })}</span>
|
||||
</div>
|
||||
{(stats.totalCountriesPlanned || 0) > 0 && (
|
||||
<span className="text-[9px] font-semibold mt-1.5 uppercase tracking-wide whitespace-nowrap" style={{ color: tf }}>
|
||||
@@ -761,7 +761,7 @@ function SidebarContent({ data, stats, regionsVisited, countryRegions, onOpenPla
|
||||
)}
|
||||
</div>
|
||||
{/* Other stats */}
|
||||
{[[regionsVisited, t('atlas.regions')], [stats.totalTrips, t('atlas.trips')], [stats.totalPlaces, t('atlas.places')], [stats.totalCities || 0, t('atlas.cities')], [stats.totalDays, t('atlas.days')]].map(([v, l], i) => (
|
||||
{[[regionsVisited, t('atlas.regions', { count: regionsVisited })], [stats.totalTrips, t('atlas.trips', { count: stats.totalTrips })], [stats.totalPlaces, t('atlas.places', { count: stats.totalPlaces })], [stats.totalCities || 0, t('atlas.cities', { count: stats.totalCities || 0 })], [stats.totalDays, t('atlas.days', { count: stats.totalDays })]].map(([v, l], i) => (
|
||||
<div key={i} className="flex flex-col items-center justify-center px-3 py-5 shrink-0">
|
||||
<span className="text-2xl font-black tabular-nums leading-none" style={{ color: tp }}>{v}</span>
|
||||
<span className="text-[9px] font-semibold mt-1.5 uppercase tracking-wide whitespace-nowrap" style={{ color: tf }}>{l}</span>
|
||||
@@ -835,13 +835,13 @@ function SidebarContent({ data, stats, regionsVisited, countryRegions, onOpenPla
|
||||
<button type="button" onClick={onOpenPlaces}
|
||||
className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-1.5 py-px text-[10px] font-semibold tabular-nums transition-opacity hover:opacity-75"
|
||||
style={{ background: bg(0.1), color: tp }}>
|
||||
<MapPin size={9} />{countryDetail.places.length} {t('atlas.places')}
|
||||
<MapPin size={9} />{countryDetail.places.length} {t('atlas.places', { count: countryDetail.places.length })}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{[
|
||||
[countryDetail.trips.length, t('atlas.tripPlural')],
|
||||
...(countryRegions > 0 ? [[countryRegions, t('atlas.regions')] as const] : []),
|
||||
[countryDetail.trips.length, t('atlas.tripsLabel', { count: countryDetail.trips.length })],
|
||||
...(countryRegions > 0 ? [[countryRegions, t('atlas.regions', { count: countryRegions })] as const] : []),
|
||||
].map(([n, label]) => (
|
||||
<span key={label} className="whitespace-nowrap rounded-full px-1.5 py-px text-[10px] font-semibold tabular-nums" style={{ background: bg(0.06), color: tf }}>
|
||||
{n} {label}
|
||||
|
||||
@@ -460,8 +460,8 @@ describe('AtlasPage wiring', () => {
|
||||
render(<AtlasPage />);
|
||||
|
||||
expect(screen.getByText('France')).toBeInTheDocument();
|
||||
expect(screen.getAllByText((_, el) => el?.textContent?.trim() === '3 atlas.places').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText((_, el) => el?.textContent?.trim() === '4 atlas.tripPlural').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText((_, el) => el?.textContent?.trim() === '3 atlas.places:3').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText((_, el) => el?.textContent?.trim() === '4 atlas.tripsLabel:4').length).toBeGreaterThan(0);
|
||||
// Only the first three trips are shown.
|
||||
expect(screen.queryByText('Brest')).not.toBeInTheDocument();
|
||||
|
||||
|
||||
@@ -423,7 +423,7 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch
|
||||
</div>
|
||||
|
||||
<div className="pass-cell places">
|
||||
<div className="pass-label">{t('dashboard.places')}</div>
|
||||
<div className="pass-label">{t('dashboard.pass.places')}</div>
|
||||
<div className="places-preview">
|
||||
{places.slice(0, 3).map(p => (
|
||||
<div key={p.id} className="place-av">
|
||||
@@ -664,8 +664,8 @@ function TripCard({ trip, locale, badges, matchedPlaces, onOpen, onEdit, onCopy,
|
||||
</div>
|
||||
)}
|
||||
<div className="trip-meta" style={{ gridTemplateColumns: 'repeat(3, 1fr)' }}>
|
||||
<div><span className="n mono">{trip.day_count ?? 0}</span><span className="k">{t('dashboard.days')}</span></div>
|
||||
<div><span className="n mono">{trip.place_count ?? 0}</span><span className="k">{t('dashboard.places')}</span></div>
|
||||
<div><span className="n mono">{trip.day_count ?? 0}</span><span className="k">{t('dashboard.days', { count: trip.day_count ?? 0 })}</span></div>
|
||||
<div><span className="n mono">{trip.place_count ?? 0}</span><span className="k">{t('dashboard.places', { count: trip.place_count ?? 0 })}</span></div>
|
||||
<div><span className="n mono">{trip.shared_count ?? 0}</span><span className="k">{t('dashboard.card.buddies', { count: trip.shared_count ?? 0 })}</span></div>
|
||||
</div>
|
||||
<TripCardBadges items={badges ?? []} />
|
||||
|
||||
@@ -362,7 +362,7 @@ describe('JourneyDetailPage', () => {
|
||||
|
||||
// Gallery view renders photo count text
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/1 photos/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 photo\b/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1100,7 +1100,7 @@ describe('JourneyDetailPage', () => {
|
||||
await user.click(galleryBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/1 photos/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 photo\b/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Gallery renders photos as images
|
||||
@@ -1120,7 +1120,7 @@ describe('JourneyDetailPage', () => {
|
||||
await user.click(galleryBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/1 photos/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 photo\b/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Gallery has an Upload button
|
||||
@@ -1787,7 +1787,7 @@ describe('JourneyDetailPage', () => {
|
||||
await user.click(galleryBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/1 photos/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 photo\b/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the photo in the gallery grid
|
||||
@@ -2005,7 +2005,7 @@ describe('JourneyDetailPage', () => {
|
||||
await user.click(galleryBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/1 photos/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 photo\b/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Gallery photos render in a grid; each photo has a group container
|
||||
@@ -2024,7 +2024,7 @@ describe('JourneyDetailPage', () => {
|
||||
await user.click(galleryBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/1 photos/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 photo\b/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The photo has caption 'Colosseum'
|
||||
@@ -2064,7 +2064,7 @@ describe('JourneyDetailPage', () => {
|
||||
await user.click(galleryBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/1 photos/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 photo\b/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Immich')).toBeInTheDocument();
|
||||
@@ -2105,7 +2105,7 @@ describe('JourneyDetailPage', () => {
|
||||
await user.click(galleryBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/1 photos/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 photo\b/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Synology Photos')).toBeInTheDocument();
|
||||
@@ -3357,12 +3357,12 @@ describe('JourneyDetailPage', () => {
|
||||
await user.click(galleryBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/1 photos/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 photo\b/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The gallery photo has a delete (X) button that appears on hover
|
||||
// In the gallery grid, each photo container has an X button
|
||||
const galleryGrid = screen.getByText(/1 photos/i).closest('div')!.parentElement!;
|
||||
const galleryGrid = screen.getByText(/1 photo\b/i).closest('div')!.parentElement!;
|
||||
const xButtons = galleryGrid.querySelectorAll('button');
|
||||
// Find the X delete button on the photo
|
||||
const deleteBtn = Array.from(xButtons).find(btn => {
|
||||
|
||||
@@ -468,7 +468,7 @@ function JourneyDetailPageDesktop() {
|
||||
<h3 className="text-[14px] font-semibold capitalize" style={{ color: 'var(--vg-ink)' }}>{new Date(date + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-[0.07em]" style={{ background: 'var(--vg-surf2)', color: 'var(--vg-ink3)' }}><MapPin size={12} /> {entries.length} {t('journey.synced.places')}</span>
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-[0.07em]" style={{ background: 'var(--vg-surf2)', color: 'var(--vg-ink3)' }}><MapPin size={12} /> {entries.length} {t('journey.synced.places', { count: entries.length })}</span>
|
||||
{/* The only Add button used to be at the very top and always
|
||||
started on today, so putting something into an earlier day
|
||||
meant correcting the date by hand (discussion #2299). */}
|
||||
|
||||
@@ -208,7 +208,7 @@ function JourneyPageDesktop() {
|
||||
<span className="text-[13px] text-zinc-500">
|
||||
{filteredJourneys.length === 0
|
||||
? t('journey.search.noResults', { query: searchQuery.trim() })
|
||||
: `${filteredJourneys.length} ${t('journey.frontpage.journeys')}`}
|
||||
: `${filteredJourneys.length} ${t('journey.frontpage.journeys', { count: filteredJourneys.length })}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -372,9 +372,10 @@ function CreateJourneyDialog({ title, onTitleChange, subtitle, onSubtitleChange,
|
||||
let status: TripStatus = 'upcoming'
|
||||
if (trip.end_date && trip.end_date < today) status = 'completed'
|
||||
else if (trip.start_date && trip.start_date <= today) status = 'active'
|
||||
// A trip without dates has no length to show, so it gets no day chip.
|
||||
const days = trip.start_date
|
||||
? Math.ceil((new Date(trip.end_date || trip.start_date).getTime() - new Date(trip.start_date).getTime()) / 86400000) + 1
|
||||
: '?'
|
||||
: null
|
||||
return (
|
||||
<div
|
||||
key={trip.id}
|
||||
@@ -396,8 +397,8 @@ function CreateJourneyDialog({ title, onTitleChange, subtitle, onSubtitleChange,
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-semibold text-content" style={fs(13.5, 'body')}>{trip.title}</div>
|
||||
<div className="mt-0.5 flex items-center gap-2.5 text-content-faint" style={fs(11.5)}>
|
||||
<span className="flex items-center gap-1"><Calendar size={11} /> {days} {t('journey.stats.days').toLowerCase()}</span>
|
||||
<span className="flex items-center gap-1"><MapPin size={11} /> {trip.place_count || 0} {t('journey.frontpage.places')}</span>
|
||||
{days !== null && <span className="flex items-center gap-1"><Calendar size={11} /> {t('mobileJourney.daysCount', { count: days })}</span>}
|
||||
<span className="flex items-center gap-1"><MapPin size={11} /> {trip.place_count || 0} {t('journey.frontpage.places', { count: trip.place_count || 0 })}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`flex-none rounded-full px-2 py-[2px] font-semibold ${TRIP_STATUS_LOOK[status]}`} style={fs(10.5)}>
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('JourneyPage wiring', () => {
|
||||
it('FE-JRN-LISTWIRE-005: an active search reports how many journeys matched', () => {
|
||||
const journeys = [buildJourney({ id: 1, title: 'Kyoto trip' })];
|
||||
setup({ journeys, filteredJourneys: journeys, searchQuery: 'kyo' });
|
||||
expect(screen.getByText('1 journeys')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 journey')).toBeInTheDocument();
|
||||
expect(screen.queryByText('All Journeys')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -52,8 +52,8 @@ export default function SharedTripPage() {
|
||||
];
|
||||
|
||||
const stats: HeroStat[] = [
|
||||
{ key: 'days', value: data.days?.length || 0, label: t('dashboard.days') },
|
||||
{ key: 'places', value: data.places?.length || 0, label: t('dashboard.places') },
|
||||
{ key: 'days', value: data.days?.length || 0, label: t('dashboard.days', { count: data.days?.length || 0 }) },
|
||||
{ key: 'places', value: data.places?.length || 0, label: t('dashboard.places', { count: data.places?.length || 0 }) },
|
||||
{ key: 'bookings', value: permissions?.share_bookings ? reservations.length : 0, label: t('trip.tabs.reservations') },
|
||||
].filter(s => s.value > 0);
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
|
||||
<SettingsCard
|
||||
icon={Users}
|
||||
title={t('admin.tabs.users')}
|
||||
hint={<>{users.length} {t('admin.stats.users')}</>}
|
||||
hint={<>{users.length} {t('admin.stats.users', { count: users.length })}</>}
|
||||
action={
|
||||
<button type="button" onClick={() => setShowCreateUser(true)} className={SETTINGS_BUTTON_PRIMARY} style={fs(12.5, 'body')}>
|
||||
<UserPlus size={14} strokeWidth={2.1} />
|
||||
|
||||
@@ -300,7 +300,7 @@ export function useJourneyDetail() {
|
||||
try {
|
||||
const { restored } = await journeyApi.restoreSuggestions(current.id)
|
||||
await loadJourney(current.id)
|
||||
toast.success(t('journey.suggestions.restored', { count: String(restored) }))
|
||||
toast.success(t('journey.suggestions.restored', { count: restored }))
|
||||
} catch {
|
||||
toast.error(t('common.errorTitle'))
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export function SharedDayCard(p: SharedDayCardProps) {
|
||||
</span>
|
||||
<span className="mt-1.5 flex min-w-0 flex-wrap items-center gap-1">
|
||||
{p.stays.map(stay => <StayPill key={stay.id} stay={stay} dayId={day.id} />)}
|
||||
{!p.travelOnly && <SoftPill>{p.placeCount} {t('shared.places')}</SoftPill>}
|
||||
{!p.travelOnly && <SoftPill>{p.placeCount} {t('shared.places', { count: p.placeCount })}</SoftPill>}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -26,7 +26,7 @@ export function SharedUnplannedCard({ places, categories }: SharedUnplannedCardP
|
||||
<MapPin size={14} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-bold text-content" style={fs(13.5, 'body')}>{t('shared.unplanned')}</span>
|
||||
<SoftPill>{places.length} {t('shared.places')}</SoftPill>
|
||||
<SoftPill>{places.length} {t('shared.places', { count: places.length })}</SoftPill>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(c => !c)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-I18NCTX-001 to FE-COMP-I18NCTX-017 (plus -003b / -013b)
|
||||
// FE-COMP-I18NCTX-001 to FE-COMP-I18NCTX-018 (plus -003b / -013b)
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { SUPPORTED_LANGUAGES, TranslationProvider, useTranslation } from '../../../src/i18n/TranslationContext';
|
||||
import { TransHtml } from '../../../src/i18n/TransHtml';
|
||||
@@ -140,6 +140,16 @@ describe('TranslationProvider', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-COMP-I18NCTX-018: plural forms follow the language of the strings on screen, not the one still loading', async () => {
|
||||
withLanguage('ru');
|
||||
render(<TranslationProvider><Probe tKey="places.count" params={{ count: 21 }} /></TranslationProvider>);
|
||||
|
||||
// English strings until the Russian chunk arrives, read with the English
|
||||
// rule: 21 is "other" there, while Russian's rule would pick English's .one.
|
||||
expect(screen.getByTestId('value').textContent).toBe('21 places');
|
||||
await waitFor(() => expect(screen.getByTestId('value').textContent).toBe('21 место'));
|
||||
});
|
||||
|
||||
it('FE-COMP-I18NCTX-012: the default context outside a provider echoes the key', () => {
|
||||
render(<Probe tKey="common.save" />);
|
||||
expect(screen.getByTestId('value').textContent).toBe('common.save');
|
||||
|
||||
@@ -1,25 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { TranslationStrings } from '@trek/shared/i18n'
|
||||
import { pluralFormOf, pluralGroups } from '@trek/shared'
|
||||
import { SUPPORTED_LANGUAGE_CODES, type TranslationStrings } from '@trek/shared/i18n'
|
||||
import en from '@trek/shared/i18n/en'
|
||||
import de from '@trek/shared/i18n/de'
|
||||
import es from '@trek/shared/i18n/es'
|
||||
import fr from '@trek/shared/i18n/fr'
|
||||
import hu from '@trek/shared/i18n/hu'
|
||||
import itIT from '@trek/shared/i18n/it'
|
||||
import tr from '@trek/shared/i18n/tr'
|
||||
import ru from '@trek/shared/i18n/ru'
|
||||
import zh from '@trek/shared/i18n/zh'
|
||||
import zhTW from '@trek/shared/i18n/zh-TW'
|
||||
import nl from '@trek/shared/i18n/nl'
|
||||
import idID from '@trek/shared/i18n/id'
|
||||
import ar from '@trek/shared/i18n/ar'
|
||||
import br from '@trek/shared/i18n/br'
|
||||
import cs from '@trek/shared/i18n/cs'
|
||||
import pl from '@trek/shared/i18n/pl'
|
||||
import ja from '@trek/shared/i18n/ja'
|
||||
import ko from '@trek/shared/i18n/ko'
|
||||
import uk from '@trek/shared/i18n/uk'
|
||||
import gr from '@trek/shared/i18n/gr'
|
||||
|
||||
// Runtime guard for the aggregated i18n bundles. `t()` resolves keys against the
|
||||
// active locale's flat dot-key map (see TranslationContext), so a key that is
|
||||
@@ -29,50 +11,38 @@ import gr from '@trek/shared/i18n/gr'
|
||||
//
|
||||
// The shared package also runs a file-level parity check (shared/scripts), but
|
||||
// that one only inspects per-domain source files; this one asserts the *merged*
|
||||
// export each locale actually serves to the app.
|
||||
// export each locale actually serves to the app. The locales come from the one
|
||||
// registry, so a language added there is checked here without a second list.
|
||||
|
||||
const NON_EN_LOCALES: Record<string, TranslationStrings> = {
|
||||
de, es, fr, hu, it: itIT, tr, ru, zh, 'zh-TW': zhTW, nl, id: idID,
|
||||
ar, br, cs, pl, ja, ko, uk, gr,
|
||||
}
|
||||
const NON_EN_CODES = SUPPORTED_LANGUAGE_CODES.filter((code) => code !== 'en')
|
||||
|
||||
// A count-bearing string's category forms (`places.count.one`, `.few`, ...)
|
||||
// differ by language on purpose: Russian needs `.few` and `.many`, Japanese
|
||||
// none. They are left out of the key comparison here; the shared parity CLI
|
||||
// checks each locale has exactly the forms its plural rule needs.
|
||||
const VARIANTS = ['zero', 'one', 'two', 'few', 'many']
|
||||
const allEnKeys = new Set(Object.keys(en))
|
||||
const pluralGroups = new Set(
|
||||
[...allEnKeys]
|
||||
.filter((k) => VARIANTS.includes(k.slice(k.lastIndexOf('.') + 1)))
|
||||
.map((k) => k.slice(0, k.lastIndexOf('.')))
|
||||
.filter((base) => allEnKeys.has(base) || allEnKeys.has(`${base}.other`)),
|
||||
)
|
||||
const isVariant = (k: string) =>
|
||||
VARIANTS.includes(k.slice(k.lastIndexOf('.') + 1)) && pluralGroups.has(k.slice(0, k.lastIndexOf('.')))
|
||||
const enKeys = new Set([...allEnKeys].filter((k) => !isVariant(k)))
|
||||
const groups = pluralGroups(Object.keys(en))
|
||||
const isVariant = (k: string) => pluralFormOf(k, groups) !== null
|
||||
const enKeys = new Set(Object.keys(en).filter((k) => !isVariant(k)))
|
||||
|
||||
describe('i18n locale key parity', () => {
|
||||
it('covers every non-en locale', () => {
|
||||
// Keep the assertion set in lockstep with the supported language list minus en.
|
||||
expect(Object.keys(NON_EN_LOCALES)).toHaveLength(19)
|
||||
it('covers every supported locale but en', () => {
|
||||
expect(NON_EN_CODES.length).toBeGreaterThanOrEqual(26)
|
||||
})
|
||||
|
||||
for (const [locale, strings] of Object.entries(NON_EN_LOCALES)) {
|
||||
it(`${locale} has the exact same key set as en`, () => {
|
||||
const localeKeys = new Set(Object.keys(strings).filter((k) => !isVariant(k)))
|
||||
const missing = [...enKeys].filter((k) => !localeKeys.has(k))
|
||||
const extra = [...localeKeys].filter((k) => !enKeys.has(k))
|
||||
it.each(NON_EN_CODES)('%s has the exact same key set as en', async (locale) => {
|
||||
const strings = ((await import(`@trek/shared/i18n/${locale}`)) as { default: TranslationStrings }).default
|
||||
const localeKeys = new Set(Object.keys(strings).filter((k) => !isVariant(k)))
|
||||
const missing = [...enKeys].filter((k) => !localeKeys.has(k))
|
||||
const extra = [...localeKeys].filter((k) => !enKeys.has(k))
|
||||
|
||||
const diagnostic =
|
||||
`Locale "${locale}" key drift vs en — ` +
|
||||
`missing ${missing.length}` +
|
||||
(missing.length ? ` (${missing.slice(0, 10).join(', ')}${missing.length > 10 ? ', …' : ''})` : '') +
|
||||
`; extra ${extra.length}` +
|
||||
(extra.length ? ` (${extra.slice(0, 10).join(', ')}${extra.length > 10 ? ', …' : ''})` : '')
|
||||
const diagnostic =
|
||||
`Locale "${locale}" key drift vs en — ` +
|
||||
`missing ${missing.length}` +
|
||||
(missing.length ? ` (${missing.slice(0, 10).join(', ')}${missing.length > 10 ? ', …' : ''})` : '') +
|
||||
`; extra ${extra.length}` +
|
||||
(extra.length ? ` (${extra.slice(0, 10).join(', ')}${extra.length > 10 ? ', …' : ''})` : '')
|
||||
|
||||
expect(missing, diagnostic).toEqual([])
|
||||
expect(extra, diagnostic).toEqual([])
|
||||
})
|
||||
}
|
||||
expect(missing, diagnostic).toEqual([])
|
||||
expect(extra, diagnostic).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -69,7 +69,7 @@ describe('MAdminPackingTemplateManager', () => {
|
||||
await screen.findByText('Beach Trip');
|
||||
expect(screen.getByText('City Break')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 categories · 5 items')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 categories · 3 items')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 category · 3 items')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-APKG-003: a failing list request surfaces the load error toast', async () => {
|
||||
|
||||
@@ -301,7 +301,7 @@ describe('MAdminPluginsPanel — toolbar', () => {
|
||||
[registryEntry({ id: 'zulu', name: 'Zulu', latest: '2.0.0' }), registryEntry({ id: 'alpha', name: 'Alpha', latest: '1.0.0' })],
|
||||
);
|
||||
render(<MAdminPluginsPanel />);
|
||||
await screen.findByText('1 updates available for your plugins.');
|
||||
await screen.findByText('1 update available for your plugins.');
|
||||
|
||||
fireEvent.click(screen.getByTitle('Sort: Name'));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Updates first' }));
|
||||
@@ -318,7 +318,7 @@ describe('MAdminPluginsPanel — toolbar', () => {
|
||||
[registryEntry({ id: 'alpha', name: 'Alpha', latest: '2.0.0' }), registryEntry({ id: 'zulu', name: 'Zulu', latest: '9.0.0' })],
|
||||
);
|
||||
render(<MAdminPluginsPanel />);
|
||||
await screen.findByText('1 updates available for your plugins.');
|
||||
await screen.findByText('1 update available for your plugins.');
|
||||
|
||||
fireEvent.click(screen.getByTitle('Status: All'));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Update available' }));
|
||||
@@ -932,7 +932,7 @@ describe('MAdminPluginsPanel — updates and consent', () => {
|
||||
);
|
||||
render(<MAdminPluginsPanel />);
|
||||
|
||||
expect(await screen.findByText('1 updates available for your plugins.')).toBeInTheDocument();
|
||||
expect(await screen.findByText('1 update available for your plugins.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /update → v2\.0\.0/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ describe('MAdminUsersSection', () => {
|
||||
it('FE-MOB-AUSERS-001: lists users with role badges, the "you" marker and the count', () => {
|
||||
renderSection({ users: [ME, ALICE], currentUser: ME });
|
||||
|
||||
expect(screen.getByText('2 users')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 Users')).toBeInTheDocument();
|
||||
expect(screen.getByText('admin')).toBeInTheDocument();
|
||||
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('Administrator')).toBeInTheDocument();
|
||||
|
||||
@@ -133,8 +133,8 @@ describe('MAtlas', () => {
|
||||
render(<MAtlas />);
|
||||
|
||||
expect(await screen.findByText('Japan')).toBeInTheDocument();
|
||||
expect(screen.getAllByText((_, el) => el?.textContent?.trim() === '2 atlas.places').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText((_, el) => el?.textContent?.trim() === '2 atlas.trips').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText((_, el) => el?.textContent?.trim() === '2 atlas.places:2').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText((_, el) => el?.textContent?.trim() === '2 atlas.trips:2').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('Kansai')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ describe('MCollTripPickerSheet', () => {
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Japan 2020/ }))
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled())
|
||||
expect(onCopy).toHaveBeenCalledWith(1)
|
||||
expect(addToast).toHaveBeenCalledWith('Copied 1 places', 'success', undefined)
|
||||
expect(addToast).toHaveBeenCalledWith('Copied 1 place', 'success', undefined)
|
||||
})
|
||||
|
||||
it('FE-MOB-CTRIPP-010: duplicates the server skipped get their own toast', async () => {
|
||||
@@ -122,7 +122,7 @@ describe('MCollTripPickerSheet', () => {
|
||||
.mockResolvedValue({ copied: 2, skipped: [{ id: 9, name: 'Louvre' }] })
|
||||
setup({ onCopy })
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Japan 2020/ }))
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Skipped 1 duplicates', 'info', undefined))
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Skipped 1 duplicate', 'info', undefined))
|
||||
expect(addToast).toHaveBeenCalledWith('Copied 2 places', 'success', undefined)
|
||||
})
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ describe('MBagsSheet', () => {
|
||||
setup()
|
||||
expect(screen.getByRole('dialog')).toHaveAttribute('aria-label', 'packing.bags')
|
||||
expect(within(rowFor('Backpack')).getByText('300 g')).toBeInTheDocument()
|
||||
expect(within(rowFor('Backpack')).getByText('1 admin.packingTemplates.items')).toBeInTheDocument()
|
||||
expect(within(rowFor('Backpack')).getByText('1 admin.packingTemplates.items:1')).toBeInTheDocument()
|
||||
expect(within(rowFor('Suitcase')).getByText('1.2 kg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -290,7 +290,7 @@ describe('MBagsSheet', () => {
|
||||
})
|
||||
|
||||
expect(within(rowFor('Backpack')).getByText('200 g')).toBeInTheDocument()
|
||||
expect(within(rowFor('Backpack')).getByText('1 admin.packingTemplates.items')).toBeInTheDocument()
|
||||
expect(within(rowFor('Backpack')).getByText('1 admin.packingTemplates.items:1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BAGS-024: common items still count for everyone', () => {
|
||||
|
||||
@@ -116,7 +116,7 @@ describe('MCollabPolls', () => {
|
||||
] })])
|
||||
|
||||
expect(await screen.findByText('Where to eat?')).toBeInTheDocument()
|
||||
expect(screen.getByText('collab.polls.vote:1')).toBeInTheDocument()
|
||||
expect(screen.getByText('collab.polls.votes:1')).toBeInTheDocument()
|
||||
expect(screen.getByText('collab.polls.multiChoice')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -217,7 +217,7 @@ describe('MCollabPolls', () => {
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Ramen/ }))
|
||||
|
||||
await waitFor(() => expect(votePoll).toHaveBeenCalledWith(1, 1, 0))
|
||||
expect(await screen.findByText('collab.polls.vote:1')).toBeInTheDocument()
|
||||
expect(await screen.findByText('collab.polls.votes:1')).toBeInTheDocument()
|
||||
expect(screen.getByText('100%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -304,7 +304,7 @@ describe('MCollabPolls', () => {
|
||||
{ text: 'Sushi', label: 'Sushi', voters: [] },
|
||||
] }),
|
||||
}))
|
||||
expect(screen.getByText('collab.polls.vote:1')).toBeInTheDocument()
|
||||
expect(screen.getByText('collab.polls.votes:1')).toBeInTheDocument()
|
||||
|
||||
act(() => handler({ type: 'collab:poll:closed', tripId: '1', poll: poll({ id: 2, question: 'Which hotel?', is_closed: true }) }))
|
||||
expect(screen.getByText('collab.polls.closed')).toBeInTheDocument()
|
||||
|
||||
@@ -208,7 +208,7 @@ describe('MCostSheet', () => {
|
||||
fillBasics('Taxi', '30')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'B bob' }))
|
||||
|
||||
expect(screen.getByText('Split 1 ways · €30.00 each')).toBeInTheDocument()
|
||||
expect(screen.getByText('1 person · €30.00')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Tap to include')).toHaveLength(1)
|
||||
|
||||
fireEvent.click(submit())
|
||||
|
||||
@@ -224,7 +224,7 @@ describe('MPackingListTab', () => {
|
||||
|
||||
openActions()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.applyTemplate' }))
|
||||
expect(screen.getByText('12 admin.packingTemplates.items')).toBeInTheDocument()
|
||||
expect(screen.getByText('12 admin.packingTemplates.items:12')).toBeInTheDocument()
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Beach trip/ })) })
|
||||
|
||||
// The visibility follows the Shared|My-list toggle so the items land where
|
||||
|
||||
@@ -135,7 +135,7 @@ describe('MPlacesSaveToCollectionSheet', () => {
|
||||
})
|
||||
renderSheet()
|
||||
fireEvent.click(await screen.findByText('Favorites'))
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Skipped 1 duplicates', 'info', undefined))
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Skipped 1 duplicate', 'info', undefined))
|
||||
expect(addToast).toHaveBeenCalledWith('Saved 1 to Favorites', 'success', undefined)
|
||||
})
|
||||
|
||||
|
||||
@@ -141,16 +141,16 @@ describe('MRtCorridorSheet', () => {
|
||||
expect(screen.getByText('roadtrip.poi.capped')).toBeInTheDocument()
|
||||
expect(screen.getByText('roadtrip.poi.partial:2')).toBeInTheDocument()
|
||||
// The phone's own wording: the desk's ends in "narrow the corridor", and the
|
||||
// corridor width is a control this screen does not have. One stretch takes the
|
||||
// singular key, because one key with {count} in it would read as "1 stretches".
|
||||
expect(screen.getByText('mobileTrip.rtTruncated.one:1')).toBeInTheDocument()
|
||||
// corridor width is a control this screen does not have. The count goes to t(),
|
||||
// which picks the form the language needs, so one stretch never reads "1 stretches".
|
||||
expect(screen.getByText('mobileTrip.rtTruncated:1')).toBeInTheDocument()
|
||||
expect(screen.queryByText('roadtrip.poi.truncated:1')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSRCH-011b: more than one truncated stretch takes the plural key', () => {
|
||||
it('FE-MOB-RTSRCH-011b: more than one truncated stretch passes its count to the same key', () => {
|
||||
renderSheet({ answered: true, hits: [hit()], truncatedAreas: 3 })
|
||||
|
||||
expect(screen.getByText('mobileTrip.rtTruncated.other:3')).toBeInTheDocument()
|
||||
expect(screen.getByText('mobileTrip.rtTruncated:3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSRCH-012: a hit says what it is and where on the drive it sits', () => {
|
||||
|
||||
@@ -463,7 +463,7 @@ describe('MTripSheets', () => {
|
||||
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Upload' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Edit track colour' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '1 files' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '1 file' }))
|
||||
expect(screen.getByText('ridge.gpx')).toBeInTheDocument()
|
||||
expect(Boolean(screen.queryByRole('button', { name: /Add to day/i }))).toBe(dayEdit)
|
||||
},
|
||||
|
||||
+2
-1
@@ -47,7 +47,8 @@ The parity rule governs **existing** routes; it is not a license to mint new deb
|
||||
- **`<locale>/`** — one folder per language, one file per UI domain plus an `index.ts` barrel; each file exports a flat map of dot-namespaced keys typed as `TranslationStrings`. The runtime `t(key)` only resolves these top-level keys.
|
||||
- **`en/` is canonical.** Every other locale must have the identical file set and top-level keys — `i18n:parity:strict` enforces it in CI. When you add or rename a key, update every locale.
|
||||
- **Every locale gets a real translation — an English placeholder is not acceptable.** Parity only checks that the key exists, so copying the `en` string passes CI while shipping English to those users. Write native phrasing with the locale's own punctuation and the same `{placeholders}` as `en`; if you genuinely cannot translate one, say so instead of filling it with English.
|
||||
- **Further i18n specs run in `npm test`** and fail changes that parity lets through: placeholder parity (every `{placeholder}` in an `en` string must appear in each translation), no `en` string may call TREK "self-hosted" (the same build runs on managed installs; exemptions live in the spec), and a wording regression test for plugin permission descriptions (currently one string — add a case there when a permission's real behavior changes). None of these are part of `i18n:parity`, which checks key sets only.
|
||||
- **Count-bearing strings use plural forms, never `count === 1` in the caller.** The key holds the general form (the CLDR "other" category) and `key.zero|one|two|few|many` hold the forms the language's `Intl.PluralRules` selects for whole numbers (`plural.ts`); `t(key, { count })` (or `{ n }`) picks one. Pass the count as a number. Russian needs `.one`, `.few` and `.many`, Arabic all five, Japanese none. `i18n:parity` checks that every locale has exactly the forms its language reaches with whole numbers and none it never selects. A form may spell the number out (Arabic "ملف واحد") only where its category is that one number; French `one` also covers 0 and Russian `one` covers 21, so they keep `{count}`.
|
||||
- **Further i18n specs run in `npm test`** and fail changes that parity lets through: placeholder parity (every `{placeholder}` in an `en` string must appear in each translation), no `en` string may call TREK "self-hosted" (the same build runs on managed installs; exemptions live in the spec), and a wording regression test for plugin permission descriptions (currently one string — add a case there when a permission's real behavior changes). None of these are part of `i18n:parity`, which checks key sets and plural forms only.
|
||||
|
||||
## Sanitization (`src/sanitize/sanitize.ts`)
|
||||
|
||||
|
||||
@@ -29,15 +29,17 @@ import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getIntlLanguage } from '../src/i18n/languages.ts';
|
||||
import { allPluralCategories, integerPluralCategories } from '../src/i18n/plural.ts';
|
||||
import { allPluralCategories, integerPluralCategories, pluralFormOf, pluralGroups } from '../src/i18n/plural.ts';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const i18nRoot = join(here, '..', 'src', 'i18n');
|
||||
|
||||
// Match a top-level translation key declaration: leading whitespace, then a
|
||||
// quoted key (must start with a lowercase letter), then a colon. This is the
|
||||
// exact pattern every domain file uses.
|
||||
const TOP_LEVEL_KEY_RE = /^\s*'([a-z][a-zA-Z0-9.\-_]*)'\s*:/gm;
|
||||
// exact pattern every domain file uses. Keys may hold a colon themselves
|
||||
// (admin.plugins.perm.db:read:trips); without it in the class those keys were
|
||||
// never compared, and two locales lacked dozens of them unnoticed.
|
||||
const TOP_LEVEL_KEY_RE = /^\s*'([a-z][a-zA-Z0-9.\-_:]*)'\s*:/gm;
|
||||
|
||||
function listLocales() {
|
||||
return readdirSync(i18nRoot)
|
||||
@@ -61,31 +63,8 @@ function extractKeys(locale, file) {
|
||||
return keys;
|
||||
}
|
||||
|
||||
const VARIANT_CATEGORIES = ['zero', 'one', 'two', 'few', 'many'];
|
||||
|
||||
/** The plural groups en defines in one domain file: their base keys. */
|
||||
function pluralGroups(enKeys) {
|
||||
const groups = new Set();
|
||||
for (const key of enKeys) {
|
||||
const dot = key.lastIndexOf('.');
|
||||
if (dot < 0 || !VARIANT_CATEGORIES.includes(key.slice(dot + 1))) continue;
|
||||
const base = key.slice(0, dot);
|
||||
if (enKeys.has(base) || enKeys.has(`${base}.other`)) groups.add(base);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** `{ base, category }` when `key` is a category form of one of `groups`. */
|
||||
function asVariant(key, groups) {
|
||||
const dot = key.lastIndexOf('.');
|
||||
if (dot < 0) return null;
|
||||
const category = key.slice(dot + 1);
|
||||
const base = key.slice(0, dot);
|
||||
return VARIANT_CATEGORIES.includes(category) && groups.has(base) ? { base, category } : null;
|
||||
}
|
||||
|
||||
function withoutVariants(keys, groups) {
|
||||
return new Set([...keys].filter((k) => !asVariant(k, groups)));
|
||||
return new Set([...keys].filter((k) => !pluralFormOf(k, groups)));
|
||||
}
|
||||
|
||||
/** What one locale owes the plural groups of one file. */
|
||||
@@ -99,8 +78,8 @@ function checkPluralForms(locale, keys, groups) {
|
||||
for (const c of required) if (!keys.has(`${base}.${c}`)) missing.push(`${base}.${c}`);
|
||||
}
|
||||
for (const key of keys) {
|
||||
const v = asVariant(key, groups);
|
||||
if (v && !allowed.has(v.category)) invalid.push(key);
|
||||
const form = pluralFormOf(key, groups);
|
||||
if (form && !allowed.has(form.category)) invalid.push(key);
|
||||
}
|
||||
return { missing, invalid };
|
||||
}
|
||||
|
||||
@@ -62,11 +62,31 @@ const admin: TranslationStrings = {
|
||||
'admin.tabs.backup': 'النسخ الاحتياطي',
|
||||
'admin.tabs.notifications': 'الإشعارات',
|
||||
'admin.tabs.audit': 'تدقيق',
|
||||
'admin.stats.users': 'المستخدمون',
|
||||
'admin.stats.trips': 'الرحلات',
|
||||
'admin.stats.places': 'الأماكن',
|
||||
'admin.stats.users': 'مستخدم',
|
||||
'admin.stats.users.zero': 'مستخدمين',
|
||||
'admin.stats.users.one': 'مستخدم',
|
||||
'admin.stats.users.two': 'مستخدمان',
|
||||
'admin.stats.users.few': 'مستخدمين',
|
||||
'admin.stats.users.many': 'مستخدمًا',
|
||||
'admin.stats.trips': 'رحلة',
|
||||
'admin.stats.trips.zero': 'رحلات',
|
||||
'admin.stats.trips.one': 'رحلة',
|
||||
'admin.stats.trips.two': 'رحلتان',
|
||||
'admin.stats.trips.few': 'رحلات',
|
||||
'admin.stats.trips.many': 'رحلة',
|
||||
'admin.stats.places': 'مكان',
|
||||
'admin.stats.places.zero': 'أماكن',
|
||||
'admin.stats.places.one': 'مكان',
|
||||
'admin.stats.places.two': 'مكانان',
|
||||
'admin.stats.places.few': 'أماكن',
|
||||
'admin.stats.places.many': 'مكانًا',
|
||||
'admin.stats.photos': 'الصور',
|
||||
'admin.stats.files': 'الملفات',
|
||||
'admin.stats.files': 'ملف',
|
||||
'admin.stats.files.zero': 'ملفات',
|
||||
'admin.stats.files.one': 'ملف',
|
||||
'admin.stats.files.two': 'ملفان',
|
||||
'admin.stats.files.few': 'ملفات',
|
||||
'admin.stats.files.many': 'ملفًا',
|
||||
'admin.table.user': 'المستخدم',
|
||||
'admin.table.email': 'البريد الإلكتروني',
|
||||
'admin.table.role': 'الدور',
|
||||
@@ -248,8 +268,18 @@ const admin: TranslationStrings = {
|
||||
'admin.packingTemplates.create': 'قالب جديد',
|
||||
'admin.packingTemplates.namePlaceholder': 'اسم القالب (مثال: عطلة شاطئية)',
|
||||
'admin.packingTemplates.empty': 'لم يتم إنشاء قوالب بعد',
|
||||
'admin.packingTemplates.items': 'عناصر',
|
||||
'admin.packingTemplates.categories': 'فئات',
|
||||
'admin.packingTemplates.items': 'عنصر',
|
||||
'admin.packingTemplates.items.zero': 'عناصر',
|
||||
'admin.packingTemplates.items.one': 'عنصر',
|
||||
'admin.packingTemplates.items.two': 'عنصران',
|
||||
'admin.packingTemplates.items.few': 'عناصر',
|
||||
'admin.packingTemplates.items.many': 'عنصرًا',
|
||||
'admin.packingTemplates.categories': 'فئة',
|
||||
'admin.packingTemplates.categories.zero': 'فئات',
|
||||
'admin.packingTemplates.categories.one': 'فئة',
|
||||
'admin.packingTemplates.categories.two': 'فئتان',
|
||||
'admin.packingTemplates.categories.few': 'فئات',
|
||||
'admin.packingTemplates.categories.many': 'فئة',
|
||||
'admin.packingTemplates.itemName': 'اسم العنصر',
|
||||
'admin.packingTemplates.itemCategory': 'الفئة',
|
||||
'admin.packingTemplates.categoryName': 'اسم الفئة (مثال: ملابس)',
|
||||
@@ -298,6 +328,11 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.allowedHosts.restartNote': 'الحفظ يعيد تشغيل الإضافة لتأخذ القائمة الجديدة.',
|
||||
'admin.plugins.allowedHosts.add': 'إضافة مضيف مسموح به',
|
||||
'admin.plugins.allowedHosts.count': '{n} مضيف مسموح به',
|
||||
'admin.plugins.allowedHosts.count.zero': 'لا يوجد مضيفون مسموح بهم',
|
||||
'admin.plugins.allowedHosts.count.one': 'مضيف واحد مسموح به',
|
||||
'admin.plugins.allowedHosts.count.two': 'مضيفان مسموح بهما',
|
||||
'admin.plugins.allowedHosts.count.few': '{n} مضيفين مسموح بهم',
|
||||
'admin.plugins.allowedHosts.count.many': '{n} مضيفًا مسموحًا به',
|
||||
'admin.plugins.operatorEgressPill': '+ مضيفون تضيفهم',
|
||||
'admin.plugins.operatorEgressHint':
|
||||
'تتصل هذه الإضافة بخدمة لا يمكن لأحد سواك تسميتها (خادم مستضاف ذاتيًا). بعد التثبيت أضف المضيفين من ⋯ ← المضيفون المسموح بهم. ولن تصل إلى غيرهم.',
|
||||
@@ -515,7 +550,12 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.sortRecent': 'المحدّثة حديثًا',
|
||||
'admin.plugins.sortUpdates': 'التحديثات أولًا',
|
||||
'admin.plugins.sortDownloads': 'الأكثر تنزيلًا',
|
||||
'admin.plugins.updatesAvailable': '{count} تحديثات متاحة لإضافاتك.',
|
||||
'admin.plugins.updatesAvailable': 'يتوفر {count} تحديث لإضافاتك.',
|
||||
'admin.plugins.updatesAvailable.zero': 'لا تتوفر تحديثات لإضافاتك.',
|
||||
'admin.plugins.updatesAvailable.one': 'يتوفر تحديث واحد لإضافاتك.',
|
||||
'admin.plugins.updatesAvailable.two': 'يتوفر تحديثان لإضافاتك.',
|
||||
'admin.plugins.updatesAvailable.few': 'يتوفر {count} تحديثات لإضافاتك.',
|
||||
'admin.plugins.updatesAvailable.many': 'يتوفر {count} تحديثًا لإضافاتك.',
|
||||
'admin.plugins.newerNeedsTrek': 'الإصدار v{version} متوفر — يتطلب TREK {range}',
|
||||
'admin.plugins.versionsTitle': 'الإصدارات',
|
||||
'admin.plugins.versionPickerTitle': 'تغيير الإصدار — {name}',
|
||||
@@ -783,7 +823,12 @@ const admin: TranslationStrings = {
|
||||
'admin.passkey.resetHint':
|
||||
'إزالة جميع مفاتيح المرور لهذا المستخدم (مثلًا عند فقدان جهاز). سيظل بإمكانه تسجيل الدخول بكلمة المرور.',
|
||||
'admin.passkey.resetConfirm': 'إزالة جميع مفاتيح المرور لـ {name}؟',
|
||||
'admin.passkey.resetDone': 'تمت إزالة {count} من مفاتيح المرور',
|
||||
'admin.passkey.resetDone': 'تمت إزالة {count} مفتاح مرور',
|
||||
'admin.passkey.resetDone.zero': 'لم تتم إزالة أي مفتاح مرور',
|
||||
'admin.passkey.resetDone.one': 'تمت إزالة مفتاح مرور واحد',
|
||||
'admin.passkey.resetDone.two': 'تمت إزالة مفتاحَي مرور',
|
||||
'admin.passkey.resetDone.few': 'تمت إزالة {count} مفاتيح مرور',
|
||||
'admin.passkey.resetDone.many': 'تمت إزالة {count} مفتاح مرور',
|
||||
'admin.defaultSettings.mapProvider': 'محرك الخرائط',
|
||||
'admin.defaultSettings.mapProviderHint':
|
||||
'الخريطة الافتراضية لجميع المستخدمين على هذا الخادم. لا يزال بإمكان كل مستخدم تجاوزها في إعداداته الخاصة.',
|
||||
|
||||
+62
-11
@@ -2,9 +2,24 @@ import type { TranslationStrings } from '../types';
|
||||
|
||||
const atlas: TranslationStrings = {
|
||||
'atlas.subtitle': 'بصمتك السفرية حول العالم',
|
||||
'atlas.countries': 'الدول',
|
||||
'atlas.trips': 'الرحلات',
|
||||
'atlas.places': 'الأماكن',
|
||||
'atlas.countries': 'دولة',
|
||||
'atlas.countries.zero': 'دول',
|
||||
'atlas.countries.one': 'دولة',
|
||||
'atlas.countries.two': 'دولتان',
|
||||
'atlas.countries.few': 'دول',
|
||||
'atlas.countries.many': 'دولة',
|
||||
'atlas.trips': 'رحلة',
|
||||
'atlas.trips.zero': 'رحلات',
|
||||
'atlas.trips.one': 'رحلة',
|
||||
'atlas.trips.two': 'رحلتان',
|
||||
'atlas.trips.few': 'رحلات',
|
||||
'atlas.trips.many': 'رحلة',
|
||||
'atlas.places': 'مكان',
|
||||
'atlas.places.zero': 'أماكن',
|
||||
'atlas.places.one': 'مكان',
|
||||
'atlas.places.two': 'مكانان',
|
||||
'atlas.places.few': 'أماكن',
|
||||
'atlas.places.many': 'مكانًا',
|
||||
'atlas.placesShow': 'عرض الأماكن في هذا البلد',
|
||||
'atlas.placesSearch': 'البحث عن أماكن',
|
||||
'atlas.placesNone': 'لا يوجد مكان مطابق',
|
||||
@@ -33,20 +48,43 @@ const atlas: TranslationStrings = {
|
||||
'atlas.bucketNotesPlaceholder': 'ملاحظات (اختياري)',
|
||||
'atlas.bucketEmpty': 'قائمة أمنياتك فارغة',
|
||||
'atlas.bucketEmptyHint': 'أضف أماكن تحلم بزيارتها',
|
||||
'atlas.days': 'الأيام',
|
||||
'atlas.days': 'يوم',
|
||||
'atlas.days.zero': 'أيام',
|
||||
'atlas.days.one': 'يوم',
|
||||
'atlas.days.two': 'يومان',
|
||||
'atlas.days.few': 'أيام',
|
||||
'atlas.days.many': 'يومًا',
|
||||
'atlas.visitedCountries': 'الدول التي تمت زيارتها',
|
||||
'atlas.cities': 'المدن',
|
||||
'atlas.regions': 'المناطق',
|
||||
'atlas.cities': 'مدينة',
|
||||
'atlas.cities.zero': 'مدن',
|
||||
'atlas.cities.one': 'مدينة',
|
||||
'atlas.cities.two': 'مدينتان',
|
||||
'atlas.cities.few': 'مدن',
|
||||
'atlas.cities.many': 'مدينة',
|
||||
'atlas.regions': 'منطقة',
|
||||
'atlas.regions.zero': 'مناطق',
|
||||
'atlas.regions.one': 'منطقة',
|
||||
'atlas.regions.two': 'منطقتان',
|
||||
'atlas.regions.few': 'مناطق',
|
||||
'atlas.regions.many': 'منطقة',
|
||||
'atlas.noData': 'لا توجد بيانات سفر بعد',
|
||||
'atlas.noDataHint': 'أنشئ رحلة وأضف أماكن لرؤية خريطتك العالمية',
|
||||
'atlas.nextTrip': 'الرحلة القادمة',
|
||||
'atlas.daysLeft': 'يوم متبقٍ',
|
||||
'atlas.streak': 'سلسلة',
|
||||
'atlas.years': 'سنوات',
|
||||
'atlas.yearsInRow': 'سنوات متتالية',
|
||||
'atlas.yearsInRow': 'سنة متتالية',
|
||||
'atlas.yearsInRow.zero': 'سنوات متتالية',
|
||||
'atlas.yearsInRow.one': 'سنة متتالية',
|
||||
'atlas.tripsIn': 'رحلات في',
|
||||
'atlas.yearsInRow.two': 'سنتان متتاليتان',
|
||||
'atlas.yearsInRow.few': 'سنوات متتالية',
|
||||
'atlas.yearsInRow.many': 'سنة متتالية',
|
||||
'atlas.tripsIn': 'رحلة في',
|
||||
'atlas.tripsIn.zero': 'رحلات في',
|
||||
'atlas.tripsIn.one': 'رحلة في',
|
||||
'atlas.tripsIn.two': 'رحلتان في',
|
||||
'atlas.tripsIn.few': 'رحلات في',
|
||||
'atlas.tripsIn.many': 'رحلة في',
|
||||
'atlas.since': 'منذ',
|
||||
'atlas.europe': 'أوروبا',
|
||||
'atlas.asia': 'آسيا',
|
||||
@@ -57,14 +95,27 @@ const atlas: TranslationStrings = {
|
||||
'atlas.other': 'أخرى',
|
||||
'atlas.firstVisit': 'أول رحلة',
|
||||
'atlas.lastVisitLabel': 'آخر رحلة',
|
||||
'atlas.tripPlural': 'رحلات',
|
||||
'atlas.tripsLabel': 'رحلات',
|
||||
'atlas.tripsLabel': 'رحلة',
|
||||
'atlas.tripsLabel.zero': 'رحلات',
|
||||
'atlas.tripsLabel.one': 'رحلة',
|
||||
'atlas.placesVisited': 'أماكن تمت زيارتها',
|
||||
'atlas.tripsLabel.two': 'رحلتان',
|
||||
'atlas.tripsLabel.few': 'رحلات',
|
||||
'atlas.tripsLabel.many': 'رحلة',
|
||||
'atlas.placesVisited': 'مكان تمت زيارته',
|
||||
'atlas.placesVisited.zero': 'أماكن تمت زيارتها',
|
||||
'atlas.placesVisited.one': 'مكان تمت زيارته',
|
||||
'atlas.placesVisited.two': 'مكانان تمت زيارتهما',
|
||||
'atlas.placesVisited.few': 'أماكن تمت زيارتها',
|
||||
'atlas.placesVisited.many': 'مكانًا تمت زيارته',
|
||||
'atlas.planned': 'مُخطط',
|
||||
'atlas.showPlanned': 'إظهار الدول المخطط زيارتها',
|
||||
'atlas.plannedFor': 'مخطط لـ',
|
||||
'atlas.antarctica': 'أنتاركتيكا',
|
||||
'atlas.regionPlacesLabel': 'مكان',
|
||||
'atlas.regionPlacesLabel.zero': 'أماكن',
|
||||
'atlas.regionPlacesLabel.one': 'مكان',
|
||||
'atlas.regionPlacesLabel.two': 'مكانان',
|
||||
'atlas.regionPlacesLabel.few': 'أماكن',
|
||||
'atlas.regionPlacesLabel.many': 'مكانًا',
|
||||
};
|
||||
export default atlas;
|
||||
|
||||
@@ -28,7 +28,12 @@ const budget: TranslationStrings = {
|
||||
'budget.byCategory': 'حسب الفئة',
|
||||
'budget.editTooltip': 'انقر للتعديل',
|
||||
'budget.linkedToReservation': 'مرتبط بحجز — عدّل الاسم هناك',
|
||||
'budget.confirm.deleteCategory': 'هل تريد حذف الفئة "{name}" مع {count} إدخالات؟',
|
||||
'budget.confirm.deleteCategory': 'هل تريد حذف الفئة "{name}" مع {count} إدخال؟',
|
||||
'budget.confirm.deleteCategory.zero': 'هل تريد حذف الفئة "{name}" الفارغة؟',
|
||||
'budget.confirm.deleteCategory.one': 'هل تريد حذف الفئة "{name}" مع إدخال واحد؟',
|
||||
'budget.confirm.deleteCategory.two': 'هل تريد حذف الفئة "{name}" مع إدخالين؟',
|
||||
'budget.confirm.deleteCategory.few': 'هل تريد حذف الفئة "{name}" مع {count} إدخالات؟',
|
||||
'budget.confirm.deleteCategory.many': 'هل تريد حذف الفئة "{name}" مع {count} إدخالًا؟',
|
||||
'budget.deleteCategory': 'حذف الفئة',
|
||||
'budget.perPerson': 'لكل شخص',
|
||||
'budget.paid': 'مدفوع',
|
||||
@@ -58,7 +63,12 @@ const budget: TranslationStrings = {
|
||||
'costs.outstandingSub': 'مصاريف بلا دافع بعد',
|
||||
'costs.outstandingItems': 'مصاريف بحاجة إلى دافع',
|
||||
'costs.expenses': 'المصروفات',
|
||||
'costs.entries': '{count} إدخالات',
|
||||
'costs.entries': '{count} إدخال',
|
||||
'costs.entries.zero': 'لا توجد إدخالات',
|
||||
'costs.entries.one': 'إدخال واحد',
|
||||
'costs.entries.two': 'إدخالان',
|
||||
'costs.entries.few': '{count} إدخالات',
|
||||
'costs.entries.many': '{count} إدخالًا',
|
||||
'costs.searchPlaceholder': 'ابحث في المصروفات…',
|
||||
'costs.filter.all': 'الكل',
|
||||
'costs.filter.mine': 'دفعتها أنا',
|
||||
@@ -101,7 +111,12 @@ const budget: TranslationStrings = {
|
||||
'costs.noCategories': 'لا توجد مصروفات بعد.',
|
||||
'costs.settleHistory': 'سجل التسويات',
|
||||
'costs.noSettlements': 'لا توجد مدفوعات مسوّاة بعد.',
|
||||
'costs.paymentsSettled': 'تمت تسوية {count} مدفوعات',
|
||||
'costs.paymentsSettled': 'تمت تسوية {count} دفعة',
|
||||
'costs.paymentsSettled.zero': 'لم تتم تسوية أي دفعة',
|
||||
'costs.paymentsSettled.one': 'تمت تسوية دفعة واحدة',
|
||||
'costs.paymentsSettled.two': 'تمت تسوية دفعتين',
|
||||
'costs.paymentsSettled.few': 'تمت تسوية {count} دفعات',
|
||||
'costs.paymentsSettled.many': 'تمت تسوية {count} دفعة',
|
||||
'costs.paid': 'مدفوع',
|
||||
'costs.undo': 'تراجع',
|
||||
'costs.whatFor': 'لأجل ماذا كان؟',
|
||||
@@ -123,7 +138,12 @@ const budget: TranslationStrings = {
|
||||
'costs.payersUnbalanced': 'يجب أن يكون مجموع مبالغ الدافعين {amount}',
|
||||
'costs.splitBetween': 'تقسيم بالتساوي بين',
|
||||
'costs.pickSomeone': 'اختر شخصًا واحدًا على الأقل للتقسيم معه.',
|
||||
'costs.splitSummary': 'تقسيم على {count} · {amount} لكل واحد',
|
||||
'costs.splitSummary': 'تقسيم على {count} شخص · {amount} لكل منهم',
|
||||
'costs.splitSummary.zero': 'تقسيم على {count} أشخاص · {amount} لكل منهم',
|
||||
'costs.splitSummary.one': 'شخص واحد · {amount}',
|
||||
'costs.splitSummary.two': 'تقسيم على شخصين · {amount} لكل منهما',
|
||||
'costs.splitSummary.few': 'تقسيم على {count} أشخاص · {amount} لكل منهم',
|
||||
'costs.splitSummary.many': 'تقسيم على {count} شخصًا · {amount} لكل منهم',
|
||||
'costs.cat.accommodation': 'الإقامة',
|
||||
'costs.cat.food': 'الطعام والشراب',
|
||||
'costs.cat.groceries': 'البقالة',
|
||||
@@ -138,8 +158,18 @@ const budget: TranslationStrings = {
|
||||
'costs.cat.fuel': 'وقود',
|
||||
'costs.cat.parking': 'موقف سيارات',
|
||||
'costs.cat.other': 'أخرى',
|
||||
'costs.daysCount': '{count} أيام',
|
||||
'costs.travelers': '{count} مسافرين',
|
||||
'costs.daysCount': '{count} يوم',
|
||||
'costs.daysCount.zero': 'لا توجد أيام',
|
||||
'costs.daysCount.one': 'يوم واحد',
|
||||
'costs.daysCount.two': 'يومان',
|
||||
'costs.daysCount.few': '{count} أيام',
|
||||
'costs.daysCount.many': '{count} يومًا',
|
||||
'costs.travelers': '{count} مسافر',
|
||||
'costs.travelers.zero': 'لا يوجد مسافرون',
|
||||
'costs.travelers.one': 'مسافر واحد',
|
||||
'costs.travelers.two': 'مسافران',
|
||||
'costs.travelers.few': '{count} مسافرين',
|
||||
'costs.travelers.many': '{count} مسافرًا',
|
||||
'costs.liveRate': 'سعر مباشر',
|
||||
'costs.settleAll': 'تسوية الكل',
|
||||
'costs.payment': 'دفعة',
|
||||
@@ -165,6 +195,11 @@ const budget: TranslationStrings = {
|
||||
'costs.filter.allCategories': 'كل الفئات',
|
||||
'costs.filter.allDays': 'كل الأيام',
|
||||
'costs.expensesCount': '{count} مصروف',
|
||||
'costs.expensesCount.zero': 'لا توجد مصروفات',
|
||||
'costs.expensesCount.one': 'مصروف واحد',
|
||||
'costs.expensesCount.two': 'مصروفان',
|
||||
'costs.expensesCount.few': '{count} مصروفات',
|
||||
'costs.expensesCount.many': '{count} مصروفًا',
|
||||
'costs.ticketItemName': 'اسم العنصر',
|
||||
'costs.ticketSplitting': 'التقسيم:',
|
||||
'costs.ticketAddItem': 'إضافة عنصر',
|
||||
@@ -175,6 +210,11 @@ const budget: TranslationStrings = {
|
||||
'costs.splitSumOver': 'مجموع الحصص: {sum} من {total} (زائد {diff})',
|
||||
'costs.toggleSign': 'التبديل بين المصروف والاسترداد',
|
||||
'costs.receipts': 'الإيصالات',
|
||||
'costs.receipts.zero': 'إيصالات',
|
||||
'costs.receipts.one': 'إيصال',
|
||||
'costs.receipts.two': 'إيصالان',
|
||||
'costs.receipts.few': 'إيصالات',
|
||||
'costs.receipts.many': 'إيصالات',
|
||||
'costs.receiptsTitle': 'الإيصالات والفواتير',
|
||||
'costs.attachReceipt': 'إرفاق إيصال / فاتورة',
|
||||
'costs.attach': 'إرفاق',
|
||||
@@ -193,6 +233,11 @@ const budget: TranslationStrings = {
|
||||
'costs.noReceipts': 'لا توجد إيصالات مرفقة',
|
||||
'costs.deleteReceipt': 'حذف الإيصال',
|
||||
'costs.viewReceipt': 'عرض الإيصال',
|
||||
'costs.receiptLeftBehind': 'فشل الحفظ، وما زال هناك {count} إيصالات مرفوعة. احذفها من تبويب الملفات.',
|
||||
'costs.receiptLeftBehind': 'فشل الحفظ، وما زال هناك {count} إيصال مرفوع. احذفها من تبويب الملفات.',
|
||||
'costs.receiptLeftBehind.zero': 'فشل الحفظ، ولم يبقَ أي إيصال مرفوع.',
|
||||
'costs.receiptLeftBehind.one': 'فشل الحفظ، وما زال هناك إيصال مرفوع واحد. احذفه من تبويب الملفات.',
|
||||
'costs.receiptLeftBehind.two': 'فشل الحفظ، وما زال هناك إيصالان مرفوعان. احذفهما من تبويب الملفات.',
|
||||
'costs.receiptLeftBehind.few': 'فشل الحفظ، وما زال هناك {count} إيصالات مرفوعة. احذفها من تبويب الملفات.',
|
||||
'costs.receiptLeftBehind.many': 'فشل الحفظ، وما زال هناك {count} إيصالًا مرفوعًا. احذفها من تبويب الملفات.',
|
||||
};
|
||||
export default budget;
|
||||
|
||||
@@ -87,8 +87,12 @@ const collab: TranslationStrings = {
|
||||
'collab.polls.create': 'إنشاء استطلاع',
|
||||
'collab.polls.close': 'إغلاق',
|
||||
'collab.polls.closed': 'مغلق',
|
||||
'collab.polls.votes': '{n} أصوات',
|
||||
'collab.polls.votes.one': '{n} صوت',
|
||||
'collab.polls.votes': '{n} صوت',
|
||||
'collab.polls.votes.zero': 'لا توجد أصوات',
|
||||
'collab.polls.votes.one': 'صوت واحد',
|
||||
'collab.polls.votes.two': 'صوتان',
|
||||
'collab.polls.votes.few': '{n} أصوات',
|
||||
'collab.polls.votes.many': '{n} صوتًا',
|
||||
'collab.polls.multipleChoice': 'اختيار متعدد',
|
||||
'collab.polls.multiChoice': 'اختيار متعدد',
|
||||
'collab.polls.deadline': 'الموعد النهائي',
|
||||
|
||||
@@ -18,9 +18,24 @@ const collection: TranslationStrings = {
|
||||
'collections.moveToListTitle': 'نقل {count} إلى قائمة أخرى',
|
||||
'collections.duplicateToListTitle': 'تكرار {count} إلى قائمة أخرى',
|
||||
'collections.noOtherLists': 'لا توجد قوائم أخرى بعد',
|
||||
'collections.movedCount': 'تم نقل {count} أماكن',
|
||||
'collections.duplicatedCount': 'تم تكرار {count} أماكن',
|
||||
'collections.placeCount': '{count} أماكن',
|
||||
'collections.movedCount': 'تم نقل {count} مكان',
|
||||
'collections.movedCount.zero': 'لم يتم نقل أي مكان',
|
||||
'collections.movedCount.one': 'تم نقل مكان واحد',
|
||||
'collections.movedCount.two': 'تم نقل مكانين',
|
||||
'collections.movedCount.few': 'تم نقل {count} أماكن',
|
||||
'collections.movedCount.many': 'تم نقل {count} مكانًا',
|
||||
'collections.duplicatedCount': 'تم تكرار {count} مكان',
|
||||
'collections.duplicatedCount.zero': 'لم يتم تكرار أي مكان',
|
||||
'collections.duplicatedCount.one': 'تم تكرار مكان واحد',
|
||||
'collections.duplicatedCount.two': 'تم تكرار مكانين',
|
||||
'collections.duplicatedCount.few': 'تم تكرار {count} أماكن',
|
||||
'collections.duplicatedCount.many': 'تم تكرار {count} مكانًا',
|
||||
'collections.placeCount': '{count} مكان',
|
||||
'collections.placeCount.zero': 'لا توجد أماكن',
|
||||
'collections.placeCount.one': 'مكان واحد',
|
||||
'collections.placeCount.two': 'مكانان',
|
||||
'collections.placeCount.few': '{count} أماكن',
|
||||
'collections.placeCount.many': '{count} مكانًا',
|
||||
'collections.category': 'الفئة',
|
||||
'collections.noCategory': 'بدون فئة',
|
||||
'collections.editListTitle': 'تعديل القائمة',
|
||||
@@ -41,7 +56,12 @@ const collection: TranslationStrings = {
|
||||
'collections.importDoneTitle': 'اكتمل الاستيراد',
|
||||
'collections.importPickTripHint': 'اختر رحلة وانقل أماكنها إلى {name}.',
|
||||
'collections.importNoTrips': 'لا توجد رحلات بعد',
|
||||
'collections.importPlacesCount': '{count} أماكن',
|
||||
'collections.importPlacesCount': '{count} مكان',
|
||||
'collections.importPlacesCount.zero': 'لا توجد أماكن',
|
||||
'collections.importPlacesCount.one': 'مكان واحد',
|
||||
'collections.importPlacesCount.two': 'مكانان',
|
||||
'collections.importPlacesCount.few': '{count} أماكن',
|
||||
'collections.importPlacesCount.many': '{count} مكانًا',
|
||||
'collections.importSearchPlaces': 'البحث عن أماكن…',
|
||||
'collections.importOnlyNew': 'الجديد فقط',
|
||||
'collections.importSelectAll': 'تحديد الكل',
|
||||
@@ -50,11 +70,26 @@ const collection: TranslationStrings = {
|
||||
'collections.importUnscheduled': 'بدون يوم',
|
||||
'collections.importOnDay': 'اليوم {count}',
|
||||
'collections.importSelectedCount': 'تم تحديد {count}',
|
||||
'collections.importSelectedCount.zero': 'لم يتم تحديد شيء',
|
||||
'collections.importSelectedCount.one': 'تم تحديد {count}',
|
||||
'collections.importSelectedCount.two': 'تم تحديد {count}',
|
||||
'collections.importSelectedCount.few': 'تم تحديد {count}',
|
||||
'collections.importSelectedCount.many': 'تم تحديد {count}',
|
||||
'collections.importAction': 'استيراد {count}',
|
||||
'collections.importEmptyTrip': 'لا تحتوي هذه الرحلة على أماكن بعد',
|
||||
'collections.importNothingNew': 'كل شيء من هذه الرحلة موجود بالفعل في القائمة',
|
||||
'collections.importDone': 'تمت إضافة {count} أماكن',
|
||||
'collections.importDoneSkipped': '{count} كانت موجودة بالفعل وبقيت كما هي',
|
||||
'collections.importDone': 'تمت إضافة {count} مكان',
|
||||
'collections.importDone.zero': 'لم تتم إضافة أي مكان',
|
||||
'collections.importDone.one': 'تمت إضافة مكان واحد',
|
||||
'collections.importDone.two': 'تمت إضافة مكانين',
|
||||
'collections.importDone.few': 'تمت إضافة {count} أماكن',
|
||||
'collections.importDone.many': 'تمت إضافة {count} مكانًا',
|
||||
'collections.importDoneSkipped': '{count} مكان كانت في القائمة بالفعل وبقيت كما هي',
|
||||
'collections.importDoneSkipped.zero': 'لم يكن أي مكان في القائمة من قبل',
|
||||
'collections.importDoneSkipped.one': 'مكان واحد كان في القائمة بالفعل وبقي كما هو',
|
||||
'collections.importDoneSkipped.two': 'مكانان كانا في القائمة بالفعل وبقيا كما هما',
|
||||
'collections.importDoneSkipped.few': '{count} أماكن كانت في القائمة بالفعل وبقيت كما هي',
|
||||
'collections.importDoneSkipped.many': '{count} مكانًا كانت في القائمة بالفعل وبقيت كما هي',
|
||||
'collections.importDoneClean': 'جميعها الآن في {name}',
|
||||
'collections.sort.default': 'افتراضي',
|
||||
'collections.sort.nameAsc': 'الاسم (أ–ي)',
|
||||
@@ -103,6 +138,11 @@ const collection: TranslationStrings = {
|
||||
'collections.markVisitedNone': 'لا يوجد أي من هذه الأماكن في أي قائمة',
|
||||
'collections.markedVisited': 'تم وضع علامة كمَزار',
|
||||
'collections.markedVisitedTrip': 'تم وضع علامة على {count} مكان كمَزار',
|
||||
'collections.markedVisitedTrip.zero': 'لم يتم وضع علامة على أي مكان كمَزار',
|
||||
'collections.markedVisitedTrip.one': 'تم وضع علامة على مكان واحد كمَزار',
|
||||
'collections.markedVisitedTrip.two': 'تم وضع علامة على مكانين كمَزار',
|
||||
'collections.markedVisitedTrip.few': 'تم وضع علامة على {count} أماكن كمَزار',
|
||||
'collections.markedVisitedTrip.many': 'تم وضع علامة على {count} مكانًا كمَزار',
|
||||
|
||||
'collections.copyToTrip': 'نسخ إلى رحلة',
|
||||
'collections.copyToTripTitle': 'نسخ إلى رحلة',
|
||||
@@ -113,8 +153,18 @@ const collection: TranslationStrings = {
|
||||
'collections.noTrips': 'لا توجد رحلات بعد',
|
||||
'collections.alreadyInTrip': 'موجود بالفعل في هذه الرحلة',
|
||||
'collections.copied': 'تم النسخ إلى الرحلة',
|
||||
'collections.copiedCount': 'تم نسخ {count} أماكن',
|
||||
'collections.skippedDuplicates': 'تم تخطي {count} عناصر مكررة',
|
||||
'collections.copiedCount': 'تم نسخ {count} مكان',
|
||||
'collections.copiedCount.zero': 'لم يتم نسخ أي مكان',
|
||||
'collections.copiedCount.one': 'تم نسخ مكان واحد',
|
||||
'collections.copiedCount.two': 'تم نسخ مكانين',
|
||||
'collections.copiedCount.few': 'تم نسخ {count} أماكن',
|
||||
'collections.copiedCount.many': 'تم نسخ {count} مكانًا',
|
||||
'collections.skippedDuplicates': 'تم تخطي {count} عنصر مكرر',
|
||||
'collections.skippedDuplicates.zero': 'لم يتم تخطي أي عنصر مكرر',
|
||||
'collections.skippedDuplicates.one': 'تم تخطي عنصر مكرر واحد',
|
||||
'collections.skippedDuplicates.two': 'تم تخطي عنصرين مكررين',
|
||||
'collections.skippedDuplicates.few': 'تم تخطي {count} عناصر مكررة',
|
||||
'collections.skippedDuplicates.many': 'تم تخطي {count} عنصرًا مكررًا',
|
||||
'collections.copyNothing': 'لا شيء لنسخه',
|
||||
'collections.removeFromList': 'إزالة من القائمة',
|
||||
'collections.removeFromListConfirm': 'إزالة هذا المكان من القائمة؟',
|
||||
@@ -128,6 +178,11 @@ const collection: TranslationStrings = {
|
||||
'collections.search': 'البحث عن أماكن',
|
||||
'collections.selectMode': 'تحديد',
|
||||
'collections.selectedCount': 'تم تحديد {count}',
|
||||
'collections.selectedCount.zero': 'لم يتم تحديد شيء',
|
||||
'collections.selectedCount.one': 'تم تحديد {count}',
|
||||
'collections.selectedCount.two': 'تم تحديد {count}',
|
||||
'collections.selectedCount.few': 'تم تحديد {count}',
|
||||
'collections.selectedCount.many': 'تم تحديد {count}',
|
||||
|
||||
'collections.pickList': 'حفظ في قائمة',
|
||||
'collections.pickListPlaceholder': 'اختر قائمة',
|
||||
@@ -158,26 +213,63 @@ const collection: TranslationStrings = {
|
||||
'collections.file.searchLists': 'البحث في القوائم',
|
||||
'collections.file.intoHint': 'الأماكن الموجودة في القائمة تبقى كما هي، وكذلك اسمها ولونها. وتُضاف التسميات القادمة من الملف.',
|
||||
'collections.file.confirmInto': 'إضافة إلى القائمة',
|
||||
'collections.file.doneInto': 'تمت إضافة {count} مكانًا إلى {name}',
|
||||
'collections.file.doneInto': 'تمت إضافة {count} مكان إلى {name}',
|
||||
'collections.file.doneInto.zero': 'لم تتم إضافة أي مكان إلى {name}',
|
||||
'collections.file.doneInto.one': 'تمت إضافة مكان واحد إلى {name}',
|
||||
'collections.file.doneInto.two': 'تمت إضافة مكانين إلى {name}',
|
||||
'collections.file.doneInto.few': 'تمت إضافة {count} أماكن إلى {name}',
|
||||
'collections.file.doneInto.many': 'تمت إضافة {count} مكانًا إلى {name}',
|
||||
'collections.file.doneIntoDuplicates': 'تمت إضافة {count} إلى {name}، و{duplicates} كانت موجودة بالفعل',
|
||||
'collections.file.doneIntoNothing': 'كل الأماكن في الملف موجودة بالفعل في {name}',
|
||||
'collections.file.change': 'تغيير',
|
||||
'collections.file.labelCount': '{count} تسمية',
|
||||
'collections.file.labelCount.zero': 'لا توجد تسميات',
|
||||
'collections.file.labelCount.one': 'تسمية واحدة',
|
||||
'collections.file.labelCount.two': 'تسميتان',
|
||||
'collections.file.labelCount.few': '{count} تسميات',
|
||||
'collections.file.labelCount.many': '{count} تسمية',
|
||||
'collections.file.hint': 'تصل الأماكن كقائمة جديدة خاصة بك. أما التقييمات والأعضاء والصور المرفوعة فلا تأتي معها.',
|
||||
'collections.file.done': 'تم استيراد {count} مكانًا',
|
||||
'collections.file.doneSkipped': 'تم استيراد {count} مكانًا وتخطّي {skipped}',
|
||||
'collections.file.done': 'تم استيراد {count} مكان',
|
||||
'collections.file.done.zero': 'لم يتم استيراد أي مكان',
|
||||
'collections.file.done.one': 'تم استيراد مكان واحد',
|
||||
'collections.file.done.two': 'تم استيراد مكانين',
|
||||
'collections.file.done.few': 'تم استيراد {count} أماكن',
|
||||
'collections.file.done.many': 'تم استيراد {count} مكانًا',
|
||||
'collections.file.doneSkipped': 'تم استيراد {count} مكان وتخطّي {skipped}',
|
||||
'collections.file.doneSkipped.zero': 'لم يتم استيراد أي مكان، وتم تخطّي {skipped}',
|
||||
'collections.file.doneSkipped.one': 'تم استيراد مكان واحد وتخطّي {skipped}',
|
||||
'collections.file.doneSkipped.two': 'تم استيراد مكانين وتخطّي {skipped}',
|
||||
'collections.file.doneSkipped.few': 'تم استيراد {count} أماكن وتخطّي {skipped}',
|
||||
'collections.file.doneSkipped.many': 'تم استيراد {count} مكانًا وتخطّي {skipped}',
|
||||
'collections.file.errorTooLarge': 'هذا الملف أكبر من أن يكون قائمة.',
|
||||
'collections.file.errorUnreadable': 'تعذّرت قراءة هذا الملف.',
|
||||
'collections.file.errorNotACollection': 'هذا ليس ملف قائمة من TREK.',
|
||||
'collections.file.formatTrek': 'قائمة TREK',
|
||||
'collections.file.formatTrekHint': 'لنسخة TREK أخرى، مع التسميات والحالة',
|
||||
'collections.file.formatGpxHint': 'نقاط طريق لتطبيقات OsmAnd وOrganic Maps وGarmin وغيرها من تطبيقات الخرائط',
|
||||
'collections.file.gpxOmitted': 'لم يُضمَّن {count} من الأماكن في ملف GPX لأنها بلا إحداثيات',
|
||||
'collections.file.gpxOmitted': 'لم يُضمَّن {count} مكان في ملف GPX لأنها بلا إحداثيات',
|
||||
'collections.file.gpxOmitted.zero': 'لم يُستبعد أي مكان من ملف GPX',
|
||||
'collections.file.gpxOmitted.one': 'لم يُضمَّن مكان واحد في ملف GPX لأنه بلا إحداثيات',
|
||||
'collections.file.gpxOmitted.two': 'لم يُضمَّن مكانان في ملف GPX لأنهما بلا إحداثيات',
|
||||
'collections.file.gpxOmitted.few': 'لم يُضمَّن {count} أماكن في ملف GPX لأنها بلا إحداثيات',
|
||||
'collections.file.gpxOmitted.many': 'لم يُضمَّن {count} مكانًا في ملف GPX لأنها بلا إحداثيات',
|
||||
'collections.file.gpxNothing': 'لا يملك أي من هذه الأماكن إحداثيات، فلا يوجد ما يُوضع في ملف GPX.',
|
||||
'collections.file.reading': 'جارٍ قراءة الملف…',
|
||||
'collections.file.gpxSkipped': 'سيُستبعد {count} من نقاط الطريق لعدم وجود إحداثيات صالحة لها.',
|
||||
'collections.file.gpxTrack':
|
||||
'يحتوي هذا الملف أيضًا على {count} من نقاط المسار. لا تُستورد المسارات، بل نقاط الطريق فقط.',
|
||||
'collections.file.gpxSkipped': 'ستُستبعد {count} نقطة طريق لعدم وجود إحداثيات صالحة لها.',
|
||||
'collections.file.gpxSkipped.zero': 'لن تُستبعد أي نقطة طريق.',
|
||||
'collections.file.gpxSkipped.one': 'ستُستبعد نقطة طريق واحدة لعدم وجود إحداثيات صالحة لها.',
|
||||
'collections.file.gpxSkipped.two': 'ستُستبعد نقطتا طريق لعدم وجود إحداثيات صالحة لهما.',
|
||||
'collections.file.gpxSkipped.few': 'ستُستبعد {count} نقاط طريق لعدم وجود إحداثيات صالحة لها.',
|
||||
'collections.file.gpxSkipped.many': 'ستُستبعد {count} نقطة طريق لعدم وجود إحداثيات صالحة لها.',
|
||||
'collections.file.gpxTrack': 'يحتوي هذا الملف أيضًا على {count} نقطة مسار. لا تُستورد المسارات، بل نقاط الطريق فقط.',
|
||||
'collections.file.gpxTrack.zero': 'لا يحتوي هذا الملف على نقاط مسار. لا تُستورد المسارات، بل نقاط الطريق فقط.',
|
||||
'collections.file.gpxTrack.one':
|
||||
'يحتوي هذا الملف أيضًا على نقطة مسار واحدة. لا تُستورد المسارات، بل نقاط الطريق فقط.',
|
||||
'collections.file.gpxTrack.two': 'يحتوي هذا الملف أيضًا على نقطتَي مسار. لا تُستورد المسارات، بل نقاط الطريق فقط.',
|
||||
'collections.file.gpxTrack.few':
|
||||
'يحتوي هذا الملف أيضًا على {count} نقاط مسار. لا تُستورد المسارات، بل نقاط الطريق فقط.',
|
||||
'collections.file.gpxTrack.many':
|
||||
'يحتوي هذا الملف أيضًا على {count} نقطة مسار. لا تُستورد المسارات، بل نقاط الطريق فقط.',
|
||||
'collections.file.gpxEmpty': 'لا يحتوي ملف GPX هذا على نقاط طريق، فلا يوجد ما يُستورد.',
|
||||
'collections.file.errorNotGpx': 'هذا ليس ملف GPX.',
|
||||
'collections.file.errorTooManyPlaces':
|
||||
@@ -216,15 +308,30 @@ const collection: TranslationStrings = {
|
||||
|
||||
'collections.widget.title': 'المجموعات',
|
||||
'collections.widget.empty': 'لا توجد أماكن محفوظة بعد',
|
||||
'collections.widget.savedCount': '{count} محفوظة',
|
||||
'collections.widget.savedCount': '{count} مكان محفوظ',
|
||||
'collections.widget.savedCount.zero': 'لا توجد أماكن محفوظة',
|
||||
'collections.widget.savedCount.one': 'مكان محفوظ واحد',
|
||||
'collections.widget.savedCount.two': 'مكانان محفوظان',
|
||||
'collections.widget.savedCount.few': '{count} أماكن محفوظة',
|
||||
'collections.widget.savedCount.many': '{count} مكانًا محفوظًا',
|
||||
'collections.labels.title': 'التسميات',
|
||||
'collections.labels.manage': 'إدارة التسميات',
|
||||
'collections.labels.add': 'إضافة تسمية',
|
||||
'collections.labels.name': 'اسم التسمية',
|
||||
'collections.labels.namePlaceholder': 'مثال: برلين',
|
||||
'collections.labels.assign': 'تعيين تسمية',
|
||||
'collections.labels.assignN': 'إضافة تسميات إلى {count} أماكن',
|
||||
'collections.labels.assignedCount': 'تمت تسمية {count} أماكن',
|
||||
'collections.labels.assignN': 'إضافة تسميات إلى {count} مكان',
|
||||
'collections.labels.assignN.zero': 'إضافة تسميات',
|
||||
'collections.labels.assignN.one': 'إضافة تسميات إلى مكان واحد',
|
||||
'collections.labels.assignN.two': 'إضافة تسميات إلى مكانين',
|
||||
'collections.labels.assignN.few': 'إضافة تسميات إلى {count} أماكن',
|
||||
'collections.labels.assignN.many': 'إضافة تسميات إلى {count} مكانًا',
|
||||
'collections.labels.assignedCount': 'تمت إضافة التسميات إلى {count} مكان',
|
||||
'collections.labels.assignedCount.zero': 'لم تتم إضافة التسميات إلى أي مكان',
|
||||
'collections.labels.assignedCount.one': 'تمت إضافة التسميات إلى مكان واحد',
|
||||
'collections.labels.assignedCount.two': 'تمت إضافة التسميات إلى مكانين',
|
||||
'collections.labels.assignedCount.few': 'تمت إضافة التسميات إلى {count} أماكن',
|
||||
'collections.labels.assignedCount.many': 'تمت إضافة التسميات إلى {count} مكانًا',
|
||||
'collections.labels.empty': 'لا توجد تسميات بعد',
|
||||
'collections.labels.emptyHint': 'أنشئ تسمية أولًا لتجميع الأماكن في هذه القائمة.',
|
||||
};
|
||||
|
||||
@@ -4,10 +4,20 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.title': 'رحلاتي',
|
||||
'dashboard.subtitle.loading': 'جارٍ تحميل الرحلات...',
|
||||
'dashboard.subtitle.trips': '{count} رحلة ({archived} مؤرشفة)',
|
||||
'dashboard.subtitle.trips.zero': 'لا توجد رحلات ({archived} مؤرشفة)',
|
||||
'dashboard.subtitle.trips.one': 'رحلة واحدة ({archived} مؤرشفة)',
|
||||
'dashboard.subtitle.trips.two': 'رحلتان ({archived} مؤرشفة)',
|
||||
'dashboard.subtitle.trips.few': '{count} رحلات ({archived} مؤرشفة)',
|
||||
'dashboard.subtitle.trips.many': '{count} رحلة ({archived} مؤرشفة)',
|
||||
'dashboard.subtitle.empty': 'ابدأ رحلتك الأولى',
|
||||
'dashboard.subtitle.activeOne': '{count} رحلة نشطة',
|
||||
'dashboard.subtitle.activeMany': '{count} رحلات نشطة',
|
||||
'dashboard.subtitle.archivedSuffix': ' · {count} مؤرشفة',
|
||||
'dashboard.subtitle.archivedSuffix': ' · {count} رحلة مؤرشفة',
|
||||
'dashboard.subtitle.archivedSuffix.zero': ' · لا توجد رحلات مؤرشفة',
|
||||
'dashboard.subtitle.archivedSuffix.one': ' · رحلة مؤرشفة واحدة',
|
||||
'dashboard.subtitle.archivedSuffix.two': ' · رحلتان مؤرشفتان',
|
||||
'dashboard.subtitle.archivedSuffix.few': ' · {count} رحلات مؤرشفة',
|
||||
'dashboard.subtitle.archivedSuffix.many': ' · {count} رحلة مؤرشفة',
|
||||
'dashboard.newTrip': 'رحلة جديدة',
|
||||
'dashboard.newTripSub': 'خطّط لرحلة جديدة من الصفر',
|
||||
'dashboard.subscribeAllTrips': 'الاشتراك في جميع الرحلات',
|
||||
@@ -32,10 +42,25 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.shared': 'مشتركة',
|
||||
'dashboard.sharedBy': 'شاركها {name}',
|
||||
'dashboard.days': 'الأيام',
|
||||
'dashboard.days.zero': 'أيام',
|
||||
'dashboard.days.one': 'يوم',
|
||||
'dashboard.days.two': 'يومان',
|
||||
'dashboard.days.few': 'أيام',
|
||||
'dashboard.days.many': 'يومًا',
|
||||
'dashboard.places': 'الأماكن',
|
||||
'dashboard.members': 'ال חברים',
|
||||
'dashboard.card.buddies': 'ال חברים',
|
||||
'dashboard.places.zero': 'أماكن',
|
||||
'dashboard.places.one': 'مكان',
|
||||
'dashboard.places.two': 'مكانان',
|
||||
'dashboard.places.few': 'أماكن',
|
||||
'dashboard.places.many': 'مكانًا',
|
||||
'dashboard.pass.places': 'الأماكن',
|
||||
'dashboard.members': 'الرفاق',
|
||||
'dashboard.card.buddies': 'رفيق',
|
||||
'dashboard.card.buddies.zero': 'رفاق',
|
||||
'dashboard.card.buddies.one': 'رفيق',
|
||||
'dashboard.card.buddies.two': 'رفيقان',
|
||||
'dashboard.card.buddies.few': 'رفاق',
|
||||
'dashboard.card.buddies.many': 'رفيقًا',
|
||||
'dashboard.archive': 'أرشفة',
|
||||
'dashboard.copyTrip': 'نسخ',
|
||||
'dashboard.copySuffix': 'نسخة',
|
||||
@@ -49,7 +74,12 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.status.today': 'اليوم',
|
||||
'dashboard.status.tomorrow': 'غدًا',
|
||||
'dashboard.status.past': 'منتهية',
|
||||
'dashboard.status.daysLeft': 'متبقي {count} يوم',
|
||||
'dashboard.status.daysLeft': '{count} يوم متبقٍ',
|
||||
'dashboard.status.daysLeft.zero': 'لا توجد أيام متبقية',
|
||||
'dashboard.status.daysLeft.one': 'يوم واحد متبقٍ',
|
||||
'dashboard.status.daysLeft.two': 'يومان متبقيان',
|
||||
'dashboard.status.daysLeft.few': '{count} أيام متبقية',
|
||||
'dashboard.status.daysLeft.many': '{count} يومًا متبقيًا',
|
||||
'dashboard.toast.loadError': 'فشل تحميل الرحلات',
|
||||
'dashboard.loadErrorBanner': 'تعذّر الوصول إلى الخادم. رحلاتك في أمان — يرجى المحاولة مرة أخرى.',
|
||||
'dashboard.retry': 'إعادة المحاولة',
|
||||
@@ -127,14 +157,30 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.hero.badgeRecent': 'مؤخرًا',
|
||||
'dashboard.hero.tripDates': 'تواريخ الرحلة',
|
||||
'dashboard.hero.noDates': 'لا توجد تواريخ',
|
||||
'dashboard.hero.travelers': '{count} مسافرين',
|
||||
'dashboard.hero.travelers.one': '{count} مسافر',
|
||||
'dashboard.hero.destinations': '{count} وجهات',
|
||||
'dashboard.hero.destinations.one': '{count} وجهة',
|
||||
'dashboard.hero.dayUnit': 'أيام',
|
||||
'dashboard.hero.travelers': '{count} مسافر',
|
||||
'dashboard.hero.travelers.zero': 'لا يوجد مسافرون',
|
||||
'dashboard.hero.travelers.one': 'مسافر واحد',
|
||||
'dashboard.hero.travelers.two': 'مسافران',
|
||||
'dashboard.hero.travelers.few': '{count} مسافرين',
|
||||
'dashboard.hero.travelers.many': '{count} مسافرًا',
|
||||
'dashboard.hero.destinations': '{count} وجهة',
|
||||
'dashboard.hero.destinations.zero': 'لا توجد وجهات',
|
||||
'dashboard.hero.destinations.one': 'وجهة واحدة',
|
||||
'dashboard.hero.destinations.two': 'وجهتان',
|
||||
'dashboard.hero.destinations.few': '{count} وجهات',
|
||||
'dashboard.hero.destinations.many': '{count} وجهة',
|
||||
'dashboard.hero.dayUnit': 'يوم',
|
||||
'dashboard.hero.dayUnit.zero': 'أيام',
|
||||
'dashboard.hero.dayUnit.one': 'يوم',
|
||||
'dashboard.hero.daysLeft': 'الأيام المتبقية',
|
||||
'dashboard.hero.daysLeft.one': 'بقي يوم',
|
||||
'dashboard.hero.dayUnit.two': 'يومين',
|
||||
'dashboard.hero.dayUnit.few': 'أيام',
|
||||
'dashboard.hero.dayUnit.many': 'يومًا',
|
||||
'dashboard.hero.daysLeft': 'يوم متبقٍ',
|
||||
'dashboard.hero.daysLeft.zero': 'أيام متبقية',
|
||||
'dashboard.hero.daysLeft.one': 'يوم متبقٍ',
|
||||
'dashboard.hero.daysLeft.two': 'يومان متبقيان',
|
||||
'dashboard.hero.daysLeft.few': 'أيام متبقية',
|
||||
'dashboard.hero.daysLeft.many': 'يومًا متبقيًا',
|
||||
'dashboard.hero.lastDay': 'اليوم الأخير',
|
||||
'dashboard.hero.untilStart': 'حتى البداية',
|
||||
'dashboard.hero.startsIn': 'تبدأ الرحلة بعد',
|
||||
@@ -142,6 +188,11 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.atlas.ofTotal': 'من {total}',
|
||||
'dashboard.atlas.tripsTotal': 'إجمالي الرحلات',
|
||||
'dashboard.atlas.placesMapped': '{count} مكان على الخريطة',
|
||||
'dashboard.atlas.placesMapped.zero': 'لا توجد أماكن على الخريطة',
|
||||
'dashboard.atlas.placesMapped.one': 'مكان واحد على الخريطة',
|
||||
'dashboard.atlas.placesMapped.two': 'مكانان على الخريطة',
|
||||
'dashboard.atlas.placesMapped.few': '{count} أماكن على الخريطة',
|
||||
'dashboard.atlas.placesMapped.many': '{count} مكانًا على الخريطة',
|
||||
'dashboard.atlas.daysTraveled': 'أيام السفر',
|
||||
'dashboard.atlas.daysUnit': 'أيام',
|
||||
'dashboard.atlas.acrossAllTrips': 'عبر جميع الرحلات',
|
||||
@@ -176,6 +227,11 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.mobile.liveNow': 'مباشر الآن',
|
||||
'dashboard.mobile.tripProgress': 'تقدّم الرحلة',
|
||||
'dashboard.mobile.daysLeft': 'بقي {count} يوم',
|
||||
'dashboard.mobile.daysLeft.zero': 'لم يبقَ أي يوم',
|
||||
'dashboard.mobile.daysLeft.one': 'بقي يوم واحد',
|
||||
'dashboard.mobile.daysLeft.two': 'بقي يومان',
|
||||
'dashboard.mobile.daysLeft.few': 'بقي {count} أيام',
|
||||
'dashboard.mobile.daysLeft.many': 'بقي {count} يومًا',
|
||||
'dashboard.mobile.places': 'الأماكن',
|
||||
'dashboard.mobile.buddies': 'الرفاق',
|
||||
'dashboard.mobile.newTrip': 'رحلة جديدة',
|
||||
@@ -192,11 +248,25 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.mobile.ongoing': 'جارية',
|
||||
'dashboard.mobile.startsToday': 'تبدأ اليوم',
|
||||
'dashboard.mobile.tomorrow': 'غدًا',
|
||||
'dashboard.mobile.inDays': 'خلال {count} يوم',
|
||||
'dashboard.mobile.inMonths': 'خلال {count} شهر',
|
||||
'dashboard.mobile.inDays': 'بعد {count} يوم',
|
||||
'dashboard.mobile.inDays.zero': 'اليوم',
|
||||
'dashboard.mobile.inDays.one': 'بعد يوم واحد',
|
||||
'dashboard.mobile.inDays.two': 'بعد يومين',
|
||||
'dashboard.mobile.inDays.few': 'بعد {count} أيام',
|
||||
'dashboard.mobile.inDays.many': 'بعد {count} يومًا',
|
||||
'dashboard.mobile.inMonths': 'بعد {count} شهر',
|
||||
'dashboard.mobile.inMonths.zero': 'بعد أقل من شهر',
|
||||
'dashboard.mobile.inMonths.one': 'بعد شهر واحد',
|
||||
'dashboard.mobile.inMonths.two': 'بعد شهرين',
|
||||
'dashboard.mobile.inMonths.few': 'بعد {count} أشهر',
|
||||
'dashboard.mobile.inMonths.many': 'بعد {count} شهرًا',
|
||||
'dashboard.mobile.spotlightDayOf': 'اليوم {day} من {total}',
|
||||
'dashboard.mobile.spotlightDays': '{count} أيام',
|
||||
'dashboard.mobile.spotlightDays.one': '{count} يوم',
|
||||
'dashboard.mobile.spotlightDays': '{count} يوم',
|
||||
'dashboard.mobile.spotlightDays.zero': 'لا توجد أيام',
|
||||
'dashboard.mobile.spotlightDays.one': 'يوم واحد',
|
||||
'dashboard.mobile.spotlightDays.two': 'يومان',
|
||||
'dashboard.mobile.spotlightDays.few': '{count} أيام',
|
||||
'dashboard.mobile.spotlightDays.many': '{count} يومًا',
|
||||
'dashboard.mobile.completed': 'مكتملة',
|
||||
'dashboard.mobile.currencyConverter': 'محوّل العملات',
|
||||
'dashboard.aria.toggleView': 'تبديل العرض',
|
||||
|
||||
@@ -15,6 +15,11 @@ const dawarich: TranslationStrings = {
|
||||
'dawarich.syncEnabledHint': 'عند الإيقاف، لا يقرأ TREK من Dawarich إلا حين تطلب ذلك.',
|
||||
'dawarich.test.button': 'اختبار الاتصال',
|
||||
'dawarich.test.success': 'تم الاتصال. تم العثور على {count} إقامة خلال آخر 30 يوماً.',
|
||||
'dawarich.test.success.zero': 'تم الاتصال. لم يتم العثور على أي إقامة خلال آخر 30 يوماً.',
|
||||
'dawarich.test.success.one': 'تم الاتصال. تم العثور على إقامة واحدة خلال آخر 30 يوماً.',
|
||||
'dawarich.test.success.two': 'تم الاتصال. تم العثور على إقامتين خلال آخر 30 يوماً.',
|
||||
'dawarich.test.success.few': 'تم الاتصال. تم العثور على {count} إقامات خلال آخر 30 يوماً.',
|
||||
'dawarich.test.success.many': 'تم الاتصال. تم العثور على {count} إقامة خلال آخر 30 يوماً.',
|
||||
'dawarich.test.failed': 'تعذّر الوصول إلى Dawarich.',
|
||||
'dawarich.syncNow': 'تحقق الآن',
|
||||
'dawarich.connected': 'متصل',
|
||||
@@ -29,6 +34,11 @@ const dawarich: TranslationStrings = {
|
||||
'dawarich.toast.saveError': 'تعذّر حفظ الاتصال',
|
||||
'dawarich.toast.disconnected': 'تم قطع الاتصال بـ Dawarich',
|
||||
'dawarich.toast.synced': 'تم العثور على {count} إقامة جديدة',
|
||||
'dawarich.toast.synced.zero': 'لم يتم العثور على إقامات جديدة',
|
||||
'dawarich.toast.synced.one': 'تم العثور على إقامة جديدة واحدة',
|
||||
'dawarich.toast.synced.two': 'تم العثور على إقامتين جديدتين',
|
||||
'dawarich.toast.synced.few': 'تم العثور على {count} إقامات جديدة',
|
||||
'dawarich.toast.synced.many': 'تم العثور على {count} إقامة جديدة',
|
||||
'dawarich.toast.syncError': 'تعذّرت القراءة من Dawarich',
|
||||
'dawarich.toast.syncRunning': 'هناك فحص قيد التشغيل بالفعل',
|
||||
'dawarich.toast.acceptError': 'تعذّرت إضافة هذا',
|
||||
@@ -79,7 +89,12 @@ const dawarich: TranslationStrings = {
|
||||
'dawarich.badge.sourceMissing': 'اختفى من Dawarich',
|
||||
|
||||
'dawarich.suggestions.title': 'من Dawarich',
|
||||
'dawarich.suggestions.pending': '{count} في انتظارك',
|
||||
'dawarich.suggestions.pending': '{count} إقامة في انتظارك',
|
||||
'dawarich.suggestions.pending.zero': 'لا شيء في انتظارك',
|
||||
'dawarich.suggestions.pending.one': 'إقامة واحدة في انتظارك',
|
||||
'dawarich.suggestions.pending.two': 'إقامتان في انتظارك',
|
||||
'dawarich.suggestions.pending.few': '{count} إقامات في انتظارك',
|
||||
'dawarich.suggestions.pending.many': '{count} إقامة في انتظارك',
|
||||
'dawarich.suggestions.loading': 'جارٍ القراءة من Dawarich…',
|
||||
'dawarich.suggestions.notConnected': 'اربط Dawarich من الإعدادات لترى إقاماتك هنا.',
|
||||
'dawarich.suggestions.unavailable': 'تعذّرت القراءة من Dawarich.',
|
||||
@@ -142,7 +157,17 @@ const dawarich: TranslationStrings = {
|
||||
'dawarich.bucket.alreadyVisited': 'مشطوبة مسبقاً',
|
||||
'dawarich.bucket.confirm': 'اشطب {count}',
|
||||
'dawarich.bucket.confirmed': 'تم شطب {count} أمنية',
|
||||
'dawarich.bucket.skipped': '{count} مُدخلاً بلا إحداثيات وتعذّر فحصها.',
|
||||
'dawarich.bucket.confirmed.zero': 'لم يتم شطب أي أمنية',
|
||||
'dawarich.bucket.confirmed.one': 'تم شطب أمنية واحدة',
|
||||
'dawarich.bucket.confirmed.two': 'تم شطب أمنيتين',
|
||||
'dawarich.bucket.confirmed.few': 'تم شطب {count} أمنيات',
|
||||
'dawarich.bucket.confirmed.many': 'تم شطب {count} أمنية',
|
||||
'dawarich.bucket.skipped': 'تعذّر فحص {count} مُدخل لأنها بلا إحداثيات.',
|
||||
'dawarich.bucket.skipped.zero': 'لا توجد مُدخلات بلا إحداثيات.',
|
||||
'dawarich.bucket.skipped.one': 'تعذّر فحص مُدخل واحد لأنه بلا إحداثيات.',
|
||||
'dawarich.bucket.skipped.two': 'تعذّر فحص مُدخلين لأنهما بلا إحداثيات.',
|
||||
'dawarich.bucket.skipped.few': 'تعذّر فحص {count} مُدخلات لأنها بلا إحداثيات.',
|
||||
'dawarich.bucket.skipped.many': 'تعذّر فحص {count} مُدخلاً لأنها بلا إحداثيات.',
|
||||
'dawarich.bucket.truncated': 'تم فحص المُدخلات الأولى فقط. شغّل الفحص مجدداً للبقية.',
|
||||
'dawarich.bucket.visitedFrom': 'شُطبت اعتماداً على تسجيلات Dawarich',
|
||||
'dawarich.bucket.clearVisit': 'تراجع',
|
||||
@@ -155,9 +180,23 @@ const dawarich: TranslationStrings = {
|
||||
'dawarich.atlas.loading': 'جارٍ قراءة تسجيلاتك…',
|
||||
'dawarich.atlas.empty': 'لا تُظهر تسجيلاتك أي دول ليست لدى TREK أصلاً.',
|
||||
'dawarich.atlas.cities': '{count} مدينة',
|
||||
'dawarich.atlas.citiesOne': 'مدينة واحدة',
|
||||
'dawarich.atlas.cities.zero': 'لا توجد مدن',
|
||||
'dawarich.atlas.cities.one': 'مدينة واحدة',
|
||||
'dawarich.atlas.cities.two': 'مدينتان',
|
||||
'dawarich.atlas.cities.few': '{count} مدن',
|
||||
'dawarich.atlas.cities.many': '{count} مدينة',
|
||||
'dawarich.atlas.accept': 'إضافة {count} دولة',
|
||||
'dawarich.atlas.accept.zero': 'إضافة الدول',
|
||||
'dawarich.atlas.accept.one': 'إضافة دولة واحدة',
|
||||
'dawarich.atlas.accept.two': 'إضافة دولتين',
|
||||
'dawarich.atlas.accept.few': 'إضافة {count} دول',
|
||||
'dawarich.atlas.accept.many': 'إضافة {count} دولة',
|
||||
'dawarich.atlas.accepted': 'تمت إضافة {count} دولة',
|
||||
'dawarich.atlas.accepted.zero': 'لم تتم إضافة أي دولة',
|
||||
'dawarich.atlas.accepted.one': 'تمت إضافة دولة واحدة',
|
||||
'dawarich.atlas.accepted.two': 'تمت إضافة دولتين',
|
||||
'dawarich.atlas.accepted.few': 'تمت إضافة {count} دول',
|
||||
'dawarich.atlas.accepted.many': 'تمت إضافة {count} دولة',
|
||||
'dawarich.atlas.unresolved': 'تعذّر على TREK مطابقة هذه بدولة: {names}.',
|
||||
'dawarich.atlas.source': 'من Dawarich',
|
||||
'dawarich.atlas.range': 'تم الاطلاع على الفترة من {from} إلى {to}',
|
||||
@@ -168,13 +207,22 @@ const dawarich: TranslationStrings = {
|
||||
'dawarich.atlas.tab.countries': 'البلدان',
|
||||
'dawarich.atlas.window': 'تم فحص آخر 12 شهرًا.',
|
||||
'dawarich.selected': 'تم تحديد {count}',
|
||||
'dawarich.selected.zero': 'لم يتم تحديد شيء',
|
||||
'dawarich.selected.one': 'تم تحديد {count}',
|
||||
'dawarich.selected.two': 'تم تحديد {count}',
|
||||
'dawarich.selected.few': 'تم تحديد {count}',
|
||||
'dawarich.selected.many': 'تم تحديد {count}',
|
||||
'dawarich.again': 'تحقق مرة أخرى',
|
||||
'dawarich.bucket.metersAway': 'على بُعد {meters} م',
|
||||
'dawarich.bucket.kilometersAway': 'على بُعد {km} كم',
|
||||
'dawarich.bucket.rule': 'تُحتسب الأمنية محققة ضمن {meters} م وبعد {minutes} دقيقة في المكان.',
|
||||
|
||||
'dawarich.journey.dayStays.other': '{count} إقامة من Dawarich',
|
||||
'dawarich.journey.dayStays.zero': 'لا توجد إقامات من Dawarich',
|
||||
'dawarich.journey.dayStays.one': 'إقامة واحدة من Dawarich',
|
||||
'dawarich.journey.dayStays.other': 'إقامات من Dawarich: {count}',
|
||||
'dawarich.journey.dayStays.two': 'إقامتان من Dawarich',
|
||||
'dawarich.journey.dayStays.few': '{count} إقامات من Dawarich',
|
||||
'dawarich.journey.dayStays.many': '{count} إقامة من Dawarich',
|
||||
|
||||
};
|
||||
|
||||
export default dawarich;
|
||||
|
||||
@@ -68,7 +68,7 @@ const docsync: TranslationStrings = {
|
||||
'docsync.state.local_deleted': 'محذوف في TREK',
|
||||
'docsync.state.scope_drift': 'نُقل خارج المجلد',
|
||||
|
||||
'docsync.conflict.resolve': "حلّ {count}",
|
||||
'docsync.conflict.resolve': 'حلّ {count}',
|
||||
|
||||
'docsync.conflict.title': 'تغيّرت النسختان',
|
||||
'docsync.conflict.keepTrek': 'الإبقاء على نسخة TREK',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { NotificationLocale } from '../externalNotifications/types';
|
||||
import { pluralForm } from '../plural';
|
||||
|
||||
const ar: NotificationLocale = {
|
||||
email: {
|
||||
@@ -37,8 +38,22 @@ const ar: NotificationLocale = {
|
||||
body: `${p.actor} يدعوك لمشاركة مجموعة. افتح TREK للقبول أو الرفض.`,
|
||||
}),
|
||||
photos_shared: (p) => ({
|
||||
title: `${p.count} صور مشتركة`,
|
||||
body: `${p.actor} شارك ${p.count} صورة في "${p.trip}".`,
|
||||
title: pluralForm(p.count, 'ar', {
|
||||
zero: 'لم تتم مشاركة أي صورة',
|
||||
one: 'تمت مشاركة صورة واحدة',
|
||||
two: 'تمت مشاركة صورتين',
|
||||
few: `تمت مشاركة ${p.count} صور`,
|
||||
many: `تمت مشاركة ${p.count} صورة`,
|
||||
other: `تمت مشاركة ${p.count} صورة`,
|
||||
}),
|
||||
body: pluralForm(p.count, 'ar', {
|
||||
zero: `${p.actor} لم يشارك أي صورة في "${p.trip}".`,
|
||||
one: `${p.actor} شارك صورة واحدة في "${p.trip}".`,
|
||||
two: `${p.actor} شارك صورتين في "${p.trip}".`,
|
||||
few: `${p.actor} شارك ${p.count} صور في "${p.trip}".`,
|
||||
many: `${p.actor} شارك ${p.count} صورة في "${p.trip}".`,
|
||||
other: `${p.actor} شارك ${p.count} صورة في "${p.trip}".`,
|
||||
}),
|
||||
}),
|
||||
collab_message: (p) => ({
|
||||
title: `رسالة جديدة في "${p.trip}"`,
|
||||
@@ -56,7 +71,16 @@ const ar: NotificationLocale = {
|
||||
title: 'فشل النسخة المتماثلة للتخزين',
|
||||
body:
|
||||
`فشلت الكتابة على النسخة المتماثلة '${p.backend}': ${p.op} لـ ${p.key} — ${p.error}.` +
|
||||
(p.suppressed !== '0' ? ` تم تجاهل ${p.suppressed} فشل إضافي منذ آخر إشعار.` : ''),
|
||||
(p.suppressed !== '0'
|
||||
? pluralForm(p.suppressed, 'ar', {
|
||||
zero: ' لم يتم تجاهل أي حالة فشل إضافية منذ آخر إشعار.',
|
||||
one: ' تم تجاهل حالة فشل إضافية واحدة منذ آخر إشعار.',
|
||||
two: ' تم تجاهل حالتَي فشل إضافيتين منذ آخر إشعار.',
|
||||
few: ` تم تجاهل ${p.suppressed} حالات فشل إضافية منذ آخر إشعار.`,
|
||||
many: ` تم تجاهل ${p.suppressed} حالة فشل إضافية منذ آخر إشعار.`,
|
||||
other: ` تم تجاهل ${p.suppressed} حالة فشل إضافية منذ آخر إشعار.`,
|
||||
})
|
||||
: ''),
|
||||
}),
|
||||
synology_session_cleared: () => ({
|
||||
title: 'تمت إعادة تعيين جلسة Synology',
|
||||
|
||||
@@ -10,12 +10,26 @@ const files: TranslationStrings = {
|
||||
'files.title': 'الملفات',
|
||||
'files.pageTitle': 'الملفات والمستندات',
|
||||
'files.subtitle': '{count} ملف لـ {trip}',
|
||||
'files.subtitle.zero': 'لا توجد ملفات لـ {trip}',
|
||||
'files.subtitle.one': 'ملف واحد لـ {trip}',
|
||||
'files.subtitle.two': 'ملفان لـ {trip}',
|
||||
'files.subtitle.few': '{count} ملفات لـ {trip}',
|
||||
'files.subtitle.many': '{count} ملفًا لـ {trip}',
|
||||
'files.download': 'تنزيل',
|
||||
'files.openError': 'تعذر فتح الملف',
|
||||
'files.downloadPdf': 'تنزيل PDF',
|
||||
'files.count': '{count} ملفات',
|
||||
'files.count': '{count} ملف',
|
||||
'files.count.zero': 'لا توجد ملفات',
|
||||
'files.count.one': 'ملف واحد',
|
||||
'files.uploaded': 'تم رفع {count}',
|
||||
'files.count.two': 'ملفان',
|
||||
'files.count.few': '{count} ملفات',
|
||||
'files.count.many': '{count} ملفًا',
|
||||
'files.uploaded': 'تم رفع {count} ملف',
|
||||
'files.uploaded.zero': 'لم يتم رفع أي ملف',
|
||||
'files.uploaded.one': 'تم رفع ملف واحد',
|
||||
'files.uploaded.two': 'تم رفع ملفين',
|
||||
'files.uploaded.few': 'تم رفع {count} ملفات',
|
||||
'files.uploaded.many': 'تم رفع {count} ملفًا',
|
||||
'files.uploadError': 'فشل الرفع',
|
||||
'files.uploadErrorType': 'نوع الملف هذا غير مدعوم',
|
||||
'files.dropzone': 'أسقط الملفات هنا',
|
||||
|
||||
@@ -15,9 +15,19 @@ const help: TranslationStrings = {
|
||||
'help.center.onThisScreen': 'في هذه الشاشة',
|
||||
'help.center.screens': 'الشاشات',
|
||||
'help.center.thisScreen': 'هذه الشاشة',
|
||||
'help.center.subScreens': 'الشاشات الفرعية: {count}',
|
||||
'help.center.subScreens': '{count} شاشة فرعية',
|
||||
'help.center.subScreens.zero': 'لا توجد شاشات فرعية',
|
||||
'help.center.subScreens.one': 'شاشة فرعية واحدة',
|
||||
'help.center.subScreens.two': 'شاشتان فرعيتان',
|
||||
'help.center.subScreens.few': '{count} شاشات فرعية',
|
||||
'help.center.subScreens.many': '{count} شاشة فرعية',
|
||||
'help.center.subScreensLabel': 'الشاشات الفرعية',
|
||||
'help.center.guidesCount': '{count} أدلة',
|
||||
'help.center.guidesCount': '{count} دليل',
|
||||
'help.center.guidesCount.zero': 'لا توجد أدلة',
|
||||
'help.center.guidesCount.one': 'دليل واحد',
|
||||
'help.center.guidesCount.two': 'دليلان',
|
||||
'help.center.guidesCount.few': '{count} أدلة',
|
||||
'help.center.guidesCount.many': '{count} دليلًا',
|
||||
'help.center.goToScreen': 'الانتقال إلى {screen}',
|
||||
'help.center.overview': 'نظرة عامة',
|
||||
'help.center.howTo': 'كيف يمكنني…',
|
||||
@@ -28,7 +38,12 @@ const help: TranslationStrings = {
|
||||
'help.center.searchError': 'البحث غير متاح حاليًا.',
|
||||
'help.center.back': 'رجوع',
|
||||
'help.center.close': 'إغلاق المساعدة',
|
||||
'help.center.steps': '{count} خطوات',
|
||||
'help.center.steps': '{count} خطوة',
|
||||
'help.center.steps.zero': 'لا توجد خطوات',
|
||||
'help.center.steps.one': 'خطوة واحدة',
|
||||
'help.center.steps.two': 'خطوتان',
|
||||
'help.center.steps.few': '{count} خطوات',
|
||||
'help.center.steps.many': '{count} خطوة',
|
||||
'help.center.step': 'الخطوة {n}',
|
||||
'help.center.stepsLabel': 'الخطوات',
|
||||
'help.center.stepOf': 'الخطوة {n} من {total}',
|
||||
|
||||
@@ -6,7 +6,12 @@ const inspector: TranslationStrings = {
|
||||
'inspector.openingHours': 'ساعات العمل',
|
||||
'inspector.showHours': 'عرض ساعات العمل',
|
||||
'inspector.files': 'الملفات',
|
||||
'inspector.filesCount': '{count} ملفات',
|
||||
'inspector.filesCount': '{count} ملف',
|
||||
'inspector.filesCount.zero': 'لا توجد ملفات',
|
||||
'inspector.filesCount.one': 'ملف واحد',
|
||||
'inspector.filesCount.two': 'ملفان',
|
||||
'inspector.filesCount.few': '{count} ملفات',
|
||||
'inspector.filesCount.many': '{count} ملفًا',
|
||||
'inspector.remove': 'إزالة',
|
||||
'inspector.removeFromDay': 'إزالة من اليوم',
|
||||
'inspector.addToDay': 'إضافة إلى اليوم',
|
||||
|
||||
@@ -5,7 +5,12 @@ const journey: TranslationStrings = {
|
||||
'journey.search.noResults': 'لا توجد رحلات تطابق "{query}"',
|
||||
'journey.status.archived': 'مؤرشف',
|
||||
'journey.detail.backToJourney': 'العودة للمجلة',
|
||||
'journey.detail.photos': 'صور',
|
||||
'journey.detail.photos': 'صورة',
|
||||
'journey.detail.photos.zero': 'صور',
|
||||
'journey.detail.photos.one': 'صورة',
|
||||
'journey.detail.photos.two': 'صورتان',
|
||||
'journey.detail.photos.few': 'صور',
|
||||
'journey.detail.photos.many': 'صورة',
|
||||
'journey.detail.day': 'اليوم {number}',
|
||||
'journey.detail.places': 'أماكن',
|
||||
'journey.skeletons.show': 'إظهار الاقتراحات',
|
||||
@@ -40,8 +45,18 @@ const journey: TranslationStrings = {
|
||||
'journey.settings.failedToDelete': 'فشل في الحذف',
|
||||
'journey.entries.deleteTitle': 'حذف الإدخال',
|
||||
'journey.photosUploaded': 'تم رفع {count} صورة',
|
||||
'journey.photosUploaded.zero': 'لم يتم رفع أي صورة',
|
||||
'journey.photosUploaded.one': 'تم رفع صورة واحدة',
|
||||
'journey.photosUploaded.two': 'تم رفع صورتين',
|
||||
'journey.photosUploaded.few': 'تم رفع {count} صور',
|
||||
'journey.photosUploaded.many': 'تم رفع {count} صورة',
|
||||
'journey.photosUploadFailed': 'فشل رفع بعض الصور',
|
||||
'journey.photosAdded': 'تمت إضافة {count} صورة',
|
||||
'journey.photosAdded.zero': 'لم تتم إضافة أي صورة',
|
||||
'journey.photosAdded.one': 'تمت إضافة صورة واحدة',
|
||||
'journey.photosAdded.two': 'تمت إضافة صورتين',
|
||||
'journey.photosAdded.few': 'تمت إضافة {count} صور',
|
||||
'journey.photosAdded.many': 'تمت إضافة {count} صورة',
|
||||
'journey.picker.tripPeriod': 'فترة الرحلة',
|
||||
'journey.picker.dateRange': 'نطاق التاريخ',
|
||||
'journey.picker.allPhotos': 'كل الصور',
|
||||
@@ -109,7 +124,12 @@ const journey: TranslationStrings = {
|
||||
'journey.frontpage.activeJourney': 'Active Journey', // en-fallback
|
||||
'journey.frontpage.latestJourney': 'أحدث رحلة',
|
||||
'journey.frontpage.allJourneys': 'All Journeys', // en-fallback
|
||||
'journey.frontpage.journeys': 'journeys', // en-fallback
|
||||
'journey.frontpage.journeys': 'رحلة',
|
||||
'journey.frontpage.journeys.zero': 'رحلات',
|
||||
'journey.frontpage.journeys.one': 'رحلة',
|
||||
'journey.frontpage.journeys.two': 'رحلتان',
|
||||
'journey.frontpage.journeys.few': 'رحلات',
|
||||
'journey.frontpage.journeys.many': 'رحلة',
|
||||
'journey.frontpage.createNew': 'Create a new Journey', // en-fallback
|
||||
'journey.frontpage.createNewSub': 'Pick trips, write stories, share your adventures', // en-fallback
|
||||
'journey.frontpage.live': 'Live', // en-fallback
|
||||
@@ -125,13 +145,23 @@ const journey: TranslationStrings = {
|
||||
'journey.frontpage.tripsSelected': 'trips selected', // en-fallback
|
||||
'journey.frontpage.trips': 'trips', // en-fallback
|
||||
'journey.frontpage.placesImported': 'places will be imported', // en-fallback
|
||||
'journey.frontpage.places': 'places', // en-fallback
|
||||
'journey.frontpage.places': 'مكان',
|
||||
'journey.frontpage.places.zero': 'أماكن',
|
||||
'journey.frontpage.places.one': 'مكان',
|
||||
'journey.frontpage.places.two': 'مكانان',
|
||||
'journey.frontpage.places.few': 'أماكن',
|
||||
'journey.frontpage.places.many': 'مكانًا',
|
||||
'journey.detail.syncedWithTrips': 'Synced with Trips', // en-fallback
|
||||
'journey.detail.addEntry': 'Add Entry', // en-fallback
|
||||
'journey.detail.jumpToTop': 'العودة إلى الأعلى',
|
||||
'journey.detail.jumpToLast': 'الانتقال إلى آخر مدخل',
|
||||
'journey.detail.dayJump': 'الانتقال إلى يوم',
|
||||
'journey.detail.dayJumpCount': 'الأيام: {count}',
|
||||
'journey.detail.dayJumpCount': '{count} يوم',
|
||||
'journey.detail.dayJumpCount.zero': 'لا توجد أيام',
|
||||
'journey.detail.dayJumpCount.one': 'يوم واحد',
|
||||
'journey.detail.dayJumpCount.two': 'يومان',
|
||||
'journey.detail.dayJumpCount.few': '{count} أيام',
|
||||
'journey.detail.dayJumpCount.many': '{count} يومًا',
|
||||
'journey.detail.newEntry': 'New Entry', // en-fallback
|
||||
'journey.detail.editEntry': 'Edit Entry', // en-fallback
|
||||
'journey.detail.noEntries': 'No entries yet', // en-fallback
|
||||
@@ -152,7 +182,12 @@ const journey: TranslationStrings = {
|
||||
'journey.stats.places': 'Places', // en-fallback
|
||||
'journey.verdict.lovedIt': 'Loved it', // en-fallback
|
||||
'journey.verdict.couldBeBetter': 'Could be better', // en-fallback
|
||||
'journey.synced.places': 'places', // en-fallback
|
||||
'journey.synced.places': 'مكان',
|
||||
'journey.synced.places.zero': 'أماكن',
|
||||
'journey.synced.places.one': 'مكان',
|
||||
'journey.synced.places.two': 'مكانان',
|
||||
'journey.synced.places.few': 'أماكن',
|
||||
'journey.synced.places.many': 'مكانًا',
|
||||
'journey.synced.synced': 'synced', // en-fallback
|
||||
'journey.editor.allPhotosAdded': 'All photos already added', // en-fallback
|
||||
'journey.editor.writeStory': 'Write your story...', // en-fallback
|
||||
@@ -286,11 +321,21 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.exportFinishing': 'التشطيب',
|
||||
'journey.studio.exportMarks': 'علامات القص',
|
||||
'journey.studio.exportMarksHint': 'يضيف {bleed} مم من الفيض عند كل حافة ويحدد موضع القص',
|
||||
'journey.studio.exportNote': '{sheets} ورقة بقياس {width} × {height} مم. يحوّل المتصفح معاينة الطباعة إلى PDF.',
|
||||
'journey.studio.exportNote': '{count} ورقة بقياس {width} × {height} مم. يحوّل المتصفح معاينة الطباعة إلى PDF.',
|
||||
'journey.studio.exportNote.zero': 'لا توجد أوراق بقياس {width} × {height} مم. يحوّل المتصفح معاينة الطباعة إلى PDF.',
|
||||
'journey.studio.exportNote.one': 'ورقة واحدة بقياس {width} × {height} مم. يحوّل المتصفح معاينة الطباعة إلى PDF.',
|
||||
'journey.studio.exportNote.two': 'ورقتان بقياس {width} × {height} مم. يحوّل المتصفح معاينة الطباعة إلى PDF.',
|
||||
'journey.studio.exportNote.few': '{count} أوراق بقياس {width} × {height} مم. يحوّل المتصفح معاينة الطباعة إلى PDF.',
|
||||
'journey.studio.exportNote.many': '{count} ورقة بقياس {width} × {height} مم. يحوّل المتصفح معاينة الطباعة إلى PDF.',
|
||||
'journey.studio.exportOpen': 'معاينة الطباعة',
|
||||
'journey.studio.exportSave': 'حفظ بصيغة PDF',
|
||||
'journey.studio.exportPreparing': 'جارٍ التحضير',
|
||||
'journey.studio.exportSheetCount': '{count} ورقة',
|
||||
'journey.studio.exportSheetCount.zero': 'لا توجد أوراق',
|
||||
'journey.studio.exportSheetCount.one': 'ورقة واحدة',
|
||||
'journey.studio.exportSheetCount.two': 'ورقتان',
|
||||
'journey.studio.exportSheetCount.few': '{count} أوراق',
|
||||
'journey.studio.exportSheetCount.many': '{count} ورقة',
|
||||
'journey.studio.undo': 'Undo', // en-fallback
|
||||
'journey.studio.redo': 'Redo', // en-fallback
|
||||
'journey.studio.zoomIn': 'Zoom in', // en-fallback
|
||||
@@ -634,7 +679,12 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.uploadToGallery': 'ستُضاف الصور الجديدة إلى المعرض',
|
||||
'journey.studio.uploading': 'جارٍ رفع {done} من {total}',
|
||||
'journey.studio.dropFilesHere': 'أفلت لإضافة الصور',
|
||||
'journey.studio.videosSkipped': 'تم تجاهل {count} من مقاطع الفيديو. الكتاب يحتوي على صور فقط.',
|
||||
'journey.studio.videosSkipped': 'تم تجاهل {count} مقطع فيديو. الكتاب يحتوي على صور فقط.',
|
||||
'journey.studio.videosSkipped.zero': 'لم يتم تجاهل أي مقطع فيديو. الكتاب يحتوي على صور فقط.',
|
||||
'journey.studio.videosSkipped.one': 'تم تجاهل مقطع فيديو واحد. الكتاب يحتوي على صور فقط.',
|
||||
'journey.studio.videosSkipped.two': 'تم تجاهل مقطعَي فيديو. الكتاب يحتوي على صور فقط.',
|
||||
'journey.studio.videosSkipped.few': 'تم تجاهل {count} مقاطع فيديو. الكتاب يحتوي على صور فقط.',
|
||||
'journey.studio.videosSkipped.many': 'تم تجاهل {count} مقطع فيديو. الكتاب يحتوي على صور فقط.',
|
||||
'journey.studio.fillPage': 'ملء الصفحة',
|
||||
'journey.studio.fillSpread': 'ملء الصفحتين المتقابلتين',
|
||||
'journey.studio.fillHint': 'يمدّ الصورة على الصفحة كاملة بما في ذلك الفيض، ويضعها خلف كل شيء آخر.',
|
||||
@@ -657,7 +707,12 @@ const journey: TranslationStrings = {
|
||||
'journey.suggestions.dismissed': 'تم تجاهل الاقتراح',
|
||||
'journey.suggestions.restore': 'استعادة الاقتراحات المتجاهَلة',
|
||||
'journey.suggestions.restoreCount': 'الاقتراحات المتجاهَلة ({count})',
|
||||
'journey.suggestions.restored': 'عادت {count} اقتراحات',
|
||||
'journey.suggestions.restored': 'تمت استعادة {count} اقتراح',
|
||||
'journey.suggestions.restored.zero': 'لم تتم استعادة أي اقتراح',
|
||||
'journey.suggestions.restored.one': 'تمت استعادة اقتراح واحد',
|
||||
'journey.suggestions.restored.two': 'تمت استعادة اقتراحين',
|
||||
'journey.suggestions.restored.few': 'تمت استعادة {count} اقتراحات',
|
||||
'journey.suggestions.restored.many': 'تمت استعادة {count} اقتراحًا',
|
||||
'journey.detail.addOnThisDay': 'إضافة مدخل في هذا اليوم',
|
||||
'journey.detail.jumpToDay': 'الانتقال إلى {date}',
|
||||
'journey.detail.searchPlaceholder': 'ابحث في هذه الرحلة',
|
||||
|
||||
@@ -30,6 +30,16 @@ const map: TranslationStrings = {
|
||||
'map.overview.total': 'المسافة الإجمالية',
|
||||
'map.attribution': 'مصادر الخريطة',
|
||||
'map.overview.unrouted': 'تعذّر حساب {count} مرحلة، لذا المسافات غير مكتملة.',
|
||||
'map.overview.unrouted.zero': 'لا توجد مراحل تعذّر حسابها.',
|
||||
'map.overview.unrouted.one': 'تعذّر حساب مرحلة واحدة، لذا المسافات غير مكتملة.',
|
||||
'map.overview.unrouted.two': 'تعذّر حساب مرحلتين، لذا المسافات غير مكتملة.',
|
||||
'map.overview.unrouted.few': 'تعذّر حساب {count} مراحل، لذا المسافات غير مكتملة.',
|
||||
'map.overview.unrouted.many': 'تعذّر حساب {count} مرحلة، لذا المسافات غير مكتملة.',
|
||||
'map.overview.dayUnrouted': 'تعذّر حساب {count} مرحلة من هذا اليوم',
|
||||
'map.overview.dayUnrouted.zero': 'لا توجد مراحل تعذّر حسابها في هذا اليوم',
|
||||
'map.overview.dayUnrouted.one': 'تعذّر حساب مرحلة واحدة من هذا اليوم',
|
||||
'map.overview.dayUnrouted.two': 'تعذّر حساب مرحلتين من هذا اليوم',
|
||||
'map.overview.dayUnrouted.few': 'تعذّر حساب {count} مراحل من هذا اليوم',
|
||||
'map.overview.dayUnrouted.many': 'تعذّر حساب {count} مرحلة من هذا اليوم',
|
||||
};
|
||||
export default map;
|
||||
|
||||
@@ -7,8 +7,12 @@ const members: TranslationStrings = {
|
||||
'members.invite': 'دعوة',
|
||||
'members.allHaveAccess': 'جميع المستخدمين لديهم صلاحية الوصول بالفعل.',
|
||||
'members.access': 'الصلاحية',
|
||||
'members.persons': 'أشخاص',
|
||||
'members.persons': 'شخص',
|
||||
'members.persons.zero': 'أشخاص',
|
||||
'members.persons.one': 'شخص',
|
||||
'members.persons.two': 'شخصان',
|
||||
'members.persons.few': 'أشخاص',
|
||||
'members.persons.many': 'شخصًا',
|
||||
'members.you': 'أنت',
|
||||
'members.owner': 'المالك',
|
||||
'members.leaveTrip': 'مغادرة الرحلة',
|
||||
|
||||
@@ -15,7 +15,12 @@ const memories: TranslationStrings = {
|
||||
'memories.sharing': 'مشترك',
|
||||
'memories.reviewTitle': 'مراجعة صورك',
|
||||
'memories.reviewHint': 'انقر على الصور لاستبعادها من المشاركة.',
|
||||
'memories.shareCount': 'مشاركة {count} صور',
|
||||
'memories.shareCount': 'مشاركة {count} صورة',
|
||||
'memories.shareCount.zero': 'لا توجد صور للمشاركة',
|
||||
'memories.shareCount.one': 'مشاركة صورة واحدة',
|
||||
'memories.shareCount.two': 'مشاركة صورتين',
|
||||
'memories.shareCount.few': 'مشاركة {count} صور',
|
||||
'memories.shareCount.many': 'مشاركة {count} صورة',
|
||||
'memories.providerUrl': 'عنوان URL للخادم',
|
||||
'memories.providerApiKey': 'مفتاح API',
|
||||
'memories.providerUsername': 'اسم المستخدم',
|
||||
@@ -46,7 +51,12 @@ const memories: TranslationStrings = {
|
||||
'memories.selectPhotosMultiple': 'اختيار الصور',
|
||||
'memories.selectHint': 'انقر على الصور لتحديدها.',
|
||||
'memories.selected': 'محدد',
|
||||
'memories.addSelected': 'إضافة {count} صور',
|
||||
'memories.addSelected': 'إضافة {count} صورة',
|
||||
'memories.addSelected.zero': 'لا توجد صور للإضافة',
|
||||
'memories.addSelected.one': 'إضافة صورة واحدة',
|
||||
'memories.addSelected.two': 'إضافة صورتين',
|
||||
'memories.addSelected.few': 'إضافة {count} صور',
|
||||
'memories.addSelected.many': 'إضافة {count} صورة',
|
||||
'memories.alreadyAdded': 'تمت الإضافة',
|
||||
'memories.private': 'خاص',
|
||||
'memories.stopSharing': 'إيقاف المشاركة',
|
||||
|
||||
@@ -3,10 +3,30 @@ import type { TranslationStrings } from '../types';
|
||||
const mobileJourney: TranslationStrings = {
|
||||
'mobileJourney.latestJourney': 'أحدث رحلة',
|
||||
'mobileJourney.otherJourneys': 'رحلات أخرى',
|
||||
'mobileJourney.entriesCount': '{count} مدخلات',
|
||||
'mobileJourney.photosCount': '{count} صور',
|
||||
'mobileJourney.placesCount': '{count} أماكن',
|
||||
'mobileJourney.daysCount': '{count} أيام',
|
||||
'mobileJourney.entriesCount': '{count} مدخل',
|
||||
'mobileJourney.entriesCount.zero': 'لا توجد مدخلات',
|
||||
'mobileJourney.entriesCount.one': 'مدخل واحد',
|
||||
'mobileJourney.entriesCount.two': 'مدخلان',
|
||||
'mobileJourney.entriesCount.few': '{count} مدخلات',
|
||||
'mobileJourney.entriesCount.many': '{count} مدخلًا',
|
||||
'mobileJourney.photosCount': '{count} صورة',
|
||||
'mobileJourney.photosCount.zero': 'لا توجد صور',
|
||||
'mobileJourney.photosCount.one': 'صورة واحدة',
|
||||
'mobileJourney.photosCount.two': 'صورتان',
|
||||
'mobileJourney.photosCount.few': '{count} صور',
|
||||
'mobileJourney.photosCount.many': '{count} صورة',
|
||||
'mobileJourney.placesCount': '{count} مكان',
|
||||
'mobileJourney.placesCount.zero': 'لا توجد أماكن',
|
||||
'mobileJourney.placesCount.one': 'مكان واحد',
|
||||
'mobileJourney.placesCount.two': 'مكانان',
|
||||
'mobileJourney.placesCount.few': '{count} أماكن',
|
||||
'mobileJourney.placesCount.many': '{count} مكانًا',
|
||||
'mobileJourney.daysCount': '{count} يوم',
|
||||
'mobileJourney.daysCount.zero': 'لا توجد أيام',
|
||||
'mobileJourney.daysCount.one': 'يوم واحد',
|
||||
'mobileJourney.daysCount.two': 'يومان',
|
||||
'mobileJourney.daysCount.few': '{count} أيام',
|
||||
'mobileJourney.daysCount.many': '{count} يومًا',
|
||||
'mobileJourney.time': 'الوقت',
|
||||
'mobileJourney.tags': 'الوسوم',
|
||||
'mobileJourney.addTag': 'إضافة وسم',
|
||||
|
||||
@@ -41,11 +41,26 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.renameDay': 'إعادة تسمية اليوم',
|
||||
'mobileTrip.resBadge': 'حجز',
|
||||
'mobileTrip.showOnMap': 'إظهار على الخريطة',
|
||||
'mobileTrip.statDocuments': '{count} ملفات',
|
||||
'mobileTrip.statPeople': '{count} أشخاص',
|
||||
'mobileTrip.statDocuments': '{count} ملف',
|
||||
'mobileTrip.statDocuments.zero': 'لا توجد ملفات',
|
||||
'mobileTrip.statDocuments.one': 'ملف واحد',
|
||||
'mobileTrip.statDocuments.two': 'ملفان',
|
||||
'mobileTrip.statDocuments.few': '{count} ملفات',
|
||||
'mobileTrip.statDocuments.many': '{count} ملفًا',
|
||||
'mobileTrip.statPeople': '{count} شخص',
|
||||
'mobileTrip.statPeople.zero': 'لا يوجد أشخاص',
|
||||
'mobileTrip.statPeople.one': 'شخص واحد',
|
||||
'mobileTrip.statPeople.two': 'شخصان',
|
||||
'mobileTrip.statPeople.few': '{count} أشخاص',
|
||||
'mobileTrip.statPeople.many': '{count} شخصًا',
|
||||
'mobileTrip.stay': 'إقامة',
|
||||
'mobileTrip.tapAgainToDelete': 'اضغط مرة أخرى للحذف',
|
||||
'mobileTrip.todoOpenCount': '{count} مفتوحة',
|
||||
'mobileTrip.todoOpenCount.zero': 'لا شيء مفتوح',
|
||||
'mobileTrip.todoOpenCount.one': 'واحدة مفتوحة',
|
||||
'mobileTrip.todoOpenCount.two': 'اثنتان مفتوحتان',
|
||||
'mobileTrip.todoOpenCount.few': '{count} مفتوحة',
|
||||
'mobileTrip.todoOpenCount.many': '{count} مفتوحة',
|
||||
'mobileTrip.travel': 'السفر',
|
||||
'mobileTrip.upNext': 'التالي',
|
||||
'mobileTrip.viewDetails': 'عرض التفاصيل',
|
||||
@@ -58,8 +73,18 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.rtBehind': '{time} تأخرًا عن الخطة',
|
||||
'mobileTrip.rtStart': 'البداية',
|
||||
'mobileTrip.rtStayScope': 'مدة التوقف تخص المكان نفسه، لذا تُحتسب في كل يوم تكون فيه هذه المحطة ضمن الخطة.',
|
||||
'mobileTrip.rtStayLess': '{count} دقيقة أقل',
|
||||
'mobileTrip.rtStayMore': '{count} دقيقة أكثر',
|
||||
'mobileTrip.rtStayLess': 'أقل بـ{count} دقيقة',
|
||||
'mobileTrip.rtStayLess.zero': 'أقل بـ{count} دقيقة',
|
||||
'mobileTrip.rtStayLess.one': 'أقل بدقيقة واحدة',
|
||||
'mobileTrip.rtStayLess.two': 'أقل بدقيقتين',
|
||||
'mobileTrip.rtStayLess.few': 'أقل بـ{count} دقائق',
|
||||
'mobileTrip.rtStayLess.many': 'أقل بـ{count} دقيقة',
|
||||
'mobileTrip.rtStayMore': 'أكثر بـ{count} دقيقة',
|
||||
'mobileTrip.rtStayMore.zero': 'أكثر بـ{count} دقيقة',
|
||||
'mobileTrip.rtStayMore.one': 'أكثر بدقيقة واحدة',
|
||||
'mobileTrip.rtStayMore.two': 'أكثر بدقيقتين',
|
||||
'mobileTrip.rtStayMore.few': 'أكثر بـ{count} دقائق',
|
||||
'mobileTrip.rtStayMore.many': 'أكثر بـ{count} دقيقة',
|
||||
'mobileTrip.rtNightDesktopOnly': 'إضافة مبيت في هذا المكان تتم في مخطط سطح المكتب. هنا يمكنك إزالته فقط.',
|
||||
'mobileTrip.rtReach': 'إلى أي مدى',
|
||||
'mobileTrip.rtReachAhead': '{distance} أمامك',
|
||||
@@ -67,8 +92,16 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.rtFromStart': 'من بداية المرحلة',
|
||||
'mobileTrip.rtNoneAhead': 'لا شيء على الطريق أمامك. جرّب المرحلة كاملة.',
|
||||
'mobileTrip.rtNoneOnStage': 'لا شيء من هذا النوع على طول هذه المرحلة.',
|
||||
'mobileTrip.rtTruncated.one': 'مقطع واحد فيه أكثر مما يتسع في إجابة واحدة. اختر أنواعًا أقل لرؤية الباقي.',
|
||||
'mobileTrip.rtTruncated.other': 'مقاطع فيها أكثر مما يتسع في إجابة واحدة: {count}. اختر أنواعًا أقل لرؤية الباقي.',
|
||||
'mobileTrip.rtTruncated.other':
|
||||
'في {count} مقطع كانت النتائج أكثر مما تتسع له إجابة واحدة. اختر أنواعًا أقل لرؤية الباقي.',
|
||||
'mobileTrip.rtTruncated.zero': 'لم تتجاوز النتائج في أي مقطع ما تتسع له إجابة واحدة.',
|
||||
'mobileTrip.rtTruncated.one':
|
||||
'في مقطع واحد كانت النتائج أكثر مما تتسع له إجابة واحدة. اختر أنواعًا أقل لرؤية الباقي.',
|
||||
'mobileTrip.rtTruncated.two': 'في مقطعين كانت النتائج أكثر مما تتسع له إجابة واحدة. اختر أنواعًا أقل لرؤية الباقي.',
|
||||
'mobileTrip.rtTruncated.few':
|
||||
'في {count} مقاطع كانت النتائج أكثر مما تتسع له إجابة واحدة. اختر أنواعًا أقل لرؤية الباقي.',
|
||||
'mobileTrip.rtTruncated.many':
|
||||
'في {count} مقطعًا كانت النتائج أكثر مما تتسع له إجابة واحدة. اختر أنواعًا أقل لرؤية الباقي.',
|
||||
'mobileTrip.rtNoDay': 'لم يتم اختيار يوم',
|
||||
'mobileTrip.rtNoDayHint': 'تعرض الخريطة الرحلة كاملة. اضغط على يوم في الأعلى لعرض مسيره.',
|
||||
};
|
||||
|
||||
@@ -5,8 +5,18 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.nextYear': 'السنة التالية',
|
||||
'mobileVacay.prevMonth': 'الشهر السابق',
|
||||
'mobileVacay.nextMonth': 'الشهر التالي',
|
||||
'mobileVacay.usedCount': '{count} مستخدَمة',
|
||||
'mobileVacay.leftCount': '{count} متبقية',
|
||||
'mobileVacay.usedCount': '{count} يوم مستخدَم',
|
||||
'mobileVacay.usedCount.zero': 'لم يُستخدَم أي يوم',
|
||||
'mobileVacay.usedCount.one': 'يوم واحد مستخدَم',
|
||||
'mobileVacay.usedCount.two': 'يومان مستخدَمان',
|
||||
'mobileVacay.usedCount.few': '{count} أيام مستخدَمة',
|
||||
'mobileVacay.usedCount.many': '{count} يومًا مستخدَمًا',
|
||||
'mobileVacay.leftCount': '{count} يوم متبقٍّ',
|
||||
'mobileVacay.leftCount.zero': 'لم يتبقَّ أي يوم',
|
||||
'mobileVacay.leftCount.one': 'يوم واحد متبقٍّ',
|
||||
'mobileVacay.leftCount.two': 'يومان متبقيان',
|
||||
'mobileVacay.leftCount.few': '{count} أيام متبقية',
|
||||
'mobileVacay.leftCount.many': '{count} يومًا متبقيًا',
|
||||
'mobileVacay.left': 'متبقية',
|
||||
'mobileVacay.modeCompany': 'الشركة',
|
||||
'mobileVacay.decreaseAllowance': 'تقليل الرصيد',
|
||||
|
||||
@@ -21,6 +21,11 @@ const notif: TranslationStrings = {
|
||||
'notif.collection_invite.text': '{actor} يدعوك لمشاركة مجموعة',
|
||||
'notif.photos_shared.title': 'تمت مشاركة الصور',
|
||||
'notif.photos_shared.text': '{actor} شارك {count} صورة في {trip}',
|
||||
'notif.photos_shared.text.zero': '{actor} لم يشارك أي صورة في {trip}',
|
||||
'notif.photos_shared.text.one': '{actor} شارك صورة واحدة في {trip}',
|
||||
'notif.photos_shared.text.two': '{actor} شارك صورتين في {trip}',
|
||||
'notif.photos_shared.text.few': '{actor} شارك {count} صور في {trip}',
|
||||
'notif.photos_shared.text.many': '{actor} شارك {count} صورة في {trip}',
|
||||
'notif.collab_message.title': 'رسالة جديدة',
|
||||
'notif.collab_message.text': '{actor} أرسل رسالة في {trip}',
|
||||
'notif.packing_tagged.title': 'مهمة التعبئة',
|
||||
|
||||
@@ -31,5 +31,11 @@ const notifications: TranslationStrings = {
|
||||
'notifications.test.adminText': 'أرسل {actor} إشعاراً تجريبياً لجميع المسؤولين.',
|
||||
'notifications.test.tripTitle': 'نشر {actor} في رحلتك',
|
||||
'notifications.test.tripText': 'إشعار تجريبي للرحلة "{trip}".',
|
||||
'notifications.countLabel': 'إشعار',
|
||||
'notifications.countLabel.zero': 'إشعارات',
|
||||
'notifications.countLabel.one': 'إشعار',
|
||||
'notifications.countLabel.two': 'إشعاران',
|
||||
'notifications.countLabel.few': 'إشعارات',
|
||||
'notifications.countLabel.many': 'إشعارًا',
|
||||
};
|
||||
export default notifications;
|
||||
|
||||
@@ -84,8 +84,12 @@ const oauth: TranslationStrings = {
|
||||
'oauth.authorize.requestDescription': 'This application is requesting access to your TREK account.', // en-fallback
|
||||
'oauth.authorize.trustNote': 'Only grant access to applications you trust. Your data stays on your server.', // en-fallback
|
||||
'oauth.authorize.selectScope': 'Select at least one scope', // en-fallback
|
||||
'oauth.authorize.approveScopes': 'Approve ({count} scopes)', // en-fallback
|
||||
'oauth.authorize.approveScopes.one': 'Approve ({count} scope)', // en-fallback
|
||||
'oauth.authorize.approveScopes': 'موافقة ({count} نطاق)',
|
||||
'oauth.authorize.approveScopes.zero': 'موافقة (بلا نطاقات)',
|
||||
'oauth.authorize.approveScopes.one': 'موافقة (نطاق واحد)',
|
||||
'oauth.authorize.approveScopes.two': 'موافقة (نطاقان)',
|
||||
'oauth.authorize.approveScopes.few': 'موافقة ({count} نطاقات)',
|
||||
'oauth.authorize.approveScopes.many': 'موافقة ({count} نطاقًا)',
|
||||
'oauth.authorize.approveAccess': 'Approve Access', // en-fallback
|
||||
'oauth.authorize.deny': 'Deny', // en-fallback
|
||||
'oauth.authorize.choosePermissions': 'Choose which permissions to grant', // en-fallback
|
||||
|
||||
@@ -32,6 +32,11 @@ const packing: TranslationStrings = {
|
||||
'يمكن أيضًا استخدام قائمة Markdown: العنوان يحدد الفئة، و"- [ ]" و"- [x]" تصبح عناصر، و"3x" قبل الاسم تحدد الكمية.',
|
||||
'packing.importAction': 'استيراد {count}',
|
||||
'packing.importSuccess': 'تم استيراد {count} عنصر',
|
||||
'packing.importSuccess.zero': 'لم يتم استيراد أي عنصر',
|
||||
'packing.importSuccess.one': 'تم استيراد عنصر واحد',
|
||||
'packing.importSuccess.two': 'تم استيراد عنصرين',
|
||||
'packing.importSuccess.few': 'تم استيراد {count} عناصر',
|
||||
'packing.importSuccess.many': 'تم استيراد {count} عنصرًا',
|
||||
'packing.importError': 'فشل الاستيراد',
|
||||
'packing.importEmpty': 'لا توجد عناصر للاستيراد',
|
||||
'packing.progress': '{packed} من {total} جُهّز ({percent}%)',
|
||||
@@ -59,6 +64,11 @@ const packing: TranslationStrings = {
|
||||
'packing.applyTemplate': 'تطبيق قالب',
|
||||
'packing.template': 'قالب',
|
||||
'packing.templateApplied': 'تمت إضافة {count} عنصر من القالب',
|
||||
'packing.templateApplied.zero': 'لم تتم إضافة أي عنصر من القالب',
|
||||
'packing.templateApplied.one': 'تمت إضافة عنصر واحد من القالب',
|
||||
'packing.templateApplied.two': 'تمت إضافة عنصرين من القالب',
|
||||
'packing.templateApplied.few': 'تمت إضافة {count} عناصر من القالب',
|
||||
'packing.templateApplied.many': 'تمت إضافة {count} عنصرًا من القالب',
|
||||
'packing.templateError': 'فشل تطبيق القالب',
|
||||
'packing.saveAsTemplate': 'حفظ كقالب',
|
||||
'packing.templateName': 'اسم القالب',
|
||||
@@ -74,7 +84,17 @@ const packing: TranslationStrings = {
|
||||
'packing.addBag': 'إضافة أمتعة',
|
||||
'packing.changeCategory': 'نقل إلى قائمة',
|
||||
'packing.confirm.clearChecked': 'هل تريد إزالة {count} عنصر محدد؟',
|
||||
'packing.confirm.deleteCat': 'هل أنت متأكد أنك تريد حذف القائمة "{name}" التي تحتوي على {count} عنصرًا؟',
|
||||
'packing.confirm.clearChecked.zero': 'لا توجد عناصر محددة لإزالتها.',
|
||||
'packing.confirm.clearChecked.one': 'هل تريد إزالة العنصر المحدد؟',
|
||||
'packing.confirm.clearChecked.two': 'هل تريد إزالة العنصرين المحددين؟',
|
||||
'packing.confirm.clearChecked.few': 'هل تريد إزالة {count} عناصر محددة؟',
|
||||
'packing.confirm.clearChecked.many': 'هل تريد إزالة {count} عنصرًا محددًا؟',
|
||||
'packing.confirm.deleteCat': 'هل أنت متأكد أنك تريد حذف القائمة "{name}" التي تحتوي على {count} عنصر؟',
|
||||
'packing.confirm.deleteCat.zero': 'هل أنت متأكد أنك تريد حذف القائمة الفارغة "{name}"؟',
|
||||
'packing.confirm.deleteCat.one': 'هل أنت متأكد أنك تريد حذف القائمة "{name}" التي تحتوي على عنصر واحد؟',
|
||||
'packing.confirm.deleteCat.two': 'هل أنت متأكد أنك تريد حذف القائمة "{name}" التي تحتوي على عنصرين؟',
|
||||
'packing.confirm.deleteCat.few': 'هل أنت متأكد أنك تريد حذف القائمة "{name}" التي تحتوي على {count} عناصر؟',
|
||||
'packing.confirm.deleteCat.many': 'هل أنت متأكد أنك تريد حذف القائمة "{name}" التي تحتوي على {count} عنصرًا؟',
|
||||
'packing.defaultCategory': 'أخرى',
|
||||
'packing.toast.saveError': 'فشل الحفظ',
|
||||
'packing.toast.deleteError': 'فشل الحذف',
|
||||
|
||||
@@ -3,6 +3,11 @@ import type { TranslationStrings } from '../types';
|
||||
const photos: TranslationStrings = {
|
||||
'photos.title': 'صور',
|
||||
'photos.subtitle': '{count} صورة لـ {trip}',
|
||||
'photos.subtitle.zero': 'لا توجد صور لـ {trip}',
|
||||
'photos.subtitle.one': 'صورة واحدة لـ {trip}',
|
||||
'photos.subtitle.two': 'صورتان لـ {trip}',
|
||||
'photos.subtitle.few': '{count} صور لـ {trip}',
|
||||
'photos.subtitle.many': '{count} صورة لـ {trip}',
|
||||
'photos.dropHere': 'أسقط الصور هنا...',
|
||||
'photos.dropHereActive': 'أسقط الصور هنا',
|
||||
'photos.captionForAll': 'تعليق (للجميع)',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user