Compare commits

..

18 Commits

Author SHA1 Message Date
Maurice a074debd61 fix(auth): keep the last admin when OIDC claims would demote it (#1274)
On OIDC-only instances the bootstrap admin (first SSO user) rarely carries the configured admin claim, so a forced re-login — e.g. after a JWT-secret rotation — re-derived its role purely from claims and demoted it to user, locking the instance out with no recovery. The OIDC login role sync now skips a downgrade that would strip the last remaining admin, and the admin user-update endpoint guards the same case.
2026-06-21 00:28:39 +02:00
Maurice 5f44cd1403 feat(planner): bring back the Google Maps route export button (#1255)
The day-plan route bar lost its Open in Google Maps action in the 3.1.0 redesign. A small button with the Google logo (monochrome, theme-aware) now sits next to the Route toggle and opens the day stops, in planned order, as a Google Maps directions link in a new tab.
2026-06-21 00:00:35 +02:00
Maurice 459b092e28 fix(admin): show non-Docker update steps when not running in Docker (#1269)
The "How to Update" modal always rendered Docker commands and claimed the instance runs in Docker, even on bare-metal / LXC installs like Proxmox Community Scripts. It now branches on the is_docker flag the backend already returns: non-Docker installs get a generic "re-run your install method" note plus a link to the update guide. Docker stays the default when the flag is absent, so existing installs are unaffected.
2026-06-20 23:41:04 +02:00
Alejandro Pinar Ruiz 4a5cf3d83d feat(helm): add annotations support for PVCs (#1270)
Co-authored-by: Maurice <mauriceboe@icloud.com>
2026-06-20 23:23:08 +02:00
Maurice a8cb2e672a fix(atlas): give the country-GeoJSON fetch a longer timeout (#1254)
The gzipped admin0 GeoJSON is still a few MB, so behind a slow reverse proxy or Cloudflare Tunnel it could exceed the global 8s axios timeout and abort, leaving the map with no countries. It now gets a 30s per-request timeout, matching the existing /maps/pois exception.
2026-06-20 22:58:23 +02:00
Maurice ef7a3d32d8 fix(dashboard): add a text-shadow so spotlight and card titles stay legible (#1267)
When no trip is ongoing the spotlight falls back to a trip gradient, and several of those are light enough that the white title vanished in light mode. A subtle text-shadow on the hero title and trip-card names keeps them readable without affecting dark covers or dark mode.
2026-06-20 22:48:02 +02:00
Maurice e05e16de3f fix(costs): move the unfinished marker to the category icon on mobile (#1266)
A long expense title pushed the "Unfinished" pill into the price on narrow screens. On mobile the status now shows as a small marker on the category icon, freeing the title and price row; desktop keeps the labelled pill.
2026-06-20 22:32:06 +02:00
Maurice 1656ddcae2 test(days): bump over-long-time assertion to the new 250 limit (#1252)
Follow-up to raising the day-note time cap to 250: the unit test still sent 151 chars expecting a 400, which now passes validation and fell through to an unmocked service call.
2026-06-19 18:09:04 +02:00
Maurice 22ad5d73f2 fix(chart): allow setting storageClassName on PVCs (#1261)
The PVC templates rendered no storageClassName and values exposed no key, so clusters without a default StorageClass (or needing a specific class) couldn't install. Add persistence.{data,uploads}.storageClassName, omitted when empty so the default class is still used.
2026-06-19 17:56:42 +02:00
Maurice 5367d24f9f fix(pdf): show photos for OSM places in the trip PDF (#1130)
The PDF photo pre-fetch only fired for places with a google_place_id, so OSM/Nominatim places (osm_id only) fell back to category icons even though they show photos in-app. Recover osm_id from the full places pool (the assignment projection drops it) and key the photo off google_place_id || osm_id || coords, matching the UI.
2026-06-19 17:56:41 +02:00
Maurice 17822aa9eb fix(days): align note time limit to 250 and keep toasts above modal blur (#1252)
The day-note 'time' field capped at 150 server-side while the dialog and shared schema allow 250, so 151-250 char notes 400'd with a confusing 'time must be 150...' message. Raise the controller and MCP limits to 250. Also lift the toast container above modal overlays so the error toast isn't rendered behind the modal's backdrop blur.
2026-06-19 17:56:41 +02:00
Maurice 87e8a44764 fix(dashboard): count archived trips in travel stats (#1264)
The trips/days widgets filtered out archived trips while places, countries and flight distance did not, so archiving a trip zeroed only those two. Drop the is_archived filter so all stats stay consistent.
2026-06-19 17:56:26 +02:00
Maurice f8c77bff8e fix(security): allow same-origin PDF previews under CSP (#1253)
Firefox/Chrome enforce object-src, so object-src 'none' blocked the inline <object> PDF preview (worked only in Safari). Relax to 'self' for same-origin file previews.
2026-06-19 17:56:26 +02:00
Maurice 0b995cfd55 chore: add ca_profile.xml for Unraid Community Apps submission 2026-06-19 17:22:40 +02:00
Neil Soult 7a4c9998af fix(atlas): gzip-compress responses so large country GeoJSON loads behind reverse proxies (#1262)
The admin-0 country GeoJSON served at /api/addons/atlas/countries/geo is
~30 MB uncompressed. With no compression in the request pipeline the
transfer aborts (~8s, net::ERR_FAILED despite a 200) behind reverse
proxies / Cloudflare Tunnel, so the Atlas map never colours visited
countries. LAN is unaffected.

Add the `compression` middleware to the shared applyGlobalMiddleware
pipeline (gzip brings ~30 MB down to ~4 MB). text/event-stream is
excluded so the /mcp StreamableHTTP (SSE) transport is not buffered.

Adds BOOT-008 asserting content-encoding: gzip on the geo endpoint.

Fixes #1254

Co-authored-by: pai <pai@stabpablo.eu>
2026-06-19 16:19:12 +02:00
jubnl 26ade89bc8 chore: dockerignore spec.ts files 2026-06-19 13:59:16 +02:00
jubnl b150b576aa fix(budget): scale category bars relative to top category 2026-06-19 13:52:15 +02:00
jubnl a162289829 fix(budget): accept comma decimal separator in expense amounts
The expense Total Amount, per-person "Who paid", and settlement amount
inputs used type="number" with a bare parseFloat. On desktop the number
input normalized comma→dot for free, but mobile keyboards drop the comma
before onChange fires, so parseFloat("39,99") silently became 39.

Switch the three inputs to type="text" inputMode="decimal" and normalize
comma→dot in their onChange handlers, matching the pattern already used
by the other budget inputs (BudgetPanelInlineEditCell, BudgetPanelAddItemRow,
DashboardPage). Both comma and dot now work on every device.

Closes #1256
2026-06-19 13:16:29 +02:00
45 changed files with 371 additions and 45 deletions
+1
View File
@@ -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/
+9
View File
@@ -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
View File
@@ -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.
+2 -2
View File
@@ -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"
+14
View File
@@ -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 }}
+5
View File
@@ -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(
+18 -9
View File
@@ -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>
@@ -833,10 +840,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 +905,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 +965,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>
+22
View File
@@ -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,
+18 -10
View File
@@ -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)
@@ -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',
+3 -1
View File
@@ -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 => (
+18 -5
View File
@@ -229,12 +229,24 @@ 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>
<div style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 12, lineHeight: 1.8, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }} {updateInfo?.is_docker === false ? (
className="bg-gray-900 dark:bg-gray-950 text-gray-100 border border-gray-700" <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' }}
className="bg-gray-900 dark:bg-gray-950 text-gray-100 border border-gray-700"
>
{`docker pull mauriceboe/trek:latest {`docker pull mauriceboe/trek:latest
docker stop trek && docker rm trek docker stop trek && docker rm trek
docker run -d --name trek \\ docker run -d --name trek \\
@@ -243,7 +255,8 @@ docker run -d --name trek \\
-v /opt/trek/uploads:/app/uploads \\ -v /opt/trek/uploads:/app/uploads \\
--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"
+5 -2
View File
@@ -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
+2 -2
View File
@@ -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; }
+76
View File
@@ -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",
+2
View File
@@ -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",
+2 -2
View File
@@ -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,
+19 -1
View File
@@ -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
+4 -3
View File
@@ -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 151250 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)) {
+10
View File
@@ -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),
+4 -1
View File
@@ -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>();
+14 -2
View File
@@ -385,8 +385,20 @@ 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) {
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(newRole, user.id); // Never let the claim-based downgrade strip the last admin. The bootstrap
user = { ...user, role: newRole } as User; // 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);
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' },
}); });
}); });
+3
View File
@@ -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
+3
View File
@@ -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',
+3
View File
@@ -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)',
+3
View File
@@ -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)',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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)',
+3
View File
@@ -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': 'Ταξίδι',
+3
View File
@@ -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)',
+3
View File
@@ -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',
+3
View File
@@ -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)',
+3
View File
@@ -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': '日記',
+3
View File
@@ -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',
+3
View File
@@ -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)',
+3
View File
@@ -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ń.',
+3
View File
@@ -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)',
+3
View File
@@ -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',
+3
View File
@@ -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)',
+3
View File
@@ -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': '旅程',
+2
View File
@@ -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)',