mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 22:01:45 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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/
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 => (
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
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)) {
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
|||||||
@@ -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': 'Ταξίδι',
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
|||||||
@@ -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': '日記',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
|||||||
@@ -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ń.',
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
|||||||
@@ -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': '旅程',
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
|||||||
Reference in New Issue
Block a user