mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 23:01:48 +00:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dbfceddb1a | |||
| 697f9d723d | |||
| fe1ae5c4bf | |||
| 72d9f62f39 | |||
| 7cfff1f6a3 | |||
| 7a8a3ee4f2 | |||
| 927ddd6421 | |||
| c0b5d941dd | |||
| a074debd61 | |||
| 5f44cd1403 | |||
| 459b092e28 | |||
| 4a5cf3d83d | |||
| a8cb2e672a | |||
| ef7a3d32d8 | |||
| e05e16de3f | |||
| 1656ddcae2 | |||
| 22ad5d73f2 | |||
| 5367d24f9f | |||
| 17822aa9eb | |||
| 87e8a44764 | |||
| f8c77bff8e | |||
| 0b995cfd55 | |||
| 7a4c9998af | |||
| 26ade89bc8 | |||
| b150b576aa | |||
| a162289829 |
@@ -32,6 +32,7 @@ server/tests/
|
|||||||
server/vitest.config.ts
|
server/vitest.config.ts
|
||||||
server/reset-admin.js
|
server/reset-admin.js
|
||||||
**/*.test.ts
|
**/*.test.ts
|
||||||
|
**/*.spec.ts
|
||||||
wiki/
|
wiki/
|
||||||
scripts/
|
scripts/
|
||||||
charts/
|
charts/
|
||||||
|
|||||||
@@ -126,12 +126,14 @@ jobs:
|
|||||||
run: cd client && npm run lint:pages
|
run: cd client && npm run lint:pages
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: cd client && npm run test:coverage
|
# Two separate OOM sources, both avoided here:
|
||||||
|
# 1) The v8 coverage report phase (source-map remapping over 150+ files)
|
||||||
- name: Upload coverage
|
# OOMs even with a 12 GB heap, so coverage is NOT collected in CI.
|
||||||
if: success()
|
# 2) Each forks worker runs ~38 files and jsdom/MSW state accumulates
|
||||||
uses: actions/upload-artifact@v6
|
# past Node's default ~4 GB, so workers get extra heap.
|
||||||
with:
|
# Run coverage locally with `npm run test:coverage`.
|
||||||
name: frontend-coverage
|
# TODO(#1258): re-enable coverage in CI via test sharding or the istanbul
|
||||||
path: client/coverage/
|
# provider, then restore the artifact upload.
|
||||||
retention-days: 7
|
env:
|
||||||
|
NODE_OPTIONS: --max-old-space-size=8192
|
||||||
|
run: cd client && npm run test
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<CommunityApplications>
|
||||||
|
<Profile>TREK is a self-hosted, real-time collaborative travel planner. Plan trips together with interactive maps, budgets, bookings, packing lists, day-by-day itineraries and file management — every change syncs instantly across everyone in your group. Includes OIDC/SSO, TOTP MFA, dark mode, PWA support, multi-language UI and a modular addon system (Vacay, Atlas, Collab, Budget, Packing, Journey). Maintained by mauriceboe — support and bug reports via GitHub Issues.</Profile>
|
||||||
|
<Icon>https://raw.githubusercontent.com/mauriceboe/TREK/main/docs/trek-icon.png</Icon>
|
||||||
|
<WebPage>https://github.com/mauriceboe/TREK</WebPage>
|
||||||
|
<Forum>https://github.com/mauriceboe/TREK/issues</Forum>
|
||||||
|
<DonateLink>https://ko-fi.com/mauriceboe</DonateLink>
|
||||||
|
<DonateText>Support TREK development</DonateText>
|
||||||
|
</CommunityApplications>
|
||||||
+1
-1
@@ -39,7 +39,7 @@ See `values.yaml` for more options.
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
- Ingress is off by default. Enable and configure hosts for your domain.
|
- Ingress is off by default. Enable and configure hosts for your domain.
|
||||||
- PVCs require a default StorageClass or specify one as needed.
|
- PVCs use the cluster's default StorageClass. Set `persistence.data.storageClassName` and/or `persistence.uploads.storageClassName` to bind a specific class.
|
||||||
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
|
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
|
||||||
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
|
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
|
||||||
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
|
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
apiVersion: v2
|
apiVersion: v2
|
||||||
name: trek
|
name: trek
|
||||||
version: 3.1.1
|
version: 3.1.2
|
||||||
description: Minimal Helm chart for TREK app
|
description: Minimal Helm chart for TREK app
|
||||||
appVersion: "3.1.1"
|
appVersion: "3.1.2"
|
||||||
|
|||||||
@@ -5,9 +5,16 @@ metadata:
|
|||||||
name: {{ include "trek.fullname" . }}-data
|
name: {{ include "trek.fullname" . }}-data
|
||||||
labels:
|
labels:
|
||||||
app: {{ include "trek.name" . }}
|
app: {{ include "trek.name" . }}
|
||||||
|
{{- with .Values.persistence.data.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
spec:
|
spec:
|
||||||
accessModes:
|
accessModes:
|
||||||
- ReadWriteOnce
|
- ReadWriteOnce
|
||||||
|
{{- with .Values.persistence.data.storageClassName }}
|
||||||
|
storageClassName: {{ . | quote }}
|
||||||
|
{{- end }}
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
storage: {{ .Values.persistence.data.size }}
|
storage: {{ .Values.persistence.data.size }}
|
||||||
@@ -18,9 +25,16 @@ metadata:
|
|||||||
name: {{ include "trek.fullname" . }}-uploads
|
name: {{ include "trek.fullname" . }}-uploads
|
||||||
labels:
|
labels:
|
||||||
app: {{ include "trek.name" . }}
|
app: {{ include "trek.name" . }}
|
||||||
|
{{- with .Values.persistence.uploads.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
spec:
|
spec:
|
||||||
accessModes:
|
accessModes:
|
||||||
- ReadWriteOnce
|
- ReadWriteOnce
|
||||||
|
{{- with .Values.persistence.uploads.storageClassName }}
|
||||||
|
storageClassName: {{ . | quote }}
|
||||||
|
{{- end }}
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
storage: {{ .Values.persistence.uploads.size }}
|
storage: {{ .Values.persistence.uploads.size }}
|
||||||
|
|||||||
@@ -98,8 +98,13 @@ persistence:
|
|||||||
enabled: true
|
enabled: true
|
||||||
data:
|
data:
|
||||||
size: 1Gi
|
size: 1Gi
|
||||||
|
# Leave empty to use the cluster's default StorageClass; set to bind a specific class.
|
||||||
|
storageClassName: ""
|
||||||
|
annotations: {}
|
||||||
uploads:
|
uploads:
|
||||||
size: 1Gi
|
size: 1Gi
|
||||||
|
storageClassName: ""
|
||||||
|
annotations: {}
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
|||||||
|
|
||||||
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
||||||
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
|
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
|
||||||
const nums = () => screen.getAllByRole('spinbutton') as HTMLInputElement[]
|
const nums = () => screen.getAllByPlaceholderText('0.00') as HTMLInputElement[]
|
||||||
await user.type(nums()[0], '100') // total → auto equal-split across the 2 participants
|
await user.type(nums()[0], '100') // total → auto equal-split across the 2 participants
|
||||||
await waitFor(() => expect(nums()[1].value).toBe('50'))
|
await waitFor(() => expect(nums()[1].value).toBe('50'))
|
||||||
expect(nums()[2].value).toBe('50')
|
expect(nums()[2].value).toBe('50')
|
||||||
@@ -125,6 +125,30 @@ describe('CostsPanel — settlements in the ledger', () => {
|
|||||||
]))
|
]))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('accepts a comma as the decimal separator in the total amount (#1256)', async () => {
|
||||||
|
let posted: Record<string, unknown> | null = null
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||||
|
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||||
|
http.post('/api/trips/1/budget', async ({ request }) => {
|
||||||
|
posted = await request.json() as Record<string, unknown>
|
||||||
|
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'AirTags' }), id: 6 } })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const { default: userEvent } = await import('@testing-library/user-event')
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
||||||
|
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'AirTags')
|
||||||
|
await user.type(screen.getAllByPlaceholderText('0.00')[0], '39,99') // comma → normalized to 39.99
|
||||||
|
|
||||||
|
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
|
||||||
|
await user.click(addBtns[addBtns.length - 1]) // footer submit
|
||||||
|
await waitFor(() => expect(posted).toBeTruthy())
|
||||||
|
expect(posted!.total_price).toBe(39.99)
|
||||||
|
})
|
||||||
|
|
||||||
it('marks an expense with no payer as Unfinished', async () => {
|
it('marks an expense with no payer as Unfinished', async () => {
|
||||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Hotel' }), total_price: 90, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
|
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Hotel' }), total_price: 90, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
|
||||||
server.use(
|
server.use(
|
||||||
@@ -135,4 +159,39 @@ describe('CostsPanel — settlements in the ledger', () => {
|
|||||||
await screen.findByText('Hotel')
|
await screen.findByText('Hotel')
|
||||||
expect(screen.getByText('Unfinished')).toBeInTheDocument()
|
expect(screen.getByText('Unfinished')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('records a recorded-total expense with nobody to split with (#1286)', async () => {
|
||||||
|
let posted: Record<string, unknown> | null = null
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||||
|
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||||
|
http.post('/api/trips/1/budget', async ({ request }) => {
|
||||||
|
posted = await request.json() as Record<string, unknown>
|
||||||
|
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Hotel' }), id: 9 } })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const { default: userEvent } = await import('@testing-library/user-event')
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
||||||
|
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Hotel')
|
||||||
|
await user.type(screen.getAllByPlaceholderText('0.00')[0], '120') // total only, paid on-site later
|
||||||
|
|
||||||
|
// Deselect everyone — the cost is recorded without a split (the bug: this was blocked).
|
||||||
|
// The participant toggles are buttons; the same names also appear as plain text in
|
||||||
|
// the Balances sidebar, so target the buttons specifically.
|
||||||
|
await user.click(screen.getByRole('button', { name: /alice/i }))
|
||||||
|
await user.click(screen.getByRole('button', { name: /bob/i }))
|
||||||
|
|
||||||
|
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
|
||||||
|
const submit = addBtns[addBtns.length - 1] // footer submit
|
||||||
|
expect(submit).not.toBeDisabled()
|
||||||
|
await user.click(submit)
|
||||||
|
|
||||||
|
await waitFor(() => expect(posted).toBeTruthy())
|
||||||
|
expect(posted!.total_price).toBe(120)
|
||||||
|
expect(posted!.member_ids).toEqual([])
|
||||||
|
expect(posted!.payers).toEqual([])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -528,11 +528,16 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
const isUnfinished = baseTotal(e) > 0 && payers.length === 0
|
const isUnfinished = baseTotal(e) > 0 && payers.length === 0
|
||||||
return (
|
return (
|
||||||
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
|
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
|
||||||
<span style={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}><Icon size={21} /></span>
|
<span style={{ position: 'relative', width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}>
|
||||||
|
<Icon size={21} />
|
||||||
|
{isMobile && isUnfinished && (
|
||||||
|
<span title={t('costs.unfinishedHint')} style={{ position: 'absolute', bottom: -4, right: -4, width: 20, height: 20, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 12, fontWeight: 800, lineHeight: 1, border: '2px solid var(--bg-card)' }}>!</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}>
|
||||||
<span className="text-content" style={{ fontSize: 15, fontWeight: 600 }}>{e.name}</span>
|
<span className="text-content" style={{ fontSize: 15, fontWeight: 600 }}>{e.name}</span>
|
||||||
{isUnfinished && (
|
{isUnfinished && !isMobile && (
|
||||||
<span title={t('costs.unfinishedHint')} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px 2px 6px', borderRadius: 999, background: 'rgba(217,119,6,0.14)', color: '#d97706', fontSize: 11, fontWeight: 700, flexShrink: 0 }}>
|
<span title={t('costs.unfinishedHint')} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px 2px 6px', borderRadius: 999, background: 'rgba(217,119,6,0.14)', color: '#d97706', fontSize: 11, fontWeight: 700, flexShrink: 0 }}>
|
||||||
<span style={{ width: 14, height: 14, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 10, fontWeight: 800 }}>!</span>
|
<span style={{ width: 14, height: 14, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 10, fontWeight: 800 }}>!</span>
|
||||||
{t('costs.unfinished')}
|
{t('costs.unfinished')}
|
||||||
@@ -632,14 +637,16 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
|
|
||||||
function CategoryBreakdown() {
|
function CategoryBreakdown() {
|
||||||
const tot: Record<string, number> = {}
|
const tot: Record<string, number> = {}
|
||||||
let grand = 0
|
for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e) }
|
||||||
for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e); grand += baseTotal(e) }
|
|
||||||
const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) > 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0))
|
const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) > 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0))
|
||||||
if (rows.length === 0) return <div className="text-content-faint" style={{ fontSize: 12.5 }}>{t('costs.noCategories')}</div>
|
if (rows.length === 0) return <div className="text-content-faint" style={{ fontSize: 12.5 }}>{t('costs.noCategories')}</div>
|
||||||
|
// Bars are scaled relative to the most expensive category (the top row fills the
|
||||||
|
// bar), not to the trip grand total — makes the relative ranking readable.
|
||||||
|
const maxCat = Math.max(0, ...rows.map(c => tot[c.key] || 0))
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
{rows.map(c => {
|
{rows.map(c => {
|
||||||
const v = tot[c.key]; const pct = grand ? v / grand * 100 : 0
|
const v = tot[c.key]; const pct = maxCat ? v / maxCat * 100 : 0
|
||||||
return (
|
return (
|
||||||
<div key={c.key} style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 10, alignItems: 'center' }}>
|
<div key={c.key} style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 10, alignItems: 'center' }}>
|
||||||
<span style={{ width: 10, height: 10, borderRadius: 3, background: c.color }} />
|
<span style={{ width: 10, height: 10, borderRadius: 3, background: c.color }} />
|
||||||
@@ -754,8 +761,8 @@ function SettlementModal({ tripId, people, me, editing, onClose, onSaved }: {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className={labelCls}>{t('costs.amount')}</label>
|
<label className={labelCls}>{t('costs.amount')}</label>
|
||||||
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={amount}
|
<input type="text" inputMode="decimal" placeholder="0.00" value={amount}
|
||||||
onChange={e => setAmount(e.target.value)} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 14, outline: 'none', fontWeight: 600 }} />
|
onChange={e => setAmount(e.target.value.replace(',', '.'))} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 14, outline: 'none', fontWeight: 600 }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -811,7 +818,10 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
|||||||
const paidEntered = paidSum > 0
|
const paidEntered = paidSum > 0
|
||||||
const balanced = Math.abs(paidSum - totalNum) < 0.01
|
const balanced = Math.abs(paidSum - totalNum) < 0.01
|
||||||
const each = participants.size > 0 ? totalNum / participants.size : 0
|
const each = participants.size > 0 ? totalNum / participants.size : 0
|
||||||
const valid = name.trim().length > 0 && totalNum > 0 && participants.size > 0 && (!paidEntered || balanced)
|
// No participants = a recorded total with nobody to split with (e.g. a booking
|
||||||
|
// paid on-site later). It saves as an "unfinished" expense (#1286); selecting
|
||||||
|
// people only adds the who-owes-whom split on top.
|
||||||
|
const valid = name.trim().length > 0 && totalNum > 0 && (!paidEntered || balanced)
|
||||||
|
|
||||||
// Spread `amount` across `n` people in whole cents so the parts sum back exactly.
|
// Spread `amount` across `n` people in whole cents so the parts sum back exactly.
|
||||||
const splitCents = (amount: number, n: number): number[] => {
|
const splitCents = (amount: number, n: number): number[] => {
|
||||||
@@ -833,10 +843,12 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onTotalChange = (v: string) => {
|
const onTotalChange = (v: string) => {
|
||||||
|
v = v.replace(',', '.')
|
||||||
setTotal(v)
|
setTotal(v)
|
||||||
setPaid(prev => rebalance(prev, dirty, participants, parseFloat(v) || 0))
|
setPaid(prev => rebalance(prev, dirty, participants, parseFloat(v) || 0))
|
||||||
}
|
}
|
||||||
const onPaidChange = (id: number, v: string) => {
|
const onPaidChange = (id: number, v: string) => {
|
||||||
|
v = v.replace(',', '.')
|
||||||
const nextDirty = new Set(dirty); nextDirty.add(id)
|
const nextDirty = new Set(dirty); nextDirty.add(id)
|
||||||
setDirty(nextDirty)
|
setDirty(nextDirty)
|
||||||
setPaid(prev => rebalance({ ...prev, [id]: v }, nextDirty, participants, totalNum))
|
setPaid(prev => rebalance({ ...prev, [id]: v }, nextDirty, participants, totalNum))
|
||||||
@@ -896,7 +908,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
|||||||
<label className={labelCls}>{t('costs.totalAmount')}</label>
|
<label className={labelCls}>{t('costs.totalAmount')}</label>
|
||||||
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px' }}>
|
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px' }}>
|
||||||
<span className="text-content-faint" style={{ fontSize: 15 }}>{sym(currency)}</span>
|
<span className="text-content-faint" style={{ fontSize: 15 }}>{sym(currency)}</span>
|
||||||
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={total}
|
<input type="text" inputMode="decimal" placeholder="0.00" value={total}
|
||||||
onChange={e => onTotalChange(e.target.value)}
|
onChange={e => onTotalChange(e.target.value)}
|
||||||
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 15, fontWeight: 600, paddingLeft: 6, width: '100%' }} />
|
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 15, fontWeight: 600, paddingLeft: 6, width: '100%' }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -956,7 +968,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
|||||||
{on ? (
|
{on ? (
|
||||||
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
|
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
|
||||||
<span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
|
<span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
|
||||||
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={paid[p.id] || ''}
|
<input type="text" inputMode="decimal" placeholder="0.00" value={paid[p.id] || ''}
|
||||||
onChange={e => onPaidChange(p.id, e.target.value)}
|
onChange={e => onPaidChange(p.id, e.target.value)}
|
||||||
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
|
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -969,7 +981,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 10, fontSize: 12.5, display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
|
<div style={{ marginTop: 10, fontSize: 12.5, display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
|
||||||
<span className="text-content-faint">
|
<span className="text-content-faint">
|
||||||
{participants.size === 0 ? t('costs.pickSomeone') : t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })}
|
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })}
|
||||||
</span>
|
</span>
|
||||||
{paidEntered
|
{paidEntered
|
||||||
? <span style={{ fontWeight: 600, color: balanced ? '#16a34a' : '#dc2626' }}>{sym(currency)}{paidSum.toFixed(2)} / {sym(currency)}{totalNum.toFixed(2)}</span>
|
? <span style={{ fontWeight: 600, color: balanced ? '#16a34a' : '#dc2626' }}>{sym(currency)}{paidSum.toFixed(2)} / {sym(currency)}{totalNum.toFixed(2)}</span>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
calculateSegments,
|
calculateSegments,
|
||||||
optimizeRoute,
|
optimizeRoute,
|
||||||
generateGoogleMapsUrl,
|
generateGoogleMapsUrl,
|
||||||
|
withHotelBookends,
|
||||||
} from './RouteCalculator'
|
} from './RouteCalculator'
|
||||||
|
|
||||||
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
||||||
@@ -241,3 +242,46 @@ describe('generateGoogleMapsUrl', () => {
|
|||||||
expect(result).toContain('48.86,2.36')
|
expect(result).toContain('48.86,2.36')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── withHotelBookends (#1275: draw the hotel → first / last → hotel legs) ────────
|
||||||
|
|
||||||
|
describe('withHotelBookends', () => {
|
||||||
|
const hotel = { lat: 1, lng: 1 }
|
||||||
|
const a = { lat: 2, lng: 2 }
|
||||||
|
const b = { lat: 3, lng: 3 }
|
||||||
|
const evening = { lat: 4, lng: 4 }
|
||||||
|
|
||||||
|
it('FE-COMP-ROUTECALCULATOR-021: leaves runs untouched when there is no hotel', () => {
|
||||||
|
const runs = [[a, b]]
|
||||||
|
expect(withHotelBookends(runs, a, b, null, null)).toEqual([[a, b]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-ROUTECALCULATOR-022: prepends hotel→first and appends last→hotel around the runs', () => {
|
||||||
|
const runs = [[a, b]]
|
||||||
|
expect(withHotelBookends(runs, a, b, hotel, evening)).toEqual([
|
||||||
|
[hotel, a],
|
||||||
|
[a, b],
|
||||||
|
[b, evening],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-ROUTECALCULATOR-023: a single stop with no runs still draws hotel→stop→hotel', () => {
|
||||||
|
expect(withHotelBookends([], a, a, hotel, evening)).toEqual([
|
||||||
|
[hotel, a],
|
||||||
|
[a, evening],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-ROUTECALCULATOR-024: a missing first/last waypoint skips that bookend', () => {
|
||||||
|
const runs = [[a, b]]
|
||||||
|
expect(withHotelBookends(runs, undefined, undefined, hotel, evening)).toEqual([[a, b]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-ROUTECALCULATOR-025: only the start hotel adds just the opening leg', () => {
|
||||||
|
const runs = [[a, b]]
|
||||||
|
expect(withHotelBookends(runs, a, b, hotel, null)).toEqual([
|
||||||
|
[hotel, a],
|
||||||
|
[a, b],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -67,6 +67,27 @@ export async function calculateRoute(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepends a hotel→first-waypoint run and appends a last-waypoint→hotel run to the
|
||||||
|
* day's activity runs, so the drawn route starts and ends at the day's accommodation
|
||||||
|
* (matching the sidebar's hotel connectors). A bookend is only added when both its
|
||||||
|
* hotel and the first/last located waypoint exist; passing nulls leaves `runs`
|
||||||
|
* untouched. The shared first/last waypoint is repeated so the polylines join.
|
||||||
|
*/
|
||||||
|
export function withHotelBookends(
|
||||||
|
runs: Waypoint[][],
|
||||||
|
firstWay: Waypoint | undefined,
|
||||||
|
lastWay: Waypoint | undefined,
|
||||||
|
startHotel: Waypoint | null,
|
||||||
|
endHotel: Waypoint | null,
|
||||||
|
): Waypoint[][] {
|
||||||
|
const out: Waypoint[][] = []
|
||||||
|
if (startHotel && firstWay) out.push([startHotel, firstWay])
|
||||||
|
out.push(...runs)
|
||||||
|
if (endHotel && lastWay) out.push([lastWay, endHotel])
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
export function generateGoogleMapsUrl(places: Waypoint[]): string | null {
|
export function generateGoogleMapsUrl(places: Waypoint[]): string | null {
|
||||||
const valid = places.filter((p) => p.lat && p.lng)
|
const valid = places.filter((p) => p.lat && p.lng)
|
||||||
if (valid.length === 0) return null
|
if (valid.length === 0) return null
|
||||||
|
|||||||
@@ -323,6 +323,28 @@ describe('downloadTripPDF', () => {
|
|||||||
expect(photoCalled).toBe(true)
|
expect(photoCalled).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-TRIPPDF-019b: fetches photos for OSM places via osm_id recovered from the places pool (#1130)', async () => {
|
||||||
|
let fetchedId: string | null = null
|
||||||
|
server.use(
|
||||||
|
http.get('/api/maps/place-photo/:placeId', ({ params }) => {
|
||||||
|
fetchedId = params.placeId as string
|
||||||
|
return HttpResponse.json({ photoUrl: 'https://example.com/osm.jpg' })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
// The assignment projection drops osm_id; the full place in `places` carries it.
|
||||||
|
const osmPlace = { ...placeWithDetails, id: 101, image_url: null, google_place_id: null, osm_id: 'node/240109189', lat: 41.89, lng: 12.49 }
|
||||||
|
const args = {
|
||||||
|
...richArgs,
|
||||||
|
places: [osmPlace],
|
||||||
|
assignments: {
|
||||||
|
'10': [{ ...assignmentForDay, id: 201, place_id: 101, place: { ...placeWithDetails, id: 101, image_url: null, google_place_id: null } }],
|
||||||
|
} as any,
|
||||||
|
}
|
||||||
|
await downloadTripPDF(args)
|
||||||
|
// osm_id is used as the photo key (not the coords fallback), proving the pool lookup works.
|
||||||
|
expect(fetchedId).toBe('node/240109189')
|
||||||
|
})
|
||||||
|
|
||||||
it('FE-COMP-TRIPPDF-020: renders empty day message when no items assigned', async () => {
|
it('FE-COMP-TRIPPDF-020: renders empty day message when no items assigned', async () => {
|
||||||
const args = {
|
const args = {
|
||||||
...minimalArgs,
|
...minimalArgs,
|
||||||
|
|||||||
@@ -97,21 +97,29 @@ function dayCost(assignments, dayId, locale) {
|
|||||||
return total > 0 ? `${total.toLocaleString(locale)} EUR` : null
|
return total > 0 ? `${total.toLocaleString(locale)} EUR` : null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-fetch Google Place photos for all assigned places
|
// Pre-fetch place photos for all assigned places.
|
||||||
async function fetchPlacePhotos(assignments: AssignmentsMap) {
|
// Assignment places are a server-side projection that drops osm_id, so we recover
|
||||||
|
// the full place from the trip's places pool and key the photo off the same id the
|
||||||
|
// app UI uses (google_place_id || osm_id || coords) — otherwise OSM/coords-only
|
||||||
|
// places fell back to category icons in the PDF even though they show photos in-app.
|
||||||
|
async function fetchPlacePhotos(assignments: AssignmentsMap, places: Place[]) {
|
||||||
const photoMap = {} // placeId → photoUrl
|
const photoMap = {} // placeId → photoUrl
|
||||||
|
// The assignment projection drops osm_id, so recover it from the full places pool.
|
||||||
|
const osmById = new Map((places || []).map(p => [p.id, p.osm_id]))
|
||||||
const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean)
|
const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean)
|
||||||
const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()]
|
const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()]
|
||||||
|
|
||||||
// Assignment places are a server-side projection that omits osm_id, so photo
|
const toFetch = unique
|
||||||
// pre-fetch keys off the google_place_id that the projection does carry.
|
.map(p => ({ p, osm_id: osmById.get(p.id) }))
|
||||||
const toFetch = unique.filter(p => !p.image_url && p.google_place_id)
|
.filter(({ p, osm_id }) => !p.image_url && (p.google_place_id || osm_id || (p.lat != null && p.lng != null)))
|
||||||
|
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
toFetch.map(async (place) => {
|
toFetch.map(async ({ p, osm_id }) => {
|
||||||
|
// Same key the app UI uses: google_place_id || osm_id || coords.
|
||||||
|
const photoId = p.google_place_id || osm_id || `coords:${p.lat}:${p.lng}`
|
||||||
try {
|
try {
|
||||||
const data = await mapsApi.placePhoto(place.google_place_id, place.lat, place.lng, place.name)
|
const data = await mapsApi.placePhoto(photoId, p.lat, p.lng, p.name)
|
||||||
if (data.photoUrl) photoMap[place.id] = data.photoUrl
|
if (data.photoUrl) photoMap[p.id] = data.photoUrl
|
||||||
} catch {}
|
} catch {}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -141,8 +149,8 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
//retrieve accommodations for the trip to display on the day sections and prefetch their photos if needed
|
//retrieve accommodations for the trip to display on the day sections and prefetch their photos if needed
|
||||||
const accommodations = await accommodationsApi.list(trip.id);
|
const accommodations = await accommodationsApi.list(trip.id);
|
||||||
|
|
||||||
// Pre-fetch place photos from Google
|
// Pre-fetch place photos (Google, OSM and coords-only places)
|
||||||
const photoMap = await fetchPlacePhotos(assignments)
|
const photoMap = await fetchPlacePhotos(assignments, places)
|
||||||
|
|
||||||
const totalAssigned = new Set(
|
const totalAssigned = new Set(
|
||||||
Object.values(assignments || {}).flatMap(a => a.map(x => x.place?.id)).filter(Boolean)
|
Object.values(assignments || {}).flatMap(a => a.map(x => x.place?.id)).filter(Boolean)
|
||||||
|
|||||||
@@ -174,7 +174,9 @@ describe('PackingListPanel', () => {
|
|||||||
|
|
||||||
it('FE-COMP-PACKING-016: delete item button exists and triggers API call', async () => {
|
it('FE-COMP-PACKING-016: delete item button exists and triggers API call', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const item = buildPackingItem({ id: 99, name: 'To Remove', category: 'Test' });
|
// Uncategorized item: deleting it is a plain DELETE (a custom category's last
|
||||||
|
// item is instead converted to a placeholder — see FE-COMP-PACKING-070).
|
||||||
|
const item = buildPackingItem({ id: 99, name: 'To Remove', category: null });
|
||||||
let deleteCalled = false;
|
let deleteCalled = false;
|
||||||
server.use(
|
server.use(
|
||||||
http.delete('/api/trips/1/packing/99', () => {
|
http.delete('/api/trips/1/packing/99', () => {
|
||||||
@@ -1415,4 +1417,83 @@ describe('PackingListPanel', () => {
|
|||||||
expect(clickSpy).toHaveBeenCalled();
|
expect(clickSpy).toHaveBeenCalled();
|
||||||
clickSpy.mockRestore();
|
clickSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-PACKING-070: deleting the last item of a custom category converts the row to a placeholder so the category persists in place (#1289)', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const item = buildPackingItem({ id: 99, name: 'Tent', category: 'Camping Gear' });
|
||||||
|
// handleDeleteItem decides "last in category" from the rendered list.
|
||||||
|
seedStore(useTripStore, { packingItems: [item] });
|
||||||
|
let deleted = false;
|
||||||
|
let putBody: Record<string, unknown> | null = null;
|
||||||
|
server.use(
|
||||||
|
http.delete('/api/trips/1/packing/99', () => {
|
||||||
|
deleted = true;
|
||||||
|
return HttpResponse.json({ success: true });
|
||||||
|
}),
|
||||||
|
http.put('/api/trips/1/packing/99', async ({ request }) => {
|
||||||
|
putBody = await request.json() as Record<string, unknown>;
|
||||||
|
return HttpResponse.json({ item: buildPackingItem({ id: 99, name: '...', category: 'Camping Gear' }) });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(<PackingListPanel tripId={1} items={[item]} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByTitle('Delete'));
|
||||||
|
|
||||||
|
// The row is updated in place (same id) rather than deleted, so colour/position hold.
|
||||||
|
await waitFor(() => expect(putBody).toMatchObject({ name: '...' }));
|
||||||
|
expect(deleted).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-PACKING-071: deleting the placeholder row deletes it, dismissing the empty category (#1289)', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const placeholder = buildPackingItem({ id: 5, name: '...', category: 'Camping Gear' });
|
||||||
|
seedStore(useTripStore, { packingItems: [placeholder] });
|
||||||
|
let deleted = false;
|
||||||
|
let converted = false;
|
||||||
|
server.use(
|
||||||
|
http.delete('/api/trips/1/packing/5', () => {
|
||||||
|
deleted = true;
|
||||||
|
return HttpResponse.json({ success: true });
|
||||||
|
}),
|
||||||
|
http.put('/api/trips/1/packing/5', () => {
|
||||||
|
converted = true;
|
||||||
|
return HttpResponse.json({ item: placeholder });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(<PackingListPanel tripId={1} items={[placeholder]} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByTitle('Delete'));
|
||||||
|
|
||||||
|
await waitFor(() => expect(deleted).toBe(true));
|
||||||
|
// It is the placeholder itself — it must be removed, not re-converted.
|
||||||
|
expect(converted).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-PACKING-072: adding an item to an empty category reuses the placeholder row instead of appending (#1289)', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const placeholder = buildPackingItem({ id: 5, name: '...', category: 'Camping Gear' });
|
||||||
|
seedStore(useTripStore, { packingItems: [placeholder] });
|
||||||
|
let posted = false;
|
||||||
|
let putBody: Record<string, unknown> | null = null;
|
||||||
|
server.use(
|
||||||
|
http.post('/api/trips/1/packing', () => {
|
||||||
|
posted = true;
|
||||||
|
return HttpResponse.json({ item: buildPackingItem({ id: 6 }) });
|
||||||
|
}),
|
||||||
|
http.put('/api/trips/1/packing/5', async ({ request }) => {
|
||||||
|
putBody = await request.json() as Record<string, unknown>;
|
||||||
|
return HttpResponse.json({ item: buildPackingItem({ id: 5, name: 'Tent', category: 'Camping Gear' }) });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(<PackingListPanel tripId={1} items={[placeholder]} />);
|
||||||
|
|
||||||
|
// Open the category's inline "Add item" and add a real entry.
|
||||||
|
await user.click(screen.getByText('Add item'));
|
||||||
|
const input = await screen.findByPlaceholderText('Item name...');
|
||||||
|
await user.type(input, 'Tent');
|
||||||
|
await user.keyboard('{Enter}');
|
||||||
|
|
||||||
|
await waitFor(() => expect(putBody).toMatchObject({ name: 'Tent' }));
|
||||||
|
expect(posted).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface KategorieGruppeProps {
|
|||||||
allCategories: string[]
|
allCategories: string[]
|
||||||
onRename: (oldName: string, newName: string) => Promise<void>
|
onRename: (oldName: string, newName: string) => Promise<void>
|
||||||
onDeleteAll: (items: PackingItem[]) => Promise<void>
|
onDeleteAll: (items: PackingItem[]) => Promise<void>
|
||||||
|
onDeleteItem: (item: PackingItem) => Promise<void>
|
||||||
onAddItem: (category: string, name: string) => Promise<void>
|
onAddItem: (category: string, name: string) => Promise<void>
|
||||||
assignees: CategoryAssignee[]
|
assignees: CategoryAssignee[]
|
||||||
tripMembers: TripMember[]
|
tripMembers: TripMember[]
|
||||||
@@ -28,7 +29,7 @@ interface KategorieGruppeProps {
|
|||||||
canEdit?: boolean
|
canEdit?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) {
|
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) {
|
||||||
const [offen, setOffen] = useState(true)
|
const [offen, setOffen] = useState(true)
|
||||||
const [editingName, setEditingName] = useState(false)
|
const [editingName, setEditingName] = useState(false)
|
||||||
const [editKatName, setEditKatName] = useState(kategorie)
|
const [editKatName, setEditKatName] = useState(kategorie)
|
||||||
@@ -231,7 +232,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
|||||||
{offen && (
|
{offen && (
|
||||||
<div style={{ padding: '4px 4px 6px' }}>
|
<div style={{ padding: '4px 4px 6px' }}>
|
||||||
{items.map(item => (
|
{items.map(item => (
|
||||||
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} />
|
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} onDelete={onDeleteItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} />
|
||||||
))}
|
))}
|
||||||
{/* Inline add item */}
|
{/* Inline add item */}
|
||||||
{canEdit && (showAddItem ? (
|
{canEdit && (showAddItem ? (
|
||||||
|
|||||||
@@ -15,13 +15,14 @@ interface ArtikelZeileProps {
|
|||||||
tripId: number
|
tripId: number
|
||||||
categories: string[]
|
categories: string[]
|
||||||
onCategoryChange: () => void
|
onCategoryChange: () => void
|
||||||
|
onDelete?: (item: PackingItem) => Promise<void>
|
||||||
bagTrackingEnabled?: boolean
|
bagTrackingEnabled?: boolean
|
||||||
bags?: PackingBag[]
|
bags?: PackingBag[]
|
||||||
onCreateBag: (name: string) => Promise<PackingBag | undefined>
|
onCreateBag: (name: string) => Promise<PackingBag | undefined>
|
||||||
canEdit?: boolean
|
canEdit?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
|
export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDelete, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
|
||||||
const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME
|
const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name)
|
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name)
|
||||||
@@ -43,6 +44,9 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTr
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
|
// The panel routes deletion through onDelete so an emptied custom category
|
||||||
|
// keeps its placeholder; fall back to a plain delete when used standalone.
|
||||||
|
if (onDelete) { await onDelete(item); return }
|
||||||
try { await deletePackingItem(tripId, item.id) }
|
try { await deletePackingItem(tripId, item.id) }
|
||||||
catch { toast.error(t('packing.toast.deleteError')) }
|
catch { toast.error(t('packing.toast.deleteError')) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { KategorieGruppe } from './PackingListPanelCategoryGroup'
|
|||||||
|
|
||||||
export function PackingList(S: PackingState) {
|
export function PackingList(S: PackingState) {
|
||||||
const {
|
const {
|
||||||
items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory,
|
items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory, handleDeleteItem,
|
||||||
handleAddItemToCategory, categoryAssignees, tripMembers, handleSetAssignees,
|
handleAddItemToCategory, categoryAssignees, tripMembers, handleSetAssignees,
|
||||||
bagTrackingEnabled, bags, handleCreateBagByName, canEdit,
|
bagTrackingEnabled, bags, handleCreateBagByName, canEdit,
|
||||||
} = S
|
} = S
|
||||||
@@ -31,6 +31,7 @@ export function PackingList(S: PackingState) {
|
|||||||
allCategories={allCategories}
|
allCategories={allCategories}
|
||||||
onRename={handleRenameCategory}
|
onRename={handleRenameCategory}
|
||||||
onDeleteAll={handleDeleteCategory}
|
onDeleteAll={handleDeleteCategory}
|
||||||
|
onDeleteItem={handleDeleteItem}
|
||||||
onAddItem={handleAddItemToCategory}
|
onAddItem={handleAddItemToCategory}
|
||||||
assignees={categoryAssignees[kat] || []}
|
assignees={categoryAssignees[kat] || []}
|
||||||
tripMembers={tripMembers}
|
tripMembers={tripMembers}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useTranslation } from '../../i18n'
|
|||||||
import { packingApi, tripsApi } from '../../api/client'
|
import { packingApi, tripsApi } from '../../api/client'
|
||||||
import { useAddonStore } from '../../store/addonStore'
|
import { useAddonStore } from '../../store/addonStore'
|
||||||
import type { PackingItem, PackingBag } from '../../types'
|
import type { PackingItem, PackingBag } from '../../types'
|
||||||
import { BAG_COLORS } from './packingListPanel.constants'
|
import { BAG_COLORS, PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants'
|
||||||
import { parseImportLines } from './packingListPanel.helpers'
|
import { parseImportLines } from './packingListPanel.helpers'
|
||||||
|
|
||||||
export interface TripMember {
|
export interface TripMember {
|
||||||
@@ -44,7 +44,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
|||||||
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
|
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
|
||||||
const [addingCategory, setAddingCategory] = useState(false)
|
const [addingCategory, setAddingCategory] = useState(false)
|
||||||
const [newCatName, setNewCatName] = useState('')
|
const [newCatName, setNewCatName] = useState('')
|
||||||
const { addPackingItem, updatePackingItem, deletePackingItem } = useTripStore()
|
const { addPackingItem, updatePackingItem, deletePackingItem, togglePackingItem } = useTripStore()
|
||||||
const can = useCanDo()
|
const can = useCanDo()
|
||||||
const trip = useTripStore((s) => s.trip)
|
const trip = useTripStore((s) => s.trip)
|
||||||
const canEdit = can('packing_edit', trip)
|
const canEdit = can('packing_edit', trip)
|
||||||
@@ -106,10 +106,45 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
|||||||
|
|
||||||
const handleAddItemToCategory = async (category: string, name: string) => {
|
const handleAddItemToCategory = async (category: string, name: string) => {
|
||||||
try {
|
try {
|
||||||
|
// Reuse the '...' placeholder slot when the category already has one, so a
|
||||||
|
// freshly-emptied category keeps its position (and therefore its colour)
|
||||||
|
// instead of the new item being appended to the end of the list.
|
||||||
|
const placeholder = useTripStore.getState().packingItems.find(
|
||||||
|
i => i.category === category && i.name === PACKING_PLACEHOLDER_NAME
|
||||||
|
)
|
||||||
|
if (placeholder) {
|
||||||
|
await updatePackingItem(tripId, placeholder.id, { name })
|
||||||
|
} else {
|
||||||
await addPackingItem(tripId, { name, category })
|
await addPackingItem(tripId, { name, category })
|
||||||
|
}
|
||||||
} catch { toast.error(t('packing.toast.addError')) }
|
} catch { toast.error(t('packing.toast.addError')) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deleting an item from a row. When it is the last item of a user-created
|
||||||
|
// category, turn that row back into the '...' placeholder in place rather than
|
||||||
|
// deleting it (#1289). Updating the row keeps its id, list position and colour,
|
||||||
|
// so the category neither disappears nor jumps to the end. The default
|
||||||
|
// (uncategorized) group and the placeholder row itself are deleted normally —
|
||||||
|
// removing the placeholder is how an empty category is dismissed.
|
||||||
|
const handleDeleteItem = async (item: PackingItem) => {
|
||||||
|
const category = item.category
|
||||||
|
const isLastInCategory = !!category
|
||||||
|
&& item.name !== PACKING_PLACEHOLDER_NAME
|
||||||
|
&& !items.some(i => i.id !== item.id && i.category === category)
|
||||||
|
try {
|
||||||
|
if (isLastInCategory) {
|
||||||
|
if (item.checked) await togglePackingItem(tripId, item.id, false)
|
||||||
|
await updatePackingItem(tripId, item.id, {
|
||||||
|
name: PACKING_PLACEHOLDER_NAME, weight_grams: null, bag_id: null, quantity: 1,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await deletePackingItem(tripId, item.id)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t('packing.toast.deleteError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleAddNewCategory = async () => {
|
const handleAddNewCategory = async () => {
|
||||||
if (!newCatName.trim()) return
|
if (!newCatName.trim()) return
|
||||||
let catName = newCatName.trim()
|
let catName = newCatName.trim()
|
||||||
@@ -308,7 +343,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
|||||||
tripId, items, inlineHeader, t, canEdit, isAdmin, font,
|
tripId, items, inlineHeader, t, canEdit, isAdmin, font,
|
||||||
filter, setFilter, addingCategory, setAddingCategory, newCatName, setNewCatName,
|
filter, setFilter, addingCategory, setAddingCategory, newCatName, setNewCatName,
|
||||||
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
|
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
|
||||||
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleClearChecked,
|
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleClearChecked,
|
||||||
bagTrackingEnabled, bags, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal,
|
bagTrackingEnabled, bags, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal,
|
||||||
handleCreateBag, handleCreateBagByName, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
|
handleCreateBag, handleCreateBagByName, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
|
||||||
availableTemplates, showTemplateDropdown, setShowTemplateDropdown, applyingTemplate,
|
availableTemplates, showTemplateDropdown, setShowTemplateDropdown, applyingTemplate,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
|
|||||||
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react'
|
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react'
|
||||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon } from 'lucide-react'
|
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon } from 'lucide-react'
|
||||||
import { assignmentsApi, reservationsApi } from '../../api/client'
|
import { assignmentsApi, reservationsApi } from '../../api/client'
|
||||||
import { calculateRoute, calculateRouteWithLegs, optimizeRoute } from '../Map/RouteCalculator'
|
import { calculateRoute, calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl } from '../Map/RouteCalculator'
|
||||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||||
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||||
@@ -2168,6 +2168,28 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
<RouteIcon size={12} strokeWidth={2} />
|
<RouteIcon size={12} strokeWidth={2} />
|
||||||
{t('dayplan.route')}
|
{t('dayplan.route')}
|
||||||
</button>
|
</button>
|
||||||
|
{/* Open the day's stops as a route in Google Maps (planned order). #1255 */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const url = generateGoogleMapsUrl(getDayAssignments(day.id).map(a => a.place).filter(p => p?.lat != null && p?.lng != null) as { lat: number; lng: number }[])
|
||||||
|
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
||||||
|
}}
|
||||||
|
aria-label={t('planner.openGoogleMaps')}
|
||||||
|
title={t('planner.openGoogleMaps')}
|
||||||
|
className="bg-transparent text-content-secondary"
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-faint)',
|
||||||
|
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 48 48" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z" />
|
||||||
|
<path d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z" />
|
||||||
|
<path d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z" />
|
||||||
|
<path d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button onClick={() => handleOptimize(day.id)} className="bg-surface-hover text-content-secondary" style={{
|
<button onClick={() => handleOptimize(day.id)} className="bg-surface-hover text-content-secondary" style={{
|
||||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||||
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
||||||
|
|||||||
@@ -102,7 +102,9 @@ export function ToastContainer() {
|
|||||||
`}</style>
|
`}</style>
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)',
|
position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)',
|
||||||
zIndex: 9999, display: 'flex', flexDirection: 'column-reverse', gap: 8,
|
// Above modal overlays (which sit around z-index 10000 with a backdrop-filter
|
||||||
|
// blur) so error toasts paint on top and stay legible instead of blurred behind.
|
||||||
|
zIndex: 100000, display: 'flex', flexDirection: 'column-reverse', gap: 8,
|
||||||
pointerEvents: 'none', maxWidth: 420, width: '100%', padding: '0 16px',
|
pointerEvents: 'none', maxWidth: 420, width: '100%', padding: '0 16px',
|
||||||
}}>
|
}}>
|
||||||
{toasts.map(toast => (
|
{toasts.map(toast => (
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||||
import { useTripStore } from '../store/tripStore'
|
import { useTripStore } from '../store/tripStore'
|
||||||
import { calculateRouteWithLegs } from '../components/Map/RouteCalculator'
|
import { useSettingsStore } from '../store/settingsStore'
|
||||||
|
import { calculateRouteWithLegs, withHotelBookends } from '../components/Map/RouteCalculator'
|
||||||
import { getTransportRouteEndpoints } from '../utils/dayMerge'
|
import { getTransportRouteEndpoints } from '../utils/dayMerge'
|
||||||
|
import { getDayBookendHotels } from '../utils/dayOrder'
|
||||||
import type { TripStoreState } from '../store/tripStore'
|
import type { TripStoreState } from '../store/tripStore'
|
||||||
import type { RouteSegment, RouteResult } from '../types'
|
import type { RouteSegment, RouteResult, Accommodation } from '../types'
|
||||||
|
|
||||||
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other']
|
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other']
|
||||||
|
|
||||||
@@ -12,12 +14,15 @@ const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cr
|
|||||||
* day assignments, draws a straight-line route immediately, then upgrades it to real OSRM
|
* day assignments, draws a straight-line route immediately, then upgrades it to real OSRM
|
||||||
* road geometry with per-segment durations. Aborts in-flight requests when the day changes.
|
* road geometry with per-segment durations. Aborts in-flight requests when the day changes.
|
||||||
*/
|
*/
|
||||||
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null, enabled: boolean = true, profile: 'driving' | 'walking' | 'cycling' = 'driving') {
|
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null, enabled: boolean = true, profile: 'driving' | 'walking' | 'cycling' = 'driving', accommodations: Accommodation[] = []) {
|
||||||
const [route, setRoute] = useState<[number, number][][] | null>(null)
|
const [route, setRoute] = useState<[number, number][][] | null>(null)
|
||||||
const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
|
const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
|
||||||
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
|
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
|
||||||
const routeAbortRef = useRef<AbortController | null>(null)
|
const routeAbortRef = useRef<AbortController | null>(null)
|
||||||
const reservationsForSignature = useTripStore((s) => s.reservations)
|
const reservationsForSignature = useTripStore((s) => s.reservations)
|
||||||
|
// Draw the day's accommodation bookend legs (hotel → first stop, last stop →
|
||||||
|
// hotel) unless the user turned the setting off — same gate as the sidebar.
|
||||||
|
const optimizeFromAccommodation = useSettingsStore((s) => s.settings.optimize_from_accommodation)
|
||||||
|
|
||||||
const updateRouteForDay = useCallback(async (dayId: number | null) => {
|
const updateRouteForDay = useCallback(async (dayId: number | null) => {
|
||||||
if (routeAbortRef.current) routeAbortRef.current.abort()
|
if (routeAbortRef.current) routeAbortRef.current.abort()
|
||||||
@@ -93,10 +98,26 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
|||||||
}
|
}
|
||||||
if (currentRun.length >= 2) runs.push(currentRun)
|
if (currentRun.length >= 2) runs.push(currentRun)
|
||||||
|
|
||||||
const straightLines = (): [number, number][][] =>
|
// Bookend the route with the day's accommodation: a hotel → first-stop run and
|
||||||
runs.map(r => r.map(p => [p.lat, p.lng] as [number, number]))
|
// a last-stop → hotel run, so the drawn line matches the sidebar's hotel legs.
|
||||||
|
// getDayBookendHotels returns the morning/evening hotel (they differ only on a
|
||||||
|
// transfer day) and already filters to accommodations that have coordinates.
|
||||||
|
const day = allDays.find(d => d.id === dayId)
|
||||||
|
const { morning: startHotel, evening: endHotel } =
|
||||||
|
day && optimizeFromAccommodation !== false ? getDayBookendHotels(day, allDays, accommodations) : {}
|
||||||
|
const flatPts: { lat: number; lng: number }[] = []
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.kind === 'place') flatPts.push({ lat: e.lat, lng: e.lng })
|
||||||
|
else { if (e.from) flatPts.push(e.from); if (e.to) flatPts.push(e.to) }
|
||||||
|
}
|
||||||
|
const hotelPt = (a?: Accommodation) =>
|
||||||
|
a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng } : null
|
||||||
|
const runsWithHotel = withHotelBookends(runs, flatPts[0], flatPts[flatPts.length - 1], hotelPt(startHotel), hotelPt(endHotel))
|
||||||
|
|
||||||
if (runs.length === 0) { setRoute(null); setRouteSegments([]); return }
|
const straightLines = (): [number, number][][] =>
|
||||||
|
runsWithHotel.map(r => r.map(p => [p.lat, p.lng] as [number, number]))
|
||||||
|
|
||||||
|
if (runsWithHotel.length === 0) { setRoute(null); setRouteSegments([]); return }
|
||||||
|
|
||||||
// Draw straight lines immediately for snappiness, then upgrade to the real
|
// Draw straight lines immediately for snappiness, then upgrade to the real
|
||||||
// OSRM road geometry.
|
// OSRM road geometry.
|
||||||
@@ -107,7 +128,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
|||||||
try {
|
try {
|
||||||
const polylines: [number, number][][] = []
|
const polylines: [number, number][][] = []
|
||||||
const allLegs: RouteSegment[] = []
|
const allLegs: RouteSegment[] = []
|
||||||
for (const run of runs) {
|
for (const run of runsWithHotel) {
|
||||||
try {
|
try {
|
||||||
const r = await calculateRouteWithLegs(run, { signal: controller.signal, profile })
|
const r = await calculateRouteWithLegs(run, { signal: controller.signal, profile })
|
||||||
polylines.push(r.coordinates.length >= 2 ? r.coordinates : run.map(p => [p.lat, p.lng] as [number, number]))
|
polylines.push(r.coordinates.length >= 2 ? r.coordinates : run.map(p => [p.lat, p.lng] as [number, number]))
|
||||||
@@ -123,7 +144,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
|||||||
// Aborted (day changed) — newer call owns the state. Anything else: keep straight lines.
|
// Aborted (day changed) — newer call owns the state. Anything else: keep straight lines.
|
||||||
if (!(err instanceof Error) || err.name !== 'AbortError') setRouteSegments([])
|
if (!(err instanceof Error) || err.name !== 'AbortError') setRouteSegments([])
|
||||||
}
|
}
|
||||||
}, [enabled, profile])
|
}, [enabled, profile, accommodations, optimizeFromAccommodation])
|
||||||
|
|
||||||
// Stable signature for transport reservations on the selected day — changes when a transport
|
// Stable signature for transport reservations on the selected day — changes when a transport
|
||||||
// is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders.
|
// is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders.
|
||||||
@@ -147,7 +168,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
|||||||
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
|
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
|
||||||
updateRouteForDay(selectedDayId)
|
updateRouteForDay(selectedDayId)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile])
|
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation])
|
||||||
|
|
||||||
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
|
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
const {
|
const {
|
||||||
demoMode, locale, t, navigate,
|
demoMode, locale, t, navigate,
|
||||||
spotlight, heroBundle, stats, upcoming, gridTrips, isLoading,
|
spotlight, heroBundle, stats, upcoming, gridTrips, isLoading,
|
||||||
|
loadError, retryLoad,
|
||||||
tripFilter, setTripFilter, viewMode, toggleViewMode,
|
tripFilter, setTripFilter, viewMode, toggleViewMode,
|
||||||
showForm, setShowForm, editingTrip, setEditingTrip,
|
showForm, setShowForm, editingTrip, setEditingTrip,
|
||||||
deleteTrip, setDeleteTrip, copyTrip, setCopyTrip, setTrips,
|
deleteTrip, setDeleteTrip, copyTrip, setCopyTrip, setTrips,
|
||||||
@@ -102,6 +103,15 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
<MobileTopBar />
|
<MobileTopBar />
|
||||||
<main className="page">
|
<main className="page">
|
||||||
<div className="page-main">
|
<div className="page-main">
|
||||||
|
{loadError && (
|
||||||
|
<div className="dash-error" role="alert">
|
||||||
|
<span className="dash-error-txt">{t('dashboard.loadErrorBanner')}</span>
|
||||||
|
<button className="dash-error-retry" onClick={retryLoad}>
|
||||||
|
<RefreshCw size={15} />
|
||||||
|
{t('dashboard.retry')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{spotlight && (
|
{spotlight && (
|
||||||
<BoardingPassHero
|
<BoardingPassHero
|
||||||
trip={spotlight}
|
trip={spotlight}
|
||||||
@@ -132,6 +142,13 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{gridTrips.length === 0 && tripFilter === 'planned' && !isLoading && !loadError && (
|
||||||
|
<div className="trips-empty">
|
||||||
|
<h4>{t('dashboard.emptyTitle')}</h4>
|
||||||
|
<p>{t('dashboard.emptyText')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={`trips${viewMode === 'list' ? ' list-view' : ''}`}>
|
<div className={`trips${viewMode === 'list' ? ' list-view' : ''}`}>
|
||||||
{gridTrips.map(trip => (
|
{gridTrips.map(trip => (
|
||||||
<TripCard
|
<TripCard
|
||||||
|
|||||||
@@ -229,9 +229,21 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
|||||||
|
|
||||||
<div style={{ padding: '20px 24px' }}>
|
<div style={{ padding: '20px 24px' }}>
|
||||||
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
|
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
|
||||||
{t('admin.update.dockerText').replace('{version}', `v${updateInfo?.latest ?? ''}`)}
|
{(updateInfo?.is_docker === false ? t('admin.update.nonDockerText') : t('admin.update.dockerText')).replace('{version}', `v${updateInfo?.latest ?? ''}`)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{updateInfo?.is_docker === false ? (
|
||||||
|
<a
|
||||||
|
href="https://github.com/mauriceboe/TREK/wiki/Updating"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 13, lineHeight: 1.5, display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none' }}
|
||||||
|
className="bg-gray-50 dark:bg-gray-900 text-gray-700 dark:text-gray-200 border border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span className="font-semibold underline">{t('admin.update.wikiLink')}</span>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
<div style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 12, lineHeight: 1.8, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
|
<div style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 12, lineHeight: 1.8, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
|
||||||
className="bg-gray-900 dark:bg-gray-950 text-gray-100 border border-gray-700"
|
className="bg-gray-900 dark:bg-gray-950 text-gray-100 border border-gray-700"
|
||||||
>
|
>
|
||||||
@@ -244,6 +256,7 @@ docker run -d --name trek \\
|
|||||||
--restart unless-stopped \\
|
--restart unless-stopped \\
|
||||||
mauriceboe/trek:latest`}
|
mauriceboe/trek:latest`}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
||||||
className="bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800"
|
className="bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800"
|
||||||
|
|||||||
@@ -134,9 +134,12 @@ export function useAtlas() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Load country-border GeoJSON from our API (geoBoundaries, served server-side —
|
// Load country-border GeoJSON from our API (geoBoundaries, served server-side —
|
||||||
// no third-party fetch from the browser).
|
// no third-party fetch from the browser). Even gzipped the payload is a few MB, so
|
||||||
|
// it gets a longer timeout than the global 8s default to survive slow links and
|
||||||
|
// reverse-proxy / Cloudflare-Tunnel setups instead of aborting and leaving the map
|
||||||
|
// with no countries (#1254).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiClient.get('/addons/atlas/countries/geo')
|
apiClient.get('/addons/atlas/countries/geo', { timeout: 30000 })
|
||||||
.then(res => {
|
.then(res => {
|
||||||
const geo = res.data
|
const geo = res.data
|
||||||
// Dynamically build A2→A3 mapping from GeoJSON
|
// Dynamically build A2→A3 mapping from GeoJSON
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export function useDashboard() {
|
|||||||
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
|
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
|
||||||
const [copyTrip, setCopyTrip] = useState<DashboardTrip | null>(null)
|
const [copyTrip, setCopyTrip] = useState<DashboardTrip | null>(null)
|
||||||
const [tripFilter, setTripFilter] = useState<'planned' | 'archive' | 'completed'>('planned')
|
const [tripFilter, setTripFilter] = useState<'planned' | 'archive' | 'completed'>('planned')
|
||||||
|
const [loadError, setLoadError] = useState<boolean>(false)
|
||||||
|
|
||||||
const [stats, setStats] = useState<TravelStats | null>(null)
|
const [stats, setStats] = useState<TravelStats | null>(null)
|
||||||
const [upcoming, setUpcoming] = useState<UpcomingReservation[]>([])
|
const [upcoming, setUpcoming] = useState<UpcomingReservation[]>([])
|
||||||
@@ -42,7 +43,7 @@ export function useDashboard() {
|
|||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const { demoMode } = useAuthStore()
|
const { demoMode, authCheckFailed, loadUser } = useAuthStore()
|
||||||
|
|
||||||
const toggleViewMode = () => {
|
const toggleViewMode = () => {
|
||||||
setViewMode(prev => {
|
setViewMode(prev => {
|
||||||
@@ -74,13 +75,22 @@ export function useDashboard() {
|
|||||||
const { trips, archivedTrips } = await tripRepo.list()
|
const { trips, archivedTrips } = await tripRepo.list()
|
||||||
setTrips(sortTrips(trips))
|
setTrips(sortTrips(trips))
|
||||||
setArchivedTrips(sortTrips(archivedTrips))
|
setArchivedTrips(sortTrips(archivedTrips))
|
||||||
|
setLoadError(false)
|
||||||
} catch {
|
} catch {
|
||||||
|
setLoadError(true)
|
||||||
toast.error(t('dashboard.toast.loadError'))
|
toast.error(t('dashboard.toast.loadError'))
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-run both the trip fetch and the auth check so a recovered backend clears
|
||||||
|
// the error banner (loadUser resets authCheckFailed on success). #1283
|
||||||
|
const retryLoad = () => {
|
||||||
|
loadUser({ silent: true })
|
||||||
|
loadTrips()
|
||||||
|
}
|
||||||
|
|
||||||
const today = new Date().toISOString().split('T')[0]
|
const today = new Date().toISOString().split('T')[0]
|
||||||
const spotlight = trips.find(t => t.start_date && t.end_date && t.start_date <= today && t.end_date >= today)
|
const spotlight = trips.find(t => t.start_date && t.end_date && t.start_date <= today && t.end_date >= today)
|
||||||
|| trips.find(t => t.start_date && t.start_date >= today)
|
|| trips.find(t => t.start_date && t.start_date >= today)
|
||||||
@@ -177,6 +187,7 @@ export function useDashboard() {
|
|||||||
demoMode, locale, t, navigate,
|
demoMode, locale, t, navigate,
|
||||||
// data + derived
|
// data + derived
|
||||||
spotlight, heroBundle, stats, upcoming, gridTrips, isLoading,
|
spotlight, heroBundle, stats, upcoming, gridTrips, isLoading,
|
||||||
|
loadError: loadError || authCheckFailed, retryLoad,
|
||||||
// ui state
|
// ui state
|
||||||
tripFilter, setTripFilter, viewMode, toggleViewMode,
|
tripFilter, setTripFilter, viewMode, toggleViewMode,
|
||||||
showForm, setShowForm, editingTrip, setEditingTrip,
|
showForm, setShowForm, editingTrip, setEditingTrip,
|
||||||
|
|||||||
@@ -289,7 +289,7 @@ export function useTripPlanner() {
|
|||||||
})
|
})
|
||||||
}, [places, mapCategoryFilter, mapPlacesFilter, assignments, expandedDayIds])
|
}, [places, mapCategoryFilter, mapPlacesFilter, assignments, expandedDayIds])
|
||||||
|
|
||||||
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile)
|
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile, tripAccommodations)
|
||||||
|
|
||||||
const handleSelectDay = useCallback((dayId: number | null, skipFit?: boolean) => {
|
const handleSelectDay = useCallback((dayId: number | null, skipFit?: boolean) => {
|
||||||
const changed = dayId !== selectedDayId
|
const changed = dayId !== selectedDayId
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ interface AuthState {
|
|||||||
user: User | null
|
user: User | null
|
||||||
isAuthenticated: boolean
|
isAuthenticated: boolean
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
|
/** The auth check (loadUser) failed for a non-401 reason while we were online —
|
||||||
|
* the server was unreachable or erroring. Surfaced by the UI so a backend/IdP
|
||||||
|
* outage doesn't render as a blank, error-free page that looks like lost data.
|
||||||
|
* Transient, never persisted. #1283 */
|
||||||
|
authCheckFailed: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
demoMode: boolean
|
demoMode: boolean
|
||||||
devMode: boolean
|
devMode: boolean
|
||||||
@@ -86,6 +91,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
user: null,
|
user: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
|
authCheckFailed: false,
|
||||||
error: null,
|
error: null,
|
||||||
demoMode: localStorage.getItem('demo_mode') === 'true',
|
demoMode: localStorage.getItem('demo_mode') === 'true',
|
||||||
devMode: false,
|
devMode: false,
|
||||||
@@ -200,6 +206,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
set({
|
set({
|
||||||
user: null,
|
user: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
authCheckFailed: false,
|
||||||
error: null,
|
error: null,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -215,22 +222,33 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
user: data.user,
|
user: data.user,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
authCheckFailed: false,
|
||||||
})
|
})
|
||||||
await onAuthSuccess(data.user.id)
|
await onAuthSuccess(data.user.id)
|
||||||
connect()
|
connect()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (seq !== authSequence) return // stale response — ignore
|
if (seq !== authSequence) return // stale response — ignore
|
||||||
// Only clear auth state on 401 (invalid/expired token), not on network errors
|
const status = err && typeof err === 'object' && 'response' in err
|
||||||
const isAuthError = err && typeof err === 'object' && 'response' in err &&
|
? (err as { response?: { status?: number } }).response?.status
|
||||||
(err as { response?: { status?: number } }).response?.status === 401
|
: undefined
|
||||||
if (isAuthError) {
|
if (status === 401) {
|
||||||
|
// Invalid/expired token — clear auth so the guard redirects to login.
|
||||||
set({
|
set({
|
||||||
user: null,
|
user: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
authCheckFailed: false,
|
||||||
})
|
})
|
||||||
} else {
|
} else if (status === undefined && typeof navigator !== 'undefined' && !navigator.onLine) {
|
||||||
|
// Genuinely offline — keep the persisted session so the PWA serves cached
|
||||||
|
// data without a scary error. This is the offline-first happy path.
|
||||||
set({ isLoading: false })
|
set({ isLoading: false })
|
||||||
|
} else {
|
||||||
|
// Server erroring (5xx) or unreachable while we're online: keep the session
|
||||||
|
// (don't eject the user over a transient outage), but flag it so the UI can
|
||||||
|
// say "couldn't reach the server" instead of showing a blank, error-free
|
||||||
|
// page that looks like the user's trips were lost. #1283
|
||||||
|
set({ isLoading: false, authCheckFailed: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -218,7 +218,7 @@
|
|||||||
opacity: .88; margin-bottom: 16px; font-weight: 500;
|
opacity: .88; margin-bottom: 16px; font-weight: 500;
|
||||||
}
|
}
|
||||||
.trek-dash .hero-eyebrow::before { content: ""; width: 28px; height: 1px; background: oklch(1 0 0 / .6); }
|
.trek-dash .hero-eyebrow::before { content: ""; width: 28px; height: 1px; background: oklch(1 0 0 / .6); }
|
||||||
.trek-dash .hero-title { font-size: 104px; font-weight: 600; line-height: 0.9; letter-spacing: -0.045em; margin: 0; }
|
.trek-dash .hero-title { font-size: 104px; font-weight: 600; line-height: 0.9; letter-spacing: -0.045em; margin: 0; text-shadow: 0 1px 12px oklch(0 0 0 / .32), 0 1px 3px oklch(0 0 0 / .4); }
|
||||||
|
|
||||||
/* ----------------- boarding pass ----------------- */
|
/* ----------------- boarding pass ----------------- */
|
||||||
.trek-dash .hero-pass {
|
.trek-dash .hero-pass {
|
||||||
@@ -422,7 +422,7 @@
|
|||||||
.trek-dash .trip-action-btn:hover { background: oklch(1 0 0 / .3); }
|
.trek-dash .trip-action-btn:hover { background: oklch(1 0 0 / .3); }
|
||||||
.trek-dash .trip-action-btn svg { width: 16px; height: 16px; }
|
.trek-dash .trip-action-btn svg { width: 16px; height: 16px; }
|
||||||
.trek-dash .trip-cover-content { position: absolute; left: 18px; right: 18px; bottom: 16px; z-index: 1; color: #fff; }
|
.trek-dash .trip-cover-content { position: absolute; left: 18px; right: 18px; bottom: 16px; z-index: 1; color: #fff; }
|
||||||
.trek-dash .trip-name { font-size: 26px; font-weight: 600; letter-spacing: -0.025em; line-height: 1.05; margin: 0; }
|
.trek-dash .trip-name { font-size: 26px; font-weight: 600; letter-spacing: -0.025em; line-height: 1.05; margin: 0; text-shadow: 0 1px 7px oklch(0 0 0 / .3), 0 1px 2px oklch(0 0 0 / .38); }
|
||||||
.trek-dash .trip-where { margin-top: 4px; font-size: 13px; opacity: .85; display: flex; align-items: center; gap: 6px; }
|
.trek-dash .trip-where { margin-top: 4px; font-size: 13px; opacity: .85; display: flex; align-items: center; gap: 6px; }
|
||||||
.trek-dash .trip-where svg { width: 12px; height: 12px; opacity: .8; }
|
.trek-dash .trip-where svg { width: 12px; height: 12px; opacity: .8; }
|
||||||
.trek-dash .trip-body { padding: 18px 20px 20px; }
|
.trek-dash .trip-body { padding: 18px 20px 20px; }
|
||||||
@@ -456,6 +456,33 @@
|
|||||||
.trek-dash .add-trip-card .ttl { font-size: 16px; font-weight: 500; margin-bottom: 4px; }
|
.trek-dash .add-trip-card .ttl { font-size: 16px; font-weight: 500; margin-bottom: 4px; }
|
||||||
.trek-dash .add-trip-card .sub { font-size: 13px; color: var(--ink-3); }
|
.trek-dash .add-trip-card .sub { font-size: 13px; color: var(--ink-3); }
|
||||||
|
|
||||||
|
/* Error banner — shown when the trip list or the auth check couldn't reach the
|
||||||
|
server, so a backend/IdP outage no longer looks like an empty (lost-data)
|
||||||
|
dashboard. Amber rather than red: it reassures (data is safe) more than it alarms. */
|
||||||
|
.trek-dash .dash-error {
|
||||||
|
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
|
||||||
|
padding: 14px 18px; margin-bottom: 22px;
|
||||||
|
background: oklch(0.74 0.14 75 / 0.13);
|
||||||
|
border: 1px solid oklch(0.74 0.14 75 / 0.45);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
box-shadow: var(--sh-sm);
|
||||||
|
}
|
||||||
|
.trek-dash .dash-error-txt { flex: 1; min-width: 200px; font-size: 14px; color: var(--ink); }
|
||||||
|
.trek-dash .dash-error-retry {
|
||||||
|
display: inline-flex; align-items: center; gap: 7px;
|
||||||
|
padding: 8px 14px; border: none; border-radius: var(--r-xs);
|
||||||
|
background: var(--ink); color: var(--surface);
|
||||||
|
font-size: 13px; font-weight: 500; cursor: pointer;
|
||||||
|
transition: opacity .15s ease;
|
||||||
|
}
|
||||||
|
.trek-dash .dash-error-retry:hover { opacity: .88; }
|
||||||
|
|
||||||
|
/* Empty state — a genuine "you have no trips yet" message, visually distinct
|
||||||
|
from the error banner above so an outage and a real empty list never look alike. */
|
||||||
|
.trek-dash .trips-empty { margin-bottom: 18px; }
|
||||||
|
.trek-dash .trips-empty h4 { font-size: 18px; font-weight: 600; color: var(--ink); margin: 0 0 6px; }
|
||||||
|
.trek-dash .trips-empty p { font-size: 14px; color: var(--ink-3); margin: 0; }
|
||||||
|
|
||||||
/* ----------------- tools sidebar ----------------- */
|
/* ----------------- tools sidebar ----------------- */
|
||||||
.trek-dash .tool {
|
.trek-dash .tool {
|
||||||
background: var(--glass-bg); border-radius: var(--r-xl); padding: 24px 26px;
|
background: var(--glass-bg); border-radius: var(--r-xl); padding: 24px 26px;
|
||||||
|
|||||||
Generated
+76
@@ -6603,6 +6603,17 @@
|
|||||||
"assertion-error": "^2.0.1"
|
"assertion-error": "^2.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/compression": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz",
|
||||||
|
"integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/express": "*",
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/connect": {
|
"node_modules/@types/connect": {
|
||||||
"version": "3.4.38",
|
"version": "3.4.38",
|
||||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||||
@@ -8875,6 +8886,60 @@
|
|||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/compressible": {
|
||||||
|
"version": "2.0.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
||||||
|
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": ">= 1.43.0 < 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/compression": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
|
||||||
|
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bytes": "3.1.2",
|
||||||
|
"compressible": "~2.0.18",
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"negotiator": "~0.6.4",
|
||||||
|
"on-headers": "~1.1.0",
|
||||||
|
"safe-buffer": "5.2.1",
|
||||||
|
"vary": "~1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/compression/node_modules/debug": {
|
||||||
|
"version": "2.6.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
|
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/compression/node_modules/ms": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/compression/node_modules/negotiator": {
|
||||||
|
"version": "0.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
|
||||||
|
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/concat-map": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
@@ -14776,6 +14841,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/on-headers": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/once": {
|
"node_modules/once": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
@@ -20480,6 +20554,7 @@
|
|||||||
"archiver": "^6.0.1",
|
"archiver": "^6.0.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
|
"compression": "^1.8.0",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.1",
|
"dotenv": "^16.4.1",
|
||||||
@@ -20512,6 +20587,7 @@
|
|||||||
"@types/archiver": "^7.0.0",
|
"@types/archiver": "^7.0.0",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/compression": "^1.8.0",
|
||||||
"@types/cookie-parser": "^1.4.10",
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^4.17.25",
|
"@types/express": "^4.17.25",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"archiver": "^6.0.1",
|
"archiver": "^6.0.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
|
"compression": "^1.8.0",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.1",
|
"dotenv": "^16.4.1",
|
||||||
@@ -72,6 +73,7 @@
|
|||||||
"@types/archiver": "^7.0.0",
|
"@types/archiver": "^7.0.0",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/compression": "^1.8.0",
|
||||||
"@types/cookie-parser": "^1.4.10",
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^4.17.25",
|
"@types/express": "^4.17.25",
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
|||||||
tripId: z.number().int().positive(),
|
tripId: z.number().int().positive(),
|
||||||
dayId: z.number().int().positive(),
|
dayId: z.number().int().positive(),
|
||||||
text: z.string().min(1).max(500),
|
text: z.string().min(1).max(500),
|
||||||
time: z.string().max(150).optional().describe('Time label (e.g. "09:00" or "Morning")'),
|
time: z.string().max(250).optional().describe('Time label (e.g. "09:00" or "Morning")'),
|
||||||
icon: z.string().optional().describe('Emoji icon for the note'),
|
icon: z.string().optional().describe('Emoji icon for the note'),
|
||||||
},
|
},
|
||||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||||
@@ -255,7 +255,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
|||||||
dayId: z.number().int().positive(),
|
dayId: z.number().int().positive(),
|
||||||
noteId: z.number().int().positive(),
|
noteId: z.number().int().positive(),
|
||||||
text: z.string().min(1).max(500).optional(),
|
text: z.string().min(1).max(500).optional(),
|
||||||
time: z.string().max(150).nullable().optional().describe('Time label (e.g. "09:00" or "Morning"), or null to clear'),
|
time: z.string().max(250).nullable().optional().describe('Time label (e.g. "09:00" or "Morning"), or null to clear'),
|
||||||
icon: z.string().optional().describe('Emoji icon for the note'),
|
icon: z.string().optional().describe('Emoji icon for the note'),
|
||||||
},
|
},
|
||||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import express, { Request, Response, NextFunction } from 'express';
|
import express, { Request, Response, NextFunction } from 'express';
|
||||||
|
import compression from 'compression';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
@@ -28,6 +29,21 @@ export function applyGlobalMiddleware(
|
|||||||
app.set('trust proxy', Number.parseInt(process.env.TRUST_PROXY) || 1);
|
app.set('trust proxy', Number.parseInt(process.env.TRUST_PROXY) || 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compress responses (gzip via Accept-Encoding). The Atlas admin-0 country
|
||||||
|
// GeoJSON is ~30 MB uncompressed, which stalls/aborts (~8s → net::ERR_FAILED)
|
||||||
|
// behind reverse proxies and Cloudflare Tunnel (#1254); gzip brings it to ~4 MB.
|
||||||
|
// SSE responses (the /mcp StreamableHTTP transport) must NOT be buffered, so
|
||||||
|
// they are excluded explicitly.
|
||||||
|
app.use(
|
||||||
|
compression({
|
||||||
|
filter: (req, res) => {
|
||||||
|
const type = res.getHeader('Content-Type');
|
||||||
|
if (typeof type === 'string' && type.includes('text/event-stream')) return false;
|
||||||
|
return compression.filter(req, res);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
||||||
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean)
|
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean)
|
||||||
: null;
|
: null;
|
||||||
@@ -103,7 +119,9 @@ export function applyGlobalMiddleware(
|
|||||||
workerSrc: ["'self'", "blob:"],
|
workerSrc: ["'self'", "blob:"],
|
||||||
childSrc: ["'self'", "blob:"],
|
childSrc: ["'self'", "blob:"],
|
||||||
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
|
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
|
||||||
objectSrc: ["'none'"],
|
// 'self' so same-origin file previews can embed PDFs via <object>/<embed>
|
||||||
|
// (Firefox/Chrome enforce object-src; 'none' broke inline PDF previews there).
|
||||||
|
objectSrc: ["'self'"],
|
||||||
frameSrc: ["'none'"],
|
frameSrc: ["'none'"],
|
||||||
frameAncestors: ["'self'"],
|
frameAncestors: ["'self'"],
|
||||||
// Restrict <form> submission targets (form-action has no default-src
|
// Restrict <form> submission targets (form-action has no default-src
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ import { CurrentUser } from '../auth/current-user.decorator';
|
|||||||
|
|
||||||
type DayNoteBody = { text?: string; time?: string; icon?: string; sort_order?: number };
|
type DayNoteBody = { text?: string; time?: string; icon?: string; sort_order?: number };
|
||||||
|
|
||||||
// Mirrors the legacy validateStringLengths({ text: 500, time: 150 }) middleware,
|
// Runs BEFORE the trip-access check, so an over-long field 400s first. The `time`
|
||||||
// which runs BEFORE the trip-access check — so an over-long field 400s first.
|
// cap matches the shared dayNote schema (max 250) and the note dialog's counter;
|
||||||
const MAX_LENGTHS: Record<string, number> = { text: 500, time: 150 };
|
// it was 150 here, which rejected valid 151–250 char notes with a confusing error.
|
||||||
|
const MAX_LENGTHS: Record<string, number> = { text: 500, time: 250 };
|
||||||
|
|
||||||
function validateLengths(body: Record<string, unknown>): void {
|
function validateLengths(body: Record<string, unknown>): void {
|
||||||
for (const [field, max] of Object.entries(MAX_LENGTHS)) {
|
for (const [field, max] of Object.entries(MAX_LENGTHS)) {
|
||||||
|
|||||||
@@ -141,6 +141,16 @@ export function updateUser(id: string, data: { username?: string; email?: string
|
|||||||
}
|
}
|
||||||
const passwordHash = password ? bcrypt.hashSync(password, BCRYPT_COST) : null;
|
const passwordHash = password ? bcrypt.hashSync(password, BCRYPT_COST) : null;
|
||||||
|
|
||||||
|
// Don't let the admin UI demote the last remaining admin — that would leave the
|
||||||
|
// instance with no one able to manage it (and on OIDC-only setups, no recovery). #1274
|
||||||
|
if (role && role !== 'admin') {
|
||||||
|
const current = db.prepare('SELECT role FROM users WHERE id = ?').get(id) as { role?: string } | undefined;
|
||||||
|
if (current?.role === 'admin') {
|
||||||
|
const adminCount = (db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number }).count;
|
||||||
|
if (adminCount <= 1) return { error: 'Cannot remove the last admin', status: 400 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE users SET
|
UPDATE users SET
|
||||||
username = COALESCE(?, username),
|
username = COALESCE(?, username),
|
||||||
|
|||||||
@@ -935,13 +935,16 @@ export function getTravelStats(userId: number) {
|
|||||||
WHERE t.user_id = ? OR tm.user_id = ?
|
WHERE t.user_id = ? OR tm.user_id = ?
|
||||||
`).all(userId, userId) as { address: string | null; lat: number | null; lng: number | null }[];
|
`).all(userId, userId) as { address: string | null; lat: number | null; lng: number | null }[];
|
||||||
|
|
||||||
|
// Archived trips still count here, matching the places, countries and flight
|
||||||
|
// distance widgets (which never filtered on is_archived) so the dashboard stats
|
||||||
|
// stay consistent — archiving a trip no longer zeroes out trips/days.
|
||||||
const tripStats = db.prepare(`
|
const tripStats = db.prepare(`
|
||||||
SELECT COUNT(DISTINCT t.id) as trips,
|
SELECT COUNT(DISTINCT t.id) as trips,
|
||||||
COUNT(DISTINCT d.id) as days
|
COUNT(DISTINCT d.id) as days
|
||||||
FROM trips t
|
FROM trips t
|
||||||
LEFT JOIN days d ON d.trip_id = t.id
|
LEFT JOIN days d ON d.trip_id = t.id
|
||||||
LEFT JOIN trip_members tm ON t.id = tm.trip_id
|
LEFT JOIN trip_members tm ON t.id = tm.trip_id
|
||||||
WHERE (t.user_id = ? OR tm.user_id = ?) AND t.is_archived = 0
|
WHERE (t.user_id = ? OR tm.user_id = ?)
|
||||||
`).get(userId, userId) as { trips: number; days: number } | undefined;
|
`).get(userId, userId) as { trips: number; days: number } | undefined;
|
||||||
|
|
||||||
const cities = new Set<string>();
|
const cities = new Set<string>();
|
||||||
|
|||||||
@@ -385,10 +385,22 @@ export function findOrCreateUser(
|
|||||||
if (process.env.OIDC_ADMIN_VALUE) {
|
if (process.env.OIDC_ADMIN_VALUE) {
|
||||||
const newRole = resolveOidcRole(userInfo, false);
|
const newRole = resolveOidcRole(userInfo, false);
|
||||||
if (user.role !== newRole) {
|
if (user.role !== newRole) {
|
||||||
|
// Never let the claim-based downgrade strip the last admin. The bootstrap
|
||||||
|
// admin (first SSO user) usually doesn't carry the admin claim, so a forced
|
||||||
|
// re-login — e.g. after a JWT-secret rotation — would otherwise demote it and
|
||||||
|
// lock an OIDC-only instance out for good. #1274
|
||||||
|
const demotingLastAdmin =
|
||||||
|
user.role === 'admin' &&
|
||||||
|
newRole !== 'admin' &&
|
||||||
|
(db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number }).count <= 1;
|
||||||
|
if (demotingLastAdmin) {
|
||||||
|
console.warn(`[OIDC] Kept admin role for user ${user.id}: their OIDC claims map to '${newRole}', but they are the only admin — demoting would lock the instance out.`);
|
||||||
|
} else {
|
||||||
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(newRole, user.id);
|
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(newRole, user.id);
|
||||||
user = { ...user, role: newRole } as User;
|
user = { ...user, role: newRole } as User;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return { user };
|
return { user };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -122,4 +122,17 @@ describe('BOOTSTRAP (F6) — unified NestJS app serves the whole surface', () =>
|
|||||||
else process.env.NODE_ENV = prev;
|
else process.env.NODE_ENV = prev;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('BOOT-008 — large responses are gzip-compressed (Atlas country GeoJSON, #1254)', async () => {
|
||||||
|
// The admin-0 country GeoJSON is multi-MB; without compression it stalls
|
||||||
|
// behind reverse proxies / Cloudflare Tunnel. Proves applyGlobalMiddleware
|
||||||
|
// gzips it on the wire.
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const res = await request(instance)
|
||||||
|
.get('/api/addons/atlas/countries/geo')
|
||||||
|
.set('Accept-Encoding', 'gzip')
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers['content-encoding']).toBe('gzip');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -127,8 +127,8 @@ describe('DayNotesController (parity with the legacy /api/.../days/:dayId/notes
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('400 on an over-long time', () => {
|
it('400 on an over-long time', () => {
|
||||||
expect(thrown(() => new DayNotesController(notesSvc()).create(user, '5', '3', { text: 'ok', time: 'y'.repeat(151) }))).toEqual({
|
expect(thrown(() => new DayNotesController(notesSvc()).create(user, '5', '3', { text: 'ok', time: 'y'.repeat(251) }))).toEqual({
|
||||||
status: 400, body: { error: 'time must be 150 characters or less' },
|
status: 400, body: { error: 'time must be 250 characters or less' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -284,6 +284,9 @@ const admin: TranslationStrings = {
|
|||||||
'admin.update.backupLink': 'الذهاب إلى النسخ الاحتياطي',
|
'admin.update.backupLink': 'الذهاب إلى النسخ الاحتياطي',
|
||||||
'admin.update.howTo': 'كيفية التحديث',
|
'admin.update.howTo': 'كيفية التحديث',
|
||||||
'admin.update.dockerText': 'يعمل TREK الخاص بك في Docker. للتحديث إلى {version}، نفّذ الأوامر التالية على الخادم:',
|
'admin.update.dockerText': 'يعمل TREK الخاص بك في Docker. للتحديث إلى {version}، نفّذ الأوامر التالية على الخادم:',
|
||||||
|
'admin.update.nonDockerText':
|
||||||
|
'لا يعمل TREK هذا في Docker. للتحديث إلى {version}، أعد تشغيل طريقة التثبيت أو التحديث التي استخدمتها — على سبيل المثال، في Proxmox Community Scripts نفّذ التحديث من وحدة تحكم LXC:',
|
||||||
|
'admin.update.wikiLink': 'فتح دليل التحديث',
|
||||||
'admin.update.reloadHint': 'يرجى إعادة تحميل الصفحة بعد بضع ثوانٍ.',
|
'admin.update.reloadHint': 'يرجى إعادة تحميل الصفحة بعد بضع ثوانٍ.',
|
||||||
'admin.tabs.permissions': 'الصلاحيات',
|
'admin.tabs.permissions': 'الصلاحيات',
|
||||||
'admin.notifications.webhook': 'Webhook', // en-fallback
|
'admin.notifications.webhook': 'Webhook', // en-fallback
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
|
|||||||
'dashboard.status.past': 'منتهية',
|
'dashboard.status.past': 'منتهية',
|
||||||
'dashboard.status.daysLeft': 'متبقي {count} يوم',
|
'dashboard.status.daysLeft': 'متبقي {count} يوم',
|
||||||
'dashboard.toast.loadError': 'فشل تحميل الرحلات',
|
'dashboard.toast.loadError': 'فشل تحميل الرحلات',
|
||||||
|
'dashboard.loadErrorBanner': 'تعذّر الوصول إلى الخادم. رحلاتك في أمان — يرجى المحاولة مرة أخرى.',
|
||||||
|
'dashboard.retry': 'إعادة المحاولة',
|
||||||
'dashboard.toast.created': 'تم إنشاء الرحلة بنجاح',
|
'dashboard.toast.created': 'تم إنشاء الرحلة بنجاح',
|
||||||
'dashboard.toast.createError': 'فشل إنشاء الرحلة',
|
'dashboard.toast.createError': 'فشل إنشاء الرحلة',
|
||||||
'dashboard.toast.updated': 'تم تحديث الرحلة',
|
'dashboard.toast.updated': 'تم تحديث الرحلة',
|
||||||
|
|||||||
@@ -241,6 +241,9 @@ const admin: TranslationStrings = {
|
|||||||
'admin.update.backupLink': 'Ir para Backup',
|
'admin.update.backupLink': 'Ir para Backup',
|
||||||
'admin.update.howTo': 'Como atualizar',
|
'admin.update.howTo': 'Como atualizar',
|
||||||
'admin.update.dockerText': 'Sua instância TREK roda no Docker. Para atualizar para {version}, execute no servidor:',
|
'admin.update.dockerText': 'Sua instância TREK roda no Docker. Para atualizar para {version}, execute no servidor:',
|
||||||
|
'admin.update.nonDockerText':
|
||||||
|
'Esta instância do TREK não está rodando no Docker. Para atualizar para {version}, execute novamente o método de instalação ou atualização que você usou — por exemplo, no Proxmox Community Scripts, execute a atualização a partir do console do LXC:',
|
||||||
|
'admin.update.wikiLink': 'Abrir o guia de atualização',
|
||||||
'admin.update.reloadHint': 'Recarregue a página em alguns segundos.',
|
'admin.update.reloadHint': 'Recarregue a página em alguns segundos.',
|
||||||
'admin.tabs.permissions': 'Permissões',
|
'admin.tabs.permissions': 'Permissões',
|
||||||
'admin.tabs.mcpTokens': 'Acesso MCP',
|
'admin.tabs.mcpTokens': 'Acesso MCP',
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
|
|||||||
'dashboard.status.past': 'Passada',
|
'dashboard.status.past': 'Passada',
|
||||||
'dashboard.status.daysLeft': 'Faltam {count} dias',
|
'dashboard.status.daysLeft': 'Faltam {count} dias',
|
||||||
'dashboard.toast.loadError': 'Não foi possível carregar as viagens',
|
'dashboard.toast.loadError': 'Não foi possível carregar as viagens',
|
||||||
|
'dashboard.loadErrorBanner': 'Não foi possível conectar ao servidor. Suas viagens estão seguras — tente novamente.',
|
||||||
|
'dashboard.retry': 'Tentar novamente',
|
||||||
'dashboard.toast.created': 'Viagem criada com sucesso!',
|
'dashboard.toast.created': 'Viagem criada com sucesso!',
|
||||||
'dashboard.toast.createError': 'Não foi possível criar a viagem',
|
'dashboard.toast.createError': 'Não foi possível criar a viagem',
|
||||||
'dashboard.toast.updated': 'Viagem atualizada!',
|
'dashboard.toast.updated': 'Viagem atualizada!',
|
||||||
|
|||||||
@@ -268,6 +268,9 @@ const admin: TranslationStrings = {
|
|||||||
'admin.update.howTo': 'Jak aktualizovat',
|
'admin.update.howTo': 'Jak aktualizovat',
|
||||||
'admin.update.dockerText':
|
'admin.update.dockerText':
|
||||||
'Váš TREK běží v Dockeru. Pro aktualizaci na verzi {version} spusťte na svém serveru tyto příkazy:',
|
'Váš TREK běží v Dockeru. Pro aktualizaci na verzi {version} spusťte na svém serveru tyto příkazy:',
|
||||||
|
'admin.update.nonDockerText':
|
||||||
|
'Tato instance TREK neběží v Dockeru. Pro aktualizaci na verzi {version} znovu spusťte instalační nebo aktualizační metodu, kterou jste použili — například u Proxmox Community Scripts spusťte aktualizaci z konzole LXC:',
|
||||||
|
'admin.update.wikiLink': 'Otevřít průvodce aktualizací',
|
||||||
'admin.update.reloadHint': 'Prosím obnovte stránku za několik sekund.',
|
'admin.update.reloadHint': 'Prosím obnovte stránku za několik sekund.',
|
||||||
'admin.tabs.permissions': 'Oprávnění',
|
'admin.tabs.permissions': 'Oprávnění',
|
||||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
|
|||||||
'dashboard.status.past': 'Proběhlé',
|
'dashboard.status.past': 'Proběhlé',
|
||||||
'dashboard.status.daysLeft': 'zbývá {count} dní',
|
'dashboard.status.daysLeft': 'zbývá {count} dní',
|
||||||
'dashboard.toast.loadError': 'Nepodařilo se načíst cesty',
|
'dashboard.toast.loadError': 'Nepodařilo se načíst cesty',
|
||||||
|
'dashboard.loadErrorBanner': 'Server nebyl dostupný. Vaše cesty jsou v bezpečí — zkuste to prosím znovu.',
|
||||||
|
'dashboard.retry': 'Zkusit znovu',
|
||||||
'dashboard.toast.created': 'Cesta byla úspěšně vytvořena!',
|
'dashboard.toast.created': 'Cesta byla úspěšně vytvořena!',
|
||||||
'dashboard.toast.createError': 'Nepodařilo se vytvořit cestu',
|
'dashboard.toast.createError': 'Nepodařilo se vytvořit cestu',
|
||||||
'dashboard.toast.updated': 'Cesta byla aktualizována!',
|
'dashboard.toast.updated': 'Cesta byla aktualizována!',
|
||||||
|
|||||||
@@ -272,6 +272,9 @@ const admin: TranslationStrings = {
|
|||||||
'admin.update.howTo': 'Update-Anleitung',
|
'admin.update.howTo': 'Update-Anleitung',
|
||||||
'admin.update.dockerText':
|
'admin.update.dockerText':
|
||||||
'Deine TREK-Instanz läuft in Docker. Um auf {version} zu aktualisieren, führe folgende Befehle auf deinem Server aus:',
|
'Deine TREK-Instanz läuft in Docker. Um auf {version} zu aktualisieren, führe folgende Befehle auf deinem Server aus:',
|
||||||
|
'admin.update.nonDockerText':
|
||||||
|
'Diese TREK-Instanz läuft nicht in Docker. Um auf {version} zu aktualisieren, führe die Installations- oder Update-Methode erneut aus, die du verwendet hast — bei Proxmox Community Scripts startest du das Update zum Beispiel über die LXC-Konsole:',
|
||||||
|
'admin.update.wikiLink': 'Update-Anleitung öffnen',
|
||||||
'admin.update.reloadHint': 'Bitte lade die Seite in wenigen Sekunden neu.',
|
'admin.update.reloadHint': 'Bitte lade die Seite in wenigen Sekunden neu.',
|
||||||
'admin.tabs.permissions': 'Berechtigungen',
|
'admin.tabs.permissions': 'Berechtigungen',
|
||||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ const dashboard: TranslationStrings = {
|
|||||||
'dashboard.status.past': 'Vergangen',
|
'dashboard.status.past': 'Vergangen',
|
||||||
'dashboard.status.daysLeft': 'Noch {count} Tage',
|
'dashboard.status.daysLeft': 'Noch {count} Tage',
|
||||||
'dashboard.toast.loadError': 'Fehler beim Laden der Reisen',
|
'dashboard.toast.loadError': 'Fehler beim Laden der Reisen',
|
||||||
|
'dashboard.loadErrorBanner': 'Server nicht erreichbar. Deine Reisen sind sicher — bitte versuche es erneut.',
|
||||||
|
'dashboard.retry': 'Erneut versuchen',
|
||||||
'dashboard.toast.created': 'Reise erfolgreich erstellt!',
|
'dashboard.toast.created': 'Reise erfolgreich erstellt!',
|
||||||
'dashboard.toast.createError': 'Fehler beim Erstellen',
|
'dashboard.toast.createError': 'Fehler beim Erstellen',
|
||||||
'dashboard.toast.updated': 'Reise aktualisiert!',
|
'dashboard.toast.updated': 'Reise aktualisiert!',
|
||||||
|
|||||||
@@ -322,6 +322,9 @@ const admin: TranslationStrings = {
|
|||||||
'admin.update.howTo': 'How to Update',
|
'admin.update.howTo': 'How to Update',
|
||||||
'admin.update.dockerText':
|
'admin.update.dockerText':
|
||||||
'Your TREK instance runs in Docker. To update to {version}, run the following commands on your server:',
|
'Your TREK instance runs in Docker. To update to {version}, run the following commands on your server:',
|
||||||
|
'admin.update.nonDockerText':
|
||||||
|
'This TREK instance is not running in Docker. To update to {version}, re-run the install or update method you used — for example, on Proxmox Community Scripts run the update from the LXC console:',
|
||||||
|
'admin.update.wikiLink': 'Open the update guide',
|
||||||
'admin.update.reloadHint': 'Please reload the page in a few seconds.',
|
'admin.update.reloadHint': 'Please reload the page in a few seconds.',
|
||||||
'admin.tabs.permissions': 'Permissions',
|
'admin.tabs.permissions': 'Permissions',
|
||||||
'admin.addons.catalog.journey.name': 'Journey',
|
'admin.addons.catalog.journey.name': 'Journey',
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
|
|||||||
'dashboard.status.past': 'Past',
|
'dashboard.status.past': 'Past',
|
||||||
'dashboard.status.daysLeft': '{count} days left',
|
'dashboard.status.daysLeft': '{count} days left',
|
||||||
'dashboard.toast.loadError': 'Failed to load trips',
|
'dashboard.toast.loadError': 'Failed to load trips',
|
||||||
|
'dashboard.loadErrorBanner': "Couldn't reach the server. Your trips are safe — please try again.",
|
||||||
|
'dashboard.retry': 'Retry',
|
||||||
'dashboard.toast.created': 'Trip created successfully!',
|
'dashboard.toast.created': 'Trip created successfully!',
|
||||||
'dashboard.toast.createError': 'Failed to create trip',
|
'dashboard.toast.createError': 'Failed to create trip',
|
||||||
'dashboard.toast.updated': 'Trip updated!',
|
'dashboard.toast.updated': 'Trip updated!',
|
||||||
|
|||||||
@@ -256,6 +256,9 @@ const admin: TranslationStrings = {
|
|||||||
'admin.update.howTo': 'Cómo actualizar',
|
'admin.update.howTo': 'Cómo actualizar',
|
||||||
'admin.update.dockerText':
|
'admin.update.dockerText':
|
||||||
'Tu instancia de TREK se ejecuta en Docker. Para actualizar a {version}, ejecuta los siguientes comandos en tu servidor:',
|
'Tu instancia de TREK se ejecuta en Docker. Para actualizar a {version}, ejecuta los siguientes comandos en tu servidor:',
|
||||||
|
'admin.update.nonDockerText':
|
||||||
|
'Esta instancia de TREK no se ejecuta en Docker. Para actualizar a {version}, vuelve a ejecutar el método de instalación o actualización que utilizaste; por ejemplo, en Proxmox Community Scripts ejecuta la actualización desde la consola LXC:',
|
||||||
|
'admin.update.wikiLink': 'Abrir la guía de actualización',
|
||||||
'admin.update.reloadHint': 'Recarga la página en unos segundos.',
|
'admin.update.reloadHint': 'Recarga la página en unos segundos.',
|
||||||
'admin.addons.catalog.memories.name': 'Fotos (Immich)',
|
'admin.addons.catalog.memories.name': 'Fotos (Immich)',
|
||||||
'admin.addons.catalog.memories.description': 'Comparte fotos de viaje a través de tu instancia de Immich',
|
'admin.addons.catalog.memories.description': 'Comparte fotos de viaje a través de tu instancia de Immich',
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
|
|||||||
'dashboard.status.past': 'Pasado',
|
'dashboard.status.past': 'Pasado',
|
||||||
'dashboard.status.daysLeft': 'Quedan {count} días',
|
'dashboard.status.daysLeft': 'Quedan {count} días',
|
||||||
'dashboard.toast.loadError': 'No se pudieron cargar los viajes',
|
'dashboard.toast.loadError': 'No se pudieron cargar los viajes',
|
||||||
|
'dashboard.loadErrorBanner': 'No se pudo conectar con el servidor. Tus viajes están a salvo: inténtalo de nuevo.',
|
||||||
|
'dashboard.retry': 'Reintentar',
|
||||||
'dashboard.toast.created': '¡Viaje creado correctamente!',
|
'dashboard.toast.created': '¡Viaje creado correctamente!',
|
||||||
'dashboard.toast.createError': 'No se pudo crear el viaje',
|
'dashboard.toast.createError': 'No se pudo crear el viaje',
|
||||||
'dashboard.toast.updated': '¡Viaje actualizado!',
|
'dashboard.toast.updated': '¡Viaje actualizado!',
|
||||||
|
|||||||
@@ -274,6 +274,9 @@ const admin: TranslationStrings = {
|
|||||||
'admin.update.howTo': 'Comment mettre à jour',
|
'admin.update.howTo': 'Comment mettre à jour',
|
||||||
'admin.update.dockerText':
|
'admin.update.dockerText':
|
||||||
'Votre instance TREK fonctionne dans Docker. Pour mettre à jour vers {version}, exécutez les commandes suivantes sur votre serveur :',
|
'Votre instance TREK fonctionne dans Docker. Pour mettre à jour vers {version}, exécutez les commandes suivantes sur votre serveur :',
|
||||||
|
'admin.update.nonDockerText':
|
||||||
|
"Cette instance TREK ne fonctionne pas dans Docker. Pour mettre à jour vers {version}, relancez la méthode d'installation ou de mise à jour que vous avez utilisée — par exemple, sur Proxmox Community Scripts, lancez la mise à jour depuis la console LXC :",
|
||||||
|
'admin.update.wikiLink': 'Ouvrir le guide de mise à jour',
|
||||||
'admin.update.reloadHint': 'Veuillez recharger la page dans quelques secondes.',
|
'admin.update.reloadHint': 'Veuillez recharger la page dans quelques secondes.',
|
||||||
'admin.tabs.permissions': 'Permissions',
|
'admin.tabs.permissions': 'Permissions',
|
||||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ const dashboard: TranslationStrings = {
|
|||||||
'dashboard.status.past': 'Passé',
|
'dashboard.status.past': 'Passé',
|
||||||
'dashboard.status.daysLeft': '{count} jours restants',
|
'dashboard.status.daysLeft': '{count} jours restants',
|
||||||
'dashboard.toast.loadError': 'Impossible de charger les voyages',
|
'dashboard.toast.loadError': 'Impossible de charger les voyages',
|
||||||
|
'dashboard.loadErrorBanner':
|
||||||
|
"Impossible de joindre le serveur. Vos voyages sont en sécurité — veuillez réessayer.",
|
||||||
|
'dashboard.retry': 'Réessayer',
|
||||||
'dashboard.toast.created': 'Voyage créé avec succès !',
|
'dashboard.toast.created': 'Voyage créé avec succès !',
|
||||||
'dashboard.toast.createError': 'Impossible de créer le voyage',
|
'dashboard.toast.createError': 'Impossible de créer le voyage',
|
||||||
'dashboard.toast.updated': 'Voyage mis à jour !',
|
'dashboard.toast.updated': 'Voyage mis à jour !',
|
||||||
|
|||||||
@@ -323,6 +323,9 @@ const admin: TranslationStrings = {
|
|||||||
'admin.update.howTo': 'Πώς να Ενημερώσετε',
|
'admin.update.howTo': 'Πώς να Ενημερώσετε',
|
||||||
'admin.update.dockerText':
|
'admin.update.dockerText':
|
||||||
'Η εγκατάστασή σας TREK εκτελείται σε Docker. Για να ενημερωθείτε στο {version}, εκτελέστε τις ακόλουθες εντολές στον server σας:',
|
'Η εγκατάστασή σας TREK εκτελείται σε Docker. Για να ενημερωθείτε στο {version}, εκτελέστε τις ακόλουθες εντολές στον server σας:',
|
||||||
|
'admin.update.nonDockerText':
|
||||||
|
'Αυτή η εγκατάσταση TREK δεν εκτελείται σε Docker. Για να ενημερωθείτε στο {version}, εκτελέστε ξανά τη μέθοδο εγκατάστασης ή ενημέρωσης που χρησιμοποιήσατε — για παράδειγμα, στα Proxmox Community Scripts εκτελέστε την ενημέρωση από την κονσόλα LXC:',
|
||||||
|
'admin.update.wikiLink': 'Άνοιγμα του οδηγού ενημέρωσης',
|
||||||
'admin.update.reloadHint': 'Παρακαλώ ανανεώστε τη σελίδα σε λίγα δευτερόλεπτα.',
|
'admin.update.reloadHint': 'Παρακαλώ ανανεώστε τη σελίδα σε λίγα δευτερόλεπτα.',
|
||||||
'admin.tabs.permissions': 'Δικαιώματα',
|
'admin.tabs.permissions': 'Δικαιώματα',
|
||||||
'admin.addons.catalog.journey.name': 'Ταξίδι',
|
'admin.addons.catalog.journey.name': 'Ταξίδι',
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ const dashboard: TranslationStrings = {
|
|||||||
'dashboard.status.past': 'Παρελθόν',
|
'dashboard.status.past': 'Παρελθόν',
|
||||||
'dashboard.status.daysLeft': '{count} μέρες έμειναν',
|
'dashboard.status.daysLeft': '{count} μέρες έμειναν',
|
||||||
'dashboard.toast.loadError': 'Αποτυχία φόρτωσης ταξιδιών',
|
'dashboard.toast.loadError': 'Αποτυχία φόρτωσης ταξιδιών',
|
||||||
|
'dashboard.loadErrorBanner':
|
||||||
|
'Δεν ήταν δυνατή η σύνδεση με τον διακομιστή. Τα ταξίδια σας είναι ασφαλή — δοκιμάστε ξανά.',
|
||||||
|
'dashboard.retry': 'Δοκιμάστε ξανά',
|
||||||
'dashboard.toast.created': 'Ταξίδι δημιουργήθηκε επιτυχώς!',
|
'dashboard.toast.created': 'Ταξίδι δημιουργήθηκε επιτυχώς!',
|
||||||
'dashboard.toast.createError': 'Αποτυχία δημιουργίας ταξιδιού',
|
'dashboard.toast.createError': 'Αποτυχία δημιουργίας ταξιδιού',
|
||||||
'dashboard.toast.updated': 'Ταξίδι ενημερώθηκε!',
|
'dashboard.toast.updated': 'Ταξίδι ενημερώθηκε!',
|
||||||
|
|||||||
@@ -273,6 +273,9 @@ const admin: TranslationStrings = {
|
|||||||
'admin.update.howTo': 'Frissítési útmutató',
|
'admin.update.howTo': 'Frissítési útmutató',
|
||||||
'admin.update.dockerText':
|
'admin.update.dockerText':
|
||||||
'A TREK példányod Dockerben fut. A {version} verzióra frissítéshez futtasd a következő parancsokat a szervereden:',
|
'A TREK példányod Dockerben fut. A {version} verzióra frissítéshez futtasd a következő parancsokat a szervereden:',
|
||||||
|
'admin.update.nonDockerText':
|
||||||
|
'Ez a TREK példány nem Dockerben fut. A {version} verzióra frissítéshez futtasd újra a telepítési vagy frissítési módszert, amelyet használtál — például Proxmox Community Scripts esetén futtasd a frissítést az LXC konzolból:',
|
||||||
|
'admin.update.wikiLink': 'Frissítési útmutató megnyitása',
|
||||||
'admin.update.reloadHint': 'Kérjük, töltsd újra az oldalt néhány másodperc múlva.',
|
'admin.update.reloadHint': 'Kérjük, töltsd újra az oldalt néhány másodperc múlva.',
|
||||||
'admin.tabs.permissions': 'Jogosultságok',
|
'admin.tabs.permissions': 'Jogosultságok',
|
||||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ const dashboard: TranslationStrings = {
|
|||||||
'dashboard.status.past': 'Múlt',
|
'dashboard.status.past': 'Múlt',
|
||||||
'dashboard.status.daysLeft': 'Még {count} nap',
|
'dashboard.status.daysLeft': 'Még {count} nap',
|
||||||
'dashboard.toast.loadError': 'Nem sikerült betölteni az utazásokat',
|
'dashboard.toast.loadError': 'Nem sikerült betölteni az utazásokat',
|
||||||
|
'dashboard.loadErrorBanner': 'Nem sikerült elérni a kiszolgálót. Az utazásaid biztonságban vannak — kérlek, próbáld újra.',
|
||||||
|
'dashboard.retry': 'Újra',
|
||||||
'dashboard.toast.created': 'Utazás sikeresen létrehozva!',
|
'dashboard.toast.created': 'Utazás sikeresen létrehozva!',
|
||||||
'dashboard.toast.createError': 'Nem sikerült létrehozni',
|
'dashboard.toast.createError': 'Nem sikerült létrehozni',
|
||||||
'dashboard.toast.updated': 'Utazás frissítve!',
|
'dashboard.toast.updated': 'Utazás frissítve!',
|
||||||
|
|||||||
@@ -313,6 +313,9 @@ const admin: TranslationStrings = {
|
|||||||
'admin.update.howTo': 'Cara Memperbarui',
|
'admin.update.howTo': 'Cara Memperbarui',
|
||||||
'admin.update.dockerText':
|
'admin.update.dockerText':
|
||||||
'Instans TREK kamu berjalan di Docker. Untuk memperbarui ke {version}, jalankan perintah berikut di servermu:',
|
'Instans TREK kamu berjalan di Docker. Untuk memperbarui ke {version}, jalankan perintah berikut di servermu:',
|
||||||
|
'admin.update.nonDockerText':
|
||||||
|
'Instans TREK ini tidak berjalan di Docker. Untuk memperbarui ke {version}, jalankan ulang metode instalasi atau pembaruan yang kamu gunakan — misalnya, pada Proxmox Community Scripts jalankan pembaruan dari konsol LXC:',
|
||||||
|
'admin.update.wikiLink': 'Buka panduan pembaruan',
|
||||||
'admin.update.reloadHint': 'Muat ulang halaman dalam beberapa detik.',
|
'admin.update.reloadHint': 'Muat ulang halaman dalam beberapa detik.',
|
||||||
'admin.tabs.permissions': 'Izin',
|
'admin.tabs.permissions': 'Izin',
|
||||||
'admin.addons.catalog.journey.name': 'Journey',
|
'admin.addons.catalog.journey.name': 'Journey',
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
|
|||||||
'dashboard.status.past': 'Sudah lewat',
|
'dashboard.status.past': 'Sudah lewat',
|
||||||
'dashboard.status.daysLeft': '{count} hari lagi',
|
'dashboard.status.daysLeft': '{count} hari lagi',
|
||||||
'dashboard.toast.loadError': 'Gagal memuat perjalanan',
|
'dashboard.toast.loadError': 'Gagal memuat perjalanan',
|
||||||
|
'dashboard.loadErrorBanner': 'Tidak dapat terhubung ke server. Perjalananmu aman — silakan coba lagi.',
|
||||||
|
'dashboard.retry': 'Coba lagi',
|
||||||
'dashboard.toast.created': 'Perjalanan berhasil dibuat!',
|
'dashboard.toast.created': 'Perjalanan berhasil dibuat!',
|
||||||
'dashboard.toast.createError': 'Gagal membuat perjalanan',
|
'dashboard.toast.createError': 'Gagal membuat perjalanan',
|
||||||
'dashboard.toast.updated': 'Perjalanan diperbarui!',
|
'dashboard.toast.updated': 'Perjalanan diperbarui!',
|
||||||
|
|||||||
@@ -272,6 +272,9 @@ const admin: TranslationStrings = {
|
|||||||
'admin.update.howTo': 'Come aggiornare',
|
'admin.update.howTo': 'Come aggiornare',
|
||||||
'admin.update.dockerText':
|
'admin.update.dockerText':
|
||||||
'La tua istanza TREK è in esecuzione in Docker. Per aggiornare alla versione {version}, esegui i seguenti comandi sul tuo server:',
|
'La tua istanza TREK è in esecuzione in Docker. Per aggiornare alla versione {version}, esegui i seguenti comandi sul tuo server:',
|
||||||
|
'admin.update.nonDockerText':
|
||||||
|
"Questa istanza TREK non è in esecuzione in Docker. Per aggiornare alla versione {version}, riesegui il metodo di installazione o aggiornamento che hai usato — ad esempio, su Proxmox Community Scripts esegui l'aggiornamento dalla console LXC:",
|
||||||
|
'admin.update.wikiLink': "Apri la guida all'aggiornamento",
|
||||||
'admin.update.reloadHint': 'Ricarica la pagina tra qualche secondo.',
|
'admin.update.reloadHint': 'Ricarica la pagina tra qualche secondo.',
|
||||||
'admin.tabs.permissions': 'Permessi',
|
'admin.tabs.permissions': 'Permessi',
|
||||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ const dashboard: TranslationStrings = {
|
|||||||
'dashboard.status.past': 'Passato',
|
'dashboard.status.past': 'Passato',
|
||||||
'dashboard.status.daysLeft': '-{count} giorni',
|
'dashboard.status.daysLeft': '-{count} giorni',
|
||||||
'dashboard.toast.loadError': 'Impossibile caricare i viaggi',
|
'dashboard.toast.loadError': 'Impossibile caricare i viaggi',
|
||||||
|
'dashboard.loadErrorBanner':
|
||||||
|
"Impossibile raggiungere il server. I tuoi viaggi sono al sicuro — riprova.",
|
||||||
|
'dashboard.retry': 'Riprova',
|
||||||
'dashboard.toast.created': 'Viaggio creato con successo!',
|
'dashboard.toast.created': 'Viaggio creato con successo!',
|
||||||
'dashboard.toast.createError': 'Impossibile creare il viaggio',
|
'dashboard.toast.createError': 'Impossibile creare il viaggio',
|
||||||
'dashboard.toast.updated': 'Viaggio aggiornato!',
|
'dashboard.toast.updated': 'Viaggio aggiornato!',
|
||||||
|
|||||||
@@ -301,6 +301,9 @@ const admin: TranslationStrings = {
|
|||||||
'admin.update.howTo': '更新方法',
|
'admin.update.howTo': '更新方法',
|
||||||
'admin.update.dockerText':
|
'admin.update.dockerText':
|
||||||
'TREKはDockerで実行されています。{version} に更新するには、サーバーで次のコマンドを実行してください:',
|
'TREKはDockerで実行されています。{version} に更新するには、サーバーで次のコマンドを実行してください:',
|
||||||
|
'admin.update.nonDockerText':
|
||||||
|
'このTREKインスタンスはDockerで実行されていません。{version} に更新するには、使用したインストールまたは更新方法をもう一度実行してください。たとえばProxmox Community Scriptsの場合は、LXCコンソールから更新を実行します:',
|
||||||
|
'admin.update.wikiLink': '更新ガイドを開く',
|
||||||
'admin.update.reloadHint': '数秒後にページを再読み込みしてください。',
|
'admin.update.reloadHint': '数秒後にページを再読み込みしてください。',
|
||||||
'admin.tabs.permissions': '権限',
|
'admin.tabs.permissions': '権限',
|
||||||
'admin.addons.catalog.journey.name': '日記',
|
'admin.addons.catalog.journey.name': '日記',
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
|
|||||||
'dashboard.status.past': '過去',
|
'dashboard.status.past': '過去',
|
||||||
'dashboard.status.daysLeft': '残り{count}日',
|
'dashboard.status.daysLeft': '残り{count}日',
|
||||||
'dashboard.toast.loadError': '旅行の読み込みに失敗しました',
|
'dashboard.toast.loadError': '旅行の読み込みに失敗しました',
|
||||||
|
'dashboard.loadErrorBanner': 'サーバーに接続できませんでした。旅行のデータは安全に保存されています — もう一度お試しください。',
|
||||||
|
'dashboard.retry': '再試行',
|
||||||
'dashboard.toast.created': '旅行を作成しました!',
|
'dashboard.toast.created': '旅行を作成しました!',
|
||||||
'dashboard.toast.createError': '旅行の作成に失敗しました',
|
'dashboard.toast.createError': '旅行の作成に失敗しました',
|
||||||
'dashboard.toast.updated': '旅行を更新しました!',
|
'dashboard.toast.updated': '旅行を更新しました!',
|
||||||
|
|||||||
@@ -305,6 +305,9 @@ const admin: TranslationStrings = {
|
|||||||
'admin.update.howTo': '업데이트 방법',
|
'admin.update.howTo': '업데이트 방법',
|
||||||
'admin.update.dockerText':
|
'admin.update.dockerText':
|
||||||
'TREK 인스턴스가 Docker에서 실행 중입니다. {version}으로 업데이트하려면 서버에서 다음 명령을 실행하세요:',
|
'TREK 인스턴스가 Docker에서 실행 중입니다. {version}으로 업데이트하려면 서버에서 다음 명령을 실행하세요:',
|
||||||
|
'admin.update.nonDockerText':
|
||||||
|
'이 TREK 인스턴스는 Docker에서 실행되고 있지 않습니다. {version}으로 업데이트하려면 사용했던 설치 또는 업데이트 방법을 다시 실행하세요 — 예를 들어 Proxmox Community Scripts에서는 LXC 콘솔에서 업데이트를 실행하세요:',
|
||||||
|
'admin.update.wikiLink': '업데이트 가이드 열기',
|
||||||
'admin.update.reloadHint': '잠시 후 페이지를 새로 고침하세요.',
|
'admin.update.reloadHint': '잠시 후 페이지를 새로 고침하세요.',
|
||||||
'admin.tabs.permissions': '권한',
|
'admin.tabs.permissions': '권한',
|
||||||
'admin.addons.catalog.journey.name': 'Journey',
|
'admin.addons.catalog.journey.name': 'Journey',
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
|
|||||||
'dashboard.status.past': '지난 여행',
|
'dashboard.status.past': '지난 여행',
|
||||||
'dashboard.status.daysLeft': '{count}일 남음',
|
'dashboard.status.daysLeft': '{count}일 남음',
|
||||||
'dashboard.toast.loadError': '여행 불러오기 실패',
|
'dashboard.toast.loadError': '여행 불러오기 실패',
|
||||||
|
'dashboard.loadErrorBanner': '서버에 연결할 수 없습니다. 여행 정보는 안전하게 보관되어 있으니 잠시 후 다시 시도해 주세요.',
|
||||||
|
'dashboard.retry': '다시 시도',
|
||||||
'dashboard.toast.created': '여행이 생성되었습니다!',
|
'dashboard.toast.created': '여행이 생성되었습니다!',
|
||||||
'dashboard.toast.createError': '여행 생성 실패',
|
'dashboard.toast.createError': '여행 생성 실패',
|
||||||
'dashboard.toast.updated': '여행이 업데이트되었습니다!',
|
'dashboard.toast.updated': '여행이 업데이트되었습니다!',
|
||||||
|
|||||||
@@ -272,6 +272,9 @@ const admin: TranslationStrings = {
|
|||||||
'admin.update.howTo': 'Hoe bij te werken',
|
'admin.update.howTo': 'Hoe bij te werken',
|
||||||
'admin.update.dockerText':
|
'admin.update.dockerText':
|
||||||
"Je TREK-instantie draait in Docker. Om bij te werken naar {version}, voer de volgende commando's uit op je server:",
|
"Je TREK-instantie draait in Docker. Om bij te werken naar {version}, voer de volgende commando's uit op je server:",
|
||||||
|
'admin.update.nonDockerText':
|
||||||
|
'Deze TREK-instantie draait niet in Docker. Om bij te werken naar {version}, voer de installatie- of updatemethode die je hebt gebruikt opnieuw uit — bij Proxmox Community Scripts voer je de update bijvoorbeeld uit vanuit de LXC-console:',
|
||||||
|
'admin.update.wikiLink': 'Open de updatehandleiding',
|
||||||
'admin.update.reloadHint': 'Herlaad de pagina over een paar seconden.',
|
'admin.update.reloadHint': 'Herlaad de pagina over een paar seconden.',
|
||||||
'admin.tabs.permissions': 'Rechten',
|
'admin.tabs.permissions': 'Rechten',
|
||||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
|
|||||||
'dashboard.status.past': 'Afgelopen',
|
'dashboard.status.past': 'Afgelopen',
|
||||||
'dashboard.status.daysLeft': 'nog {count} dagen',
|
'dashboard.status.daysLeft': 'nog {count} dagen',
|
||||||
'dashboard.toast.loadError': 'Reizen laden mislukt',
|
'dashboard.toast.loadError': 'Reizen laden mislukt',
|
||||||
|
'dashboard.loadErrorBanner': 'De server is niet bereikbaar. Je reizen zijn veilig — probeer het opnieuw.',
|
||||||
|
'dashboard.retry': 'Opnieuw proberen',
|
||||||
'dashboard.toast.created': 'Reis aangemaakt!',
|
'dashboard.toast.created': 'Reis aangemaakt!',
|
||||||
'dashboard.toast.createError': 'Reis aanmaken mislukt',
|
'dashboard.toast.createError': 'Reis aanmaken mislukt',
|
||||||
'dashboard.toast.updated': 'Reis bijgewerkt!',
|
'dashboard.toast.updated': 'Reis bijgewerkt!',
|
||||||
|
|||||||
@@ -265,6 +265,9 @@ const admin: TranslationStrings = {
|
|||||||
'admin.update.howTo': 'Jak zaktualizować',
|
'admin.update.howTo': 'Jak zaktualizować',
|
||||||
'admin.update.dockerText':
|
'admin.update.dockerText':
|
||||||
'Twoja instancja TREK działa w Dockerze. Aby zaktualizować do {version}, uruchom następujące polecenia na swoim serwerze:',
|
'Twoja instancja TREK działa w Dockerze. Aby zaktualizować do {version}, uruchom następujące polecenia na swoim serwerze:',
|
||||||
|
'admin.update.nonDockerText':
|
||||||
|
'Ta instancja TREK nie działa w Dockerze. Aby zaktualizować do {version}, uruchom ponownie metodę instalacji lub aktualizacji, której użyłeś — na przykład w Proxmox Community Scripts uruchom aktualizację z konsoli LXC:',
|
||||||
|
'admin.update.wikiLink': 'Otwórz przewodnik aktualizacji',
|
||||||
'admin.update.reloadHint': 'Proszę odświeżyć stronę za kilka sekund.',
|
'admin.update.reloadHint': 'Proszę odświeżyć stronę za kilka sekund.',
|
||||||
'admin.notifications.title': 'Powiadomienia',
|
'admin.notifications.title': 'Powiadomienia',
|
||||||
'admin.notifications.hint': 'Wybierz jeden kanał powiadomień.',
|
'admin.notifications.hint': 'Wybierz jeden kanał powiadomień.',
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ const dashboard: TranslationStrings = {
|
|||||||
'dashboard.status.past': 'Zakończona',
|
'dashboard.status.past': 'Zakończona',
|
||||||
'dashboard.status.daysLeft': '{count} dni do końca',
|
'dashboard.status.daysLeft': '{count} dni do końca',
|
||||||
'dashboard.toast.loadError': 'Nie udało się załadować podróży',
|
'dashboard.toast.loadError': 'Nie udało się załadować podróży',
|
||||||
|
'dashboard.loadErrorBanner': 'Nie udało się połączyć z serwerem. Twoje podróże są bezpieczne — spróbuj ponownie.',
|
||||||
|
'dashboard.retry': 'Spróbuj ponownie',
|
||||||
'dashboard.toast.created': 'Podróż została utworzona pomyślnie!',
|
'dashboard.toast.created': 'Podróż została utworzona pomyślnie!',
|
||||||
'dashboard.toast.createError': 'Nie udało się utworzyć podróży',
|
'dashboard.toast.createError': 'Nie udało się utworzyć podróży',
|
||||||
'dashboard.toast.updated': 'Podróż została zaktualizowana!',
|
'dashboard.toast.updated': 'Podróż została zaktualizowana!',
|
||||||
|
|||||||
@@ -271,6 +271,9 @@ const admin: TranslationStrings = {
|
|||||||
'admin.update.howTo': 'Как обновить',
|
'admin.update.howTo': 'Как обновить',
|
||||||
'admin.update.dockerText':
|
'admin.update.dockerText':
|
||||||
'Ваш экземпляр TREK работает в Docker. Для обновления до {version} выполните следующие команды на сервере:',
|
'Ваш экземпляр TREK работает в Docker. Для обновления до {version} выполните следующие команды на сервере:',
|
||||||
|
'admin.update.nonDockerText':
|
||||||
|
'Этот экземпляр TREK работает не в Docker. Чтобы обновиться до {version}, повторно запустите способ установки или обновления, который вы использовали, — например, в Proxmox Community Scripts выполните обновление из консоли LXC:',
|
||||||
|
'admin.update.wikiLink': 'Открыть руководство по обновлению',
|
||||||
'admin.update.reloadHint': 'Перезагрузите страницу через несколько секунд.',
|
'admin.update.reloadHint': 'Перезагрузите страницу через несколько секунд.',
|
||||||
'admin.tabs.permissions': 'Разрешения',
|
'admin.tabs.permissions': 'Разрешения',
|
||||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
|
|||||||
'dashboard.status.past': 'Прошло',
|
'dashboard.status.past': 'Прошло',
|
||||||
'dashboard.status.daysLeft': 'осталось {count} дн.',
|
'dashboard.status.daysLeft': 'осталось {count} дн.',
|
||||||
'dashboard.toast.loadError': 'Не удалось загрузить поездки',
|
'dashboard.toast.loadError': 'Не удалось загрузить поездки',
|
||||||
|
'dashboard.loadErrorBanner': 'Не удалось подключиться к серверу. Ваши поездки в безопасности — попробуйте снова.',
|
||||||
|
'dashboard.retry': 'Повторить',
|
||||||
'dashboard.toast.created': 'Поездка создана!',
|
'dashboard.toast.created': 'Поездка создана!',
|
||||||
'dashboard.toast.createError': 'Не удалось создать поездку',
|
'dashboard.toast.createError': 'Не удалось создать поездку',
|
||||||
'dashboard.toast.updated': 'Поездка обновлена!',
|
'dashboard.toast.updated': 'Поездка обновлена!',
|
||||||
|
|||||||
@@ -317,6 +317,9 @@ const admin: TranslationStrings = {
|
|||||||
'admin.update.howTo': 'Nasıl Güncellenir?',
|
'admin.update.howTo': 'Nasıl Güncellenir?',
|
||||||
'admin.update.dockerText':
|
'admin.update.dockerText':
|
||||||
"TREK örneğiniz Docker'da çalışır. {version} sürümüne güncellemek için sunucunuzda aşağıdaki komutları çalıştırın:",
|
"TREK örneğiniz Docker'da çalışır. {version} sürümüne güncellemek için sunucunuzda aşağıdaki komutları çalıştırın:",
|
||||||
|
'admin.update.nonDockerText':
|
||||||
|
'Bu TREK örneği Docker üzerinde çalışmıyor. {version} sürümüne güncellemek için kullandığınız kurulum veya güncelleme yöntemini yeniden çalıştırın — örneğin Proxmox Community Scripts kullanıyorsanız güncellemeyi LXC konsolundan çalıştırın:',
|
||||||
|
'admin.update.wikiLink': 'Güncelleme kılavuzunu aç',
|
||||||
'admin.update.reloadHint': 'Lütfen birkaç saniye içinde sayfayı yeniden yükleyin.',
|
'admin.update.reloadHint': 'Lütfen birkaç saniye içinde sayfayı yeniden yükleyin.',
|
||||||
'admin.tabs.permissions': 'İzinler',
|
'admin.tabs.permissions': 'İzinler',
|
||||||
'admin.addons.catalog.journey.name': 'Seyahat',
|
'admin.addons.catalog.journey.name': 'Seyahat',
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
|
|||||||
'dashboard.status.past': 'Geçmiş',
|
'dashboard.status.past': 'Geçmiş',
|
||||||
'dashboard.status.daysLeft': '{count} gün kaldı',
|
'dashboard.status.daysLeft': '{count} gün kaldı',
|
||||||
'dashboard.toast.loadError': 'Seyahatler yüklenemedi',
|
'dashboard.toast.loadError': 'Seyahatler yüklenemedi',
|
||||||
|
'dashboard.loadErrorBanner': 'Sunucuya ulaşılamadı. Seyahatleriniz güvende — lütfen tekrar deneyin.',
|
||||||
|
'dashboard.retry': 'Tekrar dene',
|
||||||
'dashboard.toast.created': 'Seyahat oluşturuldu!',
|
'dashboard.toast.created': 'Seyahat oluşturuldu!',
|
||||||
'dashboard.toast.createError': 'Seyahat oluşturulamadı',
|
'dashboard.toast.createError': 'Seyahat oluşturulamadı',
|
||||||
'dashboard.toast.updated': 'Seyahat güncellendi!',
|
'dashboard.toast.updated': 'Seyahat güncellendi!',
|
||||||
|
|||||||
@@ -270,6 +270,9 @@ const admin: TranslationStrings = {
|
|||||||
'admin.update.howTo': 'Як оновити',
|
'admin.update.howTo': 'Як оновити',
|
||||||
'admin.update.dockerText':
|
'admin.update.dockerText':
|
||||||
'Ваш екземпляр TREK працює в Docker. Для оновлення до {version} виконайте ці команди на сервері:',
|
'Ваш екземпляр TREK працює в Docker. Для оновлення до {version} виконайте ці команди на сервері:',
|
||||||
|
'admin.update.nonDockerText':
|
||||||
|
'Цей екземпляр TREK не працює в Docker. Щоб оновити до {version}, повторно запустіть метод встановлення або оновлення, який ви використовували, — наприклад, у Proxmox Community Scripts запустіть оновлення з консолі LXC:',
|
||||||
|
'admin.update.wikiLink': 'Відкрити інструкцію з оновлення',
|
||||||
'admin.update.reloadHint': 'Перезавантажте сторінку через кілька секунд.',
|
'admin.update.reloadHint': 'Перезавантажте сторінку через кілька секунд.',
|
||||||
'admin.tabs.permissions': 'Дозволи',
|
'admin.tabs.permissions': 'Дозволи',
|
||||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ const dashboard: TranslationStrings = {
|
|||||||
'dashboard.status.past': 'Минуло',
|
'dashboard.status.past': 'Минуло',
|
||||||
'dashboard.status.daysLeft': 'залишилось {count} дн.',
|
'dashboard.status.daysLeft': 'залишилось {count} дн.',
|
||||||
'dashboard.toast.loadError': 'Не вдалося завантажити поїздки',
|
'dashboard.toast.loadError': 'Не вдалося завантажити поїздки',
|
||||||
|
'dashboard.loadErrorBanner':
|
||||||
|
"Не вдалося з'єднатися із сервером. Ваші поїздки в безпеці — будь ласка, спробуйте ще раз.",
|
||||||
|
'dashboard.retry': 'Спробувати ще раз',
|
||||||
'dashboard.toast.created': 'Поїздка створена!',
|
'dashboard.toast.created': 'Поїздка створена!',
|
||||||
'dashboard.toast.createError': 'Не вдалося створити поїздку',
|
'dashboard.toast.createError': 'Не вдалося створити поїздку',
|
||||||
'dashboard.toast.updated': 'Поїздка оновлена!',
|
'dashboard.toast.updated': 'Поїздка оновлена!',
|
||||||
|
|||||||
@@ -301,6 +301,9 @@ const admin: TranslationStrings = {
|
|||||||
'admin.update.backupLink': '前往備份',
|
'admin.update.backupLink': '前往備份',
|
||||||
'admin.update.howTo': '如何更新',
|
'admin.update.howTo': '如何更新',
|
||||||
'admin.update.dockerText': '你的 TREK 例項執行在 Docker 中。要更新到 {version},請在伺服器上執行以下命令:',
|
'admin.update.dockerText': '你的 TREK 例項執行在 Docker 中。要更新到 {version},請在伺服器上執行以下命令:',
|
||||||
|
'admin.update.nonDockerText':
|
||||||
|
'此 TREK 例項並非執行在 Docker 中。要更新到 {version},請重新執行你當初使用的安裝或更新方式——例如在 Proxmox Community Scripts 上,請從 LXC 主控臺執行更新:',
|
||||||
|
'admin.update.wikiLink': '開啟更新指南',
|
||||||
'admin.update.reloadHint': '請在幾秒後重新整理頁面。',
|
'admin.update.reloadHint': '請在幾秒後重新整理頁面。',
|
||||||
'admin.tabs.permissions': '許可權',
|
'admin.tabs.permissions': '許可權',
|
||||||
'admin.addons.catalog.journey.name': '旅程',
|
'admin.addons.catalog.journey.name': '旅程',
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
|
|||||||
'dashboard.status.past': '已結束',
|
'dashboard.status.past': '已結束',
|
||||||
'dashboard.status.daysLeft': '還剩 {count} 天',
|
'dashboard.status.daysLeft': '還剩 {count} 天',
|
||||||
'dashboard.toast.loadError': '載入旅行失敗',
|
'dashboard.toast.loadError': '載入旅行失敗',
|
||||||
|
'dashboard.loadErrorBanner': '無法連線到伺服器。你的旅行資料安全無虞——請稍後再試。',
|
||||||
|
'dashboard.retry': '重試',
|
||||||
'dashboard.toast.created': '旅行建立成功!',
|
'dashboard.toast.created': '旅行建立成功!',
|
||||||
'dashboard.toast.createError': '建立旅行失敗',
|
'dashboard.toast.createError': '建立旅行失敗',
|
||||||
'dashboard.toast.updated': '旅行已更新!',
|
'dashboard.toast.updated': '旅行已更新!',
|
||||||
|
|||||||
@@ -261,6 +261,8 @@ const admin: TranslationStrings = {
|
|||||||
'admin.update.backupLink': '前往备份',
|
'admin.update.backupLink': '前往备份',
|
||||||
'admin.update.howTo': '如何更新',
|
'admin.update.howTo': '如何更新',
|
||||||
'admin.update.dockerText': '你的 TREK 实例运行在 Docker 中。要更新到 {version},请在服务器上执行以下命令:',
|
'admin.update.dockerText': '你的 TREK 实例运行在 Docker 中。要更新到 {version},请在服务器上执行以下命令:',
|
||||||
|
'admin.update.nonDockerText': '此 TREK 实例未运行在 Docker 中。要更新到 {version},请重新执行你当初使用的安装或更新方式——例如,在 Proxmox Community Scripts 上,从 LXC 控制台运行更新:',
|
||||||
|
'admin.update.wikiLink': '打开更新指南',
|
||||||
'admin.update.reloadHint': '请在几秒后刷新页面。',
|
'admin.update.reloadHint': '请在几秒后刷新页面。',
|
||||||
'admin.tabs.permissions': '权限',
|
'admin.tabs.permissions': '权限',
|
||||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
|
|||||||
'dashboard.status.past': '已结束',
|
'dashboard.status.past': '已结束',
|
||||||
'dashboard.status.daysLeft': '还剩 {count} 天',
|
'dashboard.status.daysLeft': '还剩 {count} 天',
|
||||||
'dashboard.toast.loadError': '加载旅行失败',
|
'dashboard.toast.loadError': '加载旅行失败',
|
||||||
|
'dashboard.loadErrorBanner': '无法连接到服务器。你的旅行数据安然无恙——请稍后重试。',
|
||||||
|
'dashboard.retry': '重试',
|
||||||
'dashboard.toast.created': '旅行创建成功!',
|
'dashboard.toast.created': '旅行创建成功!',
|
||||||
'dashboard.toast.createError': '创建旅行失败',
|
'dashboard.toast.createError': '创建旅行失败',
|
||||||
'dashboard.toast.updated': '旅行已更新!',
|
'dashboard.toast.updated': '旅行已更新!',
|
||||||
|
|||||||
Reference in New Issue
Block a user