Compare commits

..

6 Commits

Author SHA1 Message Date
sss3978 00a7ac0341 Merge d30132197e into 71aa8f8051 2026-04-22 14:00:59 +00:00
sss3978 d30132197e Update client/src/i18n/translations/ja.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-22 23:00:56 +09:00
sss3978 9bf4220054 Update ja.ts 2026-04-22 21:59:16 +09:00
sss3978 0d0ab5080c Update supportedLanguages.ts 2026-04-22 21:56:52 +09:00
sss3978 1084d40685 Update TranslationContext.tsx 2026-04-22 21:55:47 +09:00
sss3978 75ef928264 Create ja.ts 2026-04-22 21:54:10 +09:00
35 changed files with 2518 additions and 360 deletions
+1 -1
View File
@@ -23,4 +23,4 @@ jobs:
- name: Publish to GitHub wiki - name: Publish to GitHub wiki
uses: Andrew-Chen-Wang/github-wiki-action@v5 uses: Andrew-Chen-Wang/github-wiki-action@v5
with: with:
strategy: clone strategy: init
+8 -14
View File
@@ -127,23 +127,19 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
#### 🧩 Addons (admin-toggleable) #### 🧩 Addons (admin-toggleable)
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
- **Budget** — expense tracker with splits, pie chart, multi-currency
- **Documents** — file attachments on trips, places, and reservations
- **Collab** — chat, notes, polls, day-by-day attendance
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking - **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI - **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods - **Collab** — chat, notes, polls, day-by-day attendance
- **Naver List Import** — one-click import from shared Naver Maps lists - **Journey** — magazine-style travel journal with entries, photos, maps, moods
- **MCP** — expose TREK to AI assistants via OAuth 2.1 - **Dashboard widgets** — currency converter and timezone clocks
</td> </td>
<td width="50%" valign="top"> <td width="50%" valign="top">
#### 🤖 AI / MCP #### 🤖 AI / MCP
- **Built-in MCP server** — OAuth 2.1 authenticated. 150+ tools, 30 resources - **Built-in MCP server** — OAuth 2.1 authenticated. 80+ tools, 27 resources
- **Granular scopes** — 27 OAuth scopes across 13 permission groups - **Granular scopes** — 24 OAuth scopes across 13 permission groups
- **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited - **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited
- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview` - **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview`
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on - **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
@@ -156,7 +152,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
#### ⚙️ Admin & customisation #### ⚙️ Admin & customisation
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar - **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
- **15 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID - **14 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history - **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates - **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
@@ -176,7 +172,7 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
``` ```
Open `http://localhost:3000`. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`). Open `http://localhost:3000`. The first user to register becomes admin.
<div align="center"> <div align="center">
@@ -342,8 +338,7 @@ server {
ssl_certificate /etc/ssl/fullchain.pem; ssl_certificate /etc/ssl/fullchain.pem;
ssl_certificate_key /etc/ssl/privkey.pem; ssl_certificate_key /etc/ssl/privkey.pem;
# 500 MB covers backup-restore uploads (capped at 500 MB server-side). client_max_body_size 50m;
client_max_body_size 500m;
location / { location / {
proxy_pass http://localhost:3000; proxy_pass http://localhost:3000;
@@ -360,7 +355,6 @@ server {
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_read_timeout 86400;
} }
} }
``` ```
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2 apiVersion: v2
name: trek name: trek
version: 3.0.2 version: 2.9.14
description: Minimal Helm chart for TREK app description: Minimal Helm chart for TREK app
appVersion: "3.0.2" appVersion: "2.9.14"
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "trek-client", "name": "trek-client",
"version": "3.0.2", "version": "2.9.14",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "trek-client", "name": "trek-client",
"version": "3.0.2", "version": "2.9.14",
"dependencies": { "dependencies": {
"@react-pdf/renderer": "^4.3.2", "@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7", "axios": "^1.6.7",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "trek-client", "name": "trek-client",
"version": "3.0.2", "version": "2.9.14",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect } from 'react'
import { Plane, Train, Car, Ship } from 'lucide-react' import { Plane, Train, Car, Ship } from 'lucide-react'
import Modal from '../shared/Modal' import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
@@ -7,8 +7,6 @@ import AirportSelect, { type Airport } from './AirportSelect'
import LocationSelect, { type LocationPoint } from './LocationSelect' import LocationSelect, { type LocationPoint } from './LocationSelect'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore'
import { formatDate } from '../../utils/formatters' import { formatDate } from '../../utils/formatters'
import type { Day, Reservation, ReservationEndpoint } from '../../types' import type { Day, Reservation, ReservationEndpoint } from '../../types'
@@ -77,8 +75,6 @@ const defaultForm = {
arrival_time: '', arrival_time: '',
confirmation_number: '', confirmation_number: '',
notes: '', notes: '',
price: '',
budget_category: '',
meta_airline: '', meta_airline: '',
meta_flight_number: '', meta_flight_number: '',
meta_train_number: '', meta_train_number: '',
@@ -98,13 +94,6 @@ interface TransportModalProps {
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId }: TransportModalProps) { export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId }: TransportModalProps) {
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
const toast = useToast() const toast = useToast()
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
const budgetItems = useTripStore(s => s.budgetItems)
const budgetCategories = useMemo(() => {
const cats = new Set<string>()
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
return Array.from(cats).sort()
}, [budgetItems])
const [form, setForm] = useState({ ...defaultForm }) const [form, setForm] = useState({ ...defaultForm })
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [fromPick, setFromPick] = useState<EndpointPick>({}) const [fromPick, setFromPick] = useState<EndpointPick>({})
@@ -137,8 +126,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
meta_train_number: meta.train_number || '', meta_train_number: meta.train_number || '',
meta_platform: meta.platform || '', meta_platform: meta.platform || '',
meta_seat: meta.seat || '', meta_seat: meta.seat || '',
price: meta.price || '',
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
}) })
if (type === 'flight') { if (type === 'flight') {
setFromPick({ airport: airportFromEndpoint(from) || undefined }) setFromPick({ airport: airportFromEndpoint(from) || undefined })
@@ -152,7 +139,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
setFromPick({}) setFromPick({})
setToPick({}) setToPick({})
} }
}, [isOpen, reservation, selectedDayId, budgetItems]) }, [isOpen, reservation, selectedDayId])
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value })) const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
@@ -186,10 +173,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
if (form.meta_platform) metadata.platform = form.meta_platform if (form.meta_platform) metadata.platform = form.meta_platform
if (form.meta_seat) metadata.seat = form.meta_seat if (form.meta_seat) metadata.seat = form.meta_seat
} }
if (isBudgetEnabled) {
if (form.price) metadata.price = form.price
if (form.budget_category) metadata.budget_category = form.budget_category
}
const startDate = startDay?.date ?? null const startDate = startDay?.date ?? null
const endDate = (endDay ?? startDay)?.date ?? null const endDate = (endDay ?? startDay)?.date ?? null
@@ -217,11 +200,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
endpoints, endpoints,
needs_review: false, needs_review: false,
} }
if (isBudgetEnabled) {
(payload as any).create_budget_entry = form.price && parseFloat(form.price) > 0
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
: { total_price: 0 }
}
await onSave(payload) await onSave(payload)
} catch (err: unknown) { } catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError')) toast.error(err instanceof Error ? err.message : t('common.unknownError'))
@@ -444,40 +422,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} /> style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
</div> </div>
{/* Price + Budget Category */}
{isBudgetEnabled && (
<>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.price')}</label>
<input type="text" inputMode="decimal" value={form.price}
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
placeholder="0.00"
style={inputStyle} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.budgetCategory')}</label>
<CustomSelect
value={form.budget_category}
onChange={v => set('budget_category', v)}
options={[
{ value: '', label: t('reservations.budgetCategoryAuto') },
...budgetCategories.map(c => ({ value: c, label: c })),
]}
placeholder={t('reservations.budgetCategoryAuto')}
size="sm"
/>
</div>
</div>
{form.price && parseFloat(form.price) > 0 && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -4 }}>
{t('reservations.budgetHint')}
</div>
)}
</>
)}
</form> </form>
</Modal> </Modal>
) )
+3 -2
View File
@@ -15,6 +15,7 @@ import ar from './translations/ar'
import br from './translations/br' import br from './translations/br'
import cs from './translations/cs' import cs from './translations/cs'
import pl from './translations/pl' import pl from './translations/pl'
import ja from './translations/ja'
import { SUPPORTED_LANGUAGES, SupportedLanguageCode } from './supportedLanguages' import { SUPPORTED_LANGUAGES, SupportedLanguageCode } from './supportedLanguages'
export { SUPPORTED_LANGUAGES } export { SUPPORTED_LANGUAGES }
@@ -23,7 +24,7 @@ type TranslationStrings = Record<string, string | { name: string; category: stri
// Keyed by SupportedLanguageCode so TypeScript enforces all languages have a translation. // Keyed by SupportedLanguageCode so TypeScript enforces all languages have a translation.
const translations: Record<SupportedLanguageCode, TranslationStrings> = { const translations: Record<SupportedLanguageCode, TranslationStrings> = {
de, en, es, fr, hu, it, ru, zh, 'zh-TW': zhTw, nl, id, ar, br, cs, pl, de, en, es, fr, hu, it, ru, zh, 'zh-TW': zhTw, nl, id, ar, br, cs, pl, ja,
} }
// Derived from SUPPORTED_LANGUAGES — add new languages there, not here. // Derived from SUPPORTED_LANGUAGES — add new languages there, not here.
@@ -38,7 +39,7 @@ export function getLocaleForLanguage(language: string): string {
export function getIntlLanguage(language: string): string { export function getIntlLanguage(language: string): string {
if (language === 'br') return 'pt-BR' if (language === 'br') return 'pt-BR'
return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'zh-TW', 'nl', 'ar', 'cs', 'pl', 'id'].includes(language) ? language : 'en' return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'zh-TW', 'nl', 'ar', 'cs', 'pl', 'id', 'ja' ].includes(language) ? language : 'en'
} }
export function isRtlLanguage(language: string): boolean { export function isRtlLanguage(language: string): boolean {
+1
View File
@@ -14,6 +14,7 @@ export const SUPPORTED_LANGUAGES = [
{ value: 'it', label: 'Italiano', locale: 'it-IT' }, { value: 'it', label: 'Italiano', locale: 'it-IT' },
{ value: 'ar', label: 'العربية', locale: 'ar-SA' }, { value: 'ar', label: 'العربية', locale: 'ar-SA' },
{ value: 'id', label: 'Bahasa Indonesia', locale: 'id-ID' }, { value: 'id', label: 'Bahasa Indonesia', locale: 'id-ID' },
{ value: 'ja', label: '日本語', locale: 'ja-JP' },
] as const ] as const
export type SupportedLanguageCode = typeof SUPPORTED_LANGUAGES[number]['value'] export type SupportedLanguageCode = typeof SUPPORTED_LANGUAGES[number]['value']
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -317,7 +317,7 @@ export default function JourneyPublicPage() {
)} )}
{/* Content */} {/* Content */}
<div className="px-5 pt-4 pb-5 cursor-pointer" onClick={() => setViewingEntry(entry)}> <div className="px-5 pt-4 pb-5">
{/* Title (only when no single photo — photo has it in overlay) */} {/* Title (only when no single photo — photo has it in overlay) */}
{photos.length !== 1 && entry.title && ( {photos.length !== 1 && entry.title && (
<h3 className="text-[16px] font-semibold text-zinc-900 dark:text-white tracking-tight leading-snug mb-2">{entry.title}</h3> <h3 className="text-[16px] font-semibold text-zinc-900 dark:text-white tracking-tight leading-snug mb-2">{entry.title}</h3>
@@ -448,7 +448,7 @@ export default function JourneyPublicPage() {
return ( return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950"> <div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
{/* Hero */} {/* Hero */}
<div className="relative text-center text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px', overflow: 'hidden' }}> <div className="relative text-center text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px' }}>
{journey.cover_image && ( {journey.cover_image && (
<div style={{ position: 'absolute', inset: 0, backgroundImage: `url(/uploads/${journey.cover_image})`, backgroundSize: 'cover', backgroundPosition: 'center', opacity: 0.15 }} /> <div style={{ position: 'absolute', inset: 0, backgroundImage: `url(/uploads/${journey.cover_image})`, backgroundSize: 'cover', backgroundPosition: 'center', opacity: 0.15 }} />
)} )}
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "trek-server", "name": "trek-server",
"version": "3.0.2", "version": "2.9.14",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "trek-server", "name": "trek-server",
"version": "3.0.2", "version": "2.9.14",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.28.0", "@modelcontextprotocol/sdk": "^1.28.0",
"archiver": "^6.0.1", "archiver": "^6.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "trek-server", "name": "trek-server",
"version": "3.0.2", "version": "2.9.14",
"main": "src/index.ts", "main": "src/index.ts",
"scripts": { "scripts": {
"start": "node --import tsx src/index.ts", "start": "node --import tsx src/index.ts",
+2 -4
View File
@@ -372,10 +372,8 @@ export function createApp(): express.Application {
} else { } else {
console.error('Unhandled error:', err); console.error('Unhandled error:', err);
} }
const status = err.statusCode || err.status || 500; const status = err.statusCode || 500;
// Expose the message for client errors (4xx); keep 'Internal server error' for 5xx. res.status(status).json({ error: 'Internal server error' });
const message = status < 500 ? err.message : 'Internal server error';
res.status(status).json({ error: message });
}); });
return app; return app;
-64
View File
@@ -2043,70 +2043,6 @@ function runMigrations(db: Database.Database): void {
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_entry_photos_entry ON journey_entry_photos(entry_id)'); db.exec('CREATE INDEX IF NOT EXISTS idx_journey_entry_photos_entry ON journey_entry_photos(entry_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_entry_photos_photo ON journey_entry_photos(journey_photo_id)'); db.exec('CREATE INDEX IF NOT EXISTS idx_journey_entry_photos_photo ON journey_entry_photos(journey_photo_id)');
}, },
// Migration 122: Correct stale day_id / end_day_id on non-transport
// reservations. Migration 110 only backfilled transport types; tours,
// restaurants, events and "other" bookings kept a stale day_id from
// older code paths that often defaulted to the first day of the trip.
// Starting with v3.0.0 the planner renders reservations by day_id
// instead of reservation_time, so those stale rows show up on the
// wrong day. This migration nulls out day_id / end_day_id values that
// don't match the reservation's time and then backfills them from
// reservation_time / reservation_end_time.
() => {
db.exec(`
UPDATE reservations
SET day_id = NULL
WHERE reservation_time IS NOT NULL
AND day_id IS NOT NULL
AND type != 'hotel'
AND NOT EXISTS (
SELECT 1 FROM days d
WHERE d.id = reservations.day_id
AND d.date = substr(reservations.reservation_time, 1, 10)
)
`);
db.exec(`
UPDATE reservations
SET end_day_id = NULL
WHERE reservation_end_time IS NOT NULL
AND end_day_id IS NOT NULL
AND type != 'hotel'
AND NOT EXISTS (
SELECT 1 FROM days d
WHERE d.id = reservations.end_day_id
AND d.date = substr(reservations.reservation_end_time, 1, 10)
)
`);
db.exec(`
UPDATE reservations
SET day_id = (
SELECT d.id FROM days d
WHERE d.trip_id = reservations.trip_id
AND d.date = substr(reservations.reservation_time, 1, 10)
LIMIT 1
)
WHERE type != 'hotel'
AND reservation_time IS NOT NULL
AND day_id IS NULL
`);
db.exec(`
UPDATE reservations
SET end_day_id = (
SELECT d.id FROM days d
WHERE d.trip_id = reservations.trip_id
AND d.date = substr(reservations.reservation_end_time, 1, 10)
LIMIT 1
)
WHERE type != 'hotel'
AND reservation_end_time IS NOT NULL
AND end_day_id IS NULL
AND substr(reservations.reservation_end_time, 1, 10)
!= substr(reservations.reservation_time, 1, 10)
`);
},
]; ];
if (currentVersion < migrations.length) { if (currentVersion < migrations.length) {
-18
View File
@@ -9,7 +9,6 @@ import * as svc from '../services/journeyService';
import { db } from '../db/database'; import { db } from '../db/database';
import { createOrUpdateJourneyShareLink, getJourneyShareLink, deleteJourneyShareLink, getPublicJourney } from '../services/journeyShareService'; import { createOrUpdateJourneyShareLink, getJourneyShareLink, deleteJourneyShareLink, getPublicJourney } from '../services/journeyShareService';
import { uploadToImmich } from '../services/memories/immichService'; import { uploadToImmich } from '../services/memories/immichService';
import { getAllowedExtensions } from '../services/fileService';
const router = express.Router(); const router = express.Router();
@@ -26,26 +25,9 @@ const storage = multer.diskStorage({
}, },
}); });
const imageFilter: multer.Options['fileFilter'] = (_req, file, cb) => {
if (!file.mimetype.startsWith('image/') || file.mimetype.includes('svg')) {
const err: Error & { statusCode?: number } = new Error('Only image files are allowed');
err.statusCode = 400;
return cb(err);
}
const ext = path.extname(file.originalname).toLowerCase().replace('.', '');
const allowed = getAllowedExtensions().split(',').map(e => e.trim().toLowerCase());
if (!allowed.includes('*') && !allowed.includes(ext)) {
const err: Error & { statusCode?: number } = new Error(`File type .${ext} is not allowed`);
err.statusCode = 400;
return cb(err);
}
cb(null, true);
};
const upload = multer({ const upload = multer({
storage, storage,
limits: { fileSize: 20 * 1024 * 1024 }, limits: { fileSize: 20 * 1024 * 1024 },
fileFilter: imageFilter,
}); });
// ── Static prefix routes (MUST come before /:id) ───────────────────────── // ── Static prefix routes (MUST come before /:id) ─────────────────────────
+12 -10
View File
@@ -781,20 +781,22 @@ export function updatePhoto(photoId: number, userId: number, data: { caption?: s
if (!row) return null; if (!row) return null;
if (!canEdit(row.journey_id, userId)) return null; if (!canEdit(row.journey_id, userId)) return null;
// caption lives on the gallery row; sort_order lives on the junction table const fields: string[] = [];
// (JP_SELECT reads jep.sort_order, so updating journey_photos.sort_order const values: unknown[] = [];
// would not be reflected in the returned row). if (data.caption !== undefined) { fields.push('caption = ?'); values.push(data.caption); }
if (data.caption !== undefined) { if (data.sort_order !== undefined) { fields.push('sort_order = ?'); values.push(data.sort_order); }
db.prepare('UPDATE journey_photos SET caption = ? WHERE id = ?').run(data.caption, photoId); if (!fields.length) {
} // no-op: return some photo row for this gallery item (first entry link)
if (data.sort_order !== undefined) { return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE gp.id = ? LIMIT 1`).get(photoId) as JourneyPhoto | null;
db.prepare('UPDATE journey_entry_photos SET sort_order = ? WHERE journey_photo_id = ?').run(data.sort_order, photoId);
} }
values.push(photoId);
db.prepare(`UPDATE journey_photos SET ${fields.join(', ')} WHERE id = ?`).run(...values);
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE gp.id = ? LIMIT 1`).get(photoId) as JourneyPhoto | null; return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE gp.id = ? LIMIT 1`).get(photoId) as JourneyPhoto | null;
} }
// deletePhoto: hard-delete (backwards compat name used by old route). // deletePhoto: hard-delete (backwards compat name used by old route).
export function deletePhoto(photoId: number, userId: number): { id: number; photo_id: number; file_path?: string | null; journey_id: number } | null { export function deletePhoto(photoId: number, userId: number): { photo_id: number; file_path?: string | null; journey_id: number } | null {
const row = db.prepare('SELECT id, journey_id, photo_id FROM journey_photos WHERE id = ?').get(photoId) as { id: number; journey_id: number; photo_id: number } | undefined; const row = db.prepare('SELECT id, journey_id, photo_id FROM journey_photos WHERE id = ?').get(photoId) as { id: number; journey_id: number; photo_id: number } | undefined;
if (!row) return null; if (!row) return null;
if (!canEdit(row.journey_id, userId)) return null; if (!canEdit(row.journey_id, userId)) return null;
@@ -804,7 +806,7 @@ export function deletePhoto(photoId: number, userId: number): { id: number; phot
db.prepare('DELETE FROM journey_photos WHERE id = ?').run(photoId); db.prepare('DELETE FROM journey_photos WHERE id = ?').run(photoId);
deleteTrekPhotoIfOrphan(row.photo_id); deleteTrekPhotoIfOrphan(row.photo_id);
return { id: row.id, photo_id: row.photo_id, file_path: trekRow?.file_path ?? null, journey_id: row.journey_id }; return { photo_id: row.photo_id, file_path: trekRow?.file_path ?? null, journey_id: row.journey_id };
} }
// ── Contributors ───────────────────────────────────────────────────────── // ── Contributors ─────────────────────────────────────────────────────────
@@ -27,6 +27,7 @@ export async function ensureLocalThumbnail(
const meta = await sharp(thumbAbs).metadata() const meta = await sharp(thumbAbs).metadata()
return { thumbnailRelPath: thumbRel, width: meta.width ?? 0, height: meta.height ?? 0 } return { thumbnailRelPath: thumbRel, width: meta.width ?? 0, height: meta.height ?? 0 }
} }
} catch { /* regenerate */ }
await fs.mkdir(path.dirname(thumbAbs), { recursive: true }) await fs.mkdir(path.dirname(thumbAbs), { recursive: true })
await sharp(originalAbs) await sharp(originalAbs)
@@ -36,8 +37,4 @@ export async function ensureLocalThumbnail(
.toFile(thumbAbs) .toFile(thumbAbs)
const meta = await sharp(thumbAbs).metadata() const meta = await sharp(thumbAbs).metadata()
return { thumbnailRelPath: thumbRel, width: meta.width ?? 0, height: meta.height ?? 0 } return { thumbnailRelPath: thumbRel, width: meta.width ?? 0, height: meta.height ?? 0 }
} catch {
// Unsupported format, corrupt file, etc. — fall back to original in caller.
return null
}
} }
+1 -1
View File
@@ -143,7 +143,7 @@ export async function discover(issuer: string, discoveryUrl?: string | null): Pr
// Validate that the discovery doc's issuer matches the operator-configured // Validate that the discovery doc's issuer matches the operator-configured
// one. A MITM or compromised doc could otherwise supply a crafted issuer // one. A MITM or compromised doc could otherwise supply a crafted issuer
// that passes jwt.verify() because we used doc.issuer as the expected value. // that passes jwt.verify() because we used doc.issuer as the expected value.
if (doc.issuer && doc.issuer.replace(/\/+$/, '') !== issuer) { if (doc.issuer && doc.issuer !== issuer) {
throw new Error(`OIDC discovery issuer mismatch: expected "${issuer}", got "${doc.issuer}"`); throw new Error(`OIDC discovery issuer mismatch: expected "${issuer}", got "${doc.issuer}"`);
} }
doc._issuer = url; doc._issuer = url;
+7 -67
View File
@@ -43,24 +43,6 @@ function loadEndpoints(reservationId: number): ReservationEndpoint[] {
).all(reservationId) as ReservationEndpoint[]; ).all(reservationId) as ReservationEndpoint[];
} }
// Resolve the day row whose date matches the date portion of an ISO-ish
// timestamp. Used to keep `day_id` / `end_day_id` in sync with
// `reservation_time` / `reservation_end_time` so non-transport bookings
// (tours, restaurants, events, ...) end up on the right day in the UI,
// which now filters by day_id instead of reservation_time.
function resolveDayIdFromTime(
tripId: string | number,
time: string | null | undefined,
): number | null {
if (!time) return null;
const datePart = time.slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(datePart)) return null;
const row = db
.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ? LIMIT 1')
.get(tripId, datePart) as { id: number } | undefined;
return row?.id ?? null;
}
const saveEndpoints = db.transaction((reservationId: number, endpoints: EndpointInput[]) => { const saveEndpoints = db.transaction((reservationId: number, endpoints: EndpointInput[]) => {
db.prepare('DELETE FROM reservation_endpoints WHERE reservation_id = ?').run(reservationId); db.prepare('DELETE FROM reservation_endpoints WHERE reservation_id = ?').run(reservationId);
const insert = db.prepare(` const insert = db.prepare(`
@@ -178,26 +160,13 @@ export function createReservation(tripId: string | number, data: CreateReservati
} }
} }
// Derive day_id / end_day_id from reservation_time when the client
// didn't explicitly set them (non-hotel bookings only — hotels store
// their date range on the linked day_accommodation).
const resolvedType = type || 'other';
let resolvedDayId: number | null = day_id ?? null;
if (resolvedDayId == null && resolvedType !== 'hotel' && reservation_time) {
resolvedDayId = resolveDayIdFromTime(tripId, reservation_time);
}
let resolvedEndDayId: number | null = end_day_id ?? null;
if (resolvedEndDayId == null && resolvedType !== 'hotel' && reservation_end_time) {
resolvedEndDayId = resolveDayIdFromTime(tripId, reservation_end_time);
}
const result = db.prepare(` const result = db.prepare(`
INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type, accommodation_id, metadata, needs_review) INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type, accommodation_id, metadata, needs_review)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
tripId, tripId,
resolvedDayId, day_id || null,
resolvedEndDayId, end_day_id ?? null,
place_id || null, place_id || null,
assignment_id || null, assignment_id || null,
title, title,
@@ -207,7 +176,7 @@ export function createReservation(tripId: string | number, data: CreateReservati
confirmation_number || null, confirmation_number || null,
notes || null, notes || null,
status || 'pending', status || 'pending',
resolvedType, type || 'other',
resolvedAccommodationId, resolvedAccommodationId,
metadata ? JSON.stringify(metadata) : null, metadata ? JSON.stringify(metadata) : null,
needs_review ? 1 : 0 needs_review ? 1 : 0
@@ -321,35 +290,6 @@ export function updateReservation(id: string | number, tripId: string | number,
} }
} }
const resolvedType = (type ?? current.type) || 'other';
const nextReservationTime = resolvedType === 'hotel'
? null
: (reservation_time !== undefined ? (reservation_time || null) : current.reservation_time);
const nextReservationEndTime = resolvedType === 'hotel'
? null
: (reservation_end_time !== undefined ? (reservation_end_time || null) : current.reservation_end_time);
// day_id / end_day_id: honour an explicit value from the client,
// otherwise derive from the (possibly updated) reservation_time so the
// planner renders the booking on the correct day.
let nextDayId: number | null;
if (day_id !== undefined) {
nextDayId = day_id || null;
} else if (reservation_time !== undefined && resolvedType !== 'hotel') {
nextDayId = resolveDayIdFromTime(tripId, nextReservationTime);
} else {
nextDayId = current.day_id ?? null;
}
let nextEndDayId: number | null;
if (end_day_id !== undefined) {
nextEndDayId = end_day_id ?? null;
} else if (reservation_end_time !== undefined && resolvedType !== 'hotel') {
nextEndDayId = resolveDayIdFromTime(tripId, nextReservationEndTime);
} else {
nextEndDayId = (current as any).end_day_id ?? null;
}
db.prepare(` db.prepare(`
UPDATE reservations SET UPDATE reservations SET
title = COALESCE(?, title), title = COALESCE(?, title),
@@ -370,13 +310,13 @@ export function updateReservation(id: string | number, tripId: string | number,
WHERE id = ? WHERE id = ?
`).run( `).run(
title || null, title || null,
nextReservationTime, (type ?? current.type) === 'hotel' ? null : (reservation_time !== undefined ? (reservation_time || null) : current.reservation_time),
nextReservationEndTime, (type ?? current.type) === 'hotel' ? null : (reservation_end_time !== undefined ? (reservation_end_time || null) : current.reservation_end_time),
location !== undefined ? (location || null) : current.location, location !== undefined ? (location || null) : current.location,
confirmation_number !== undefined ? (confirmation_number || null) : current.confirmation_number, confirmation_number !== undefined ? (confirmation_number || null) : current.confirmation_number,
notes !== undefined ? (notes || null) : current.notes, notes !== undefined ? (notes || null) : current.notes,
nextDayId, day_id !== undefined ? (day_id || null) : current.day_id,
nextEndDayId, end_day_id !== undefined ? (end_day_id ?? null) : (current as any).end_day_id ?? null,
place_id !== undefined ? (place_id || null) : current.place_id, place_id !== undefined ? (place_id || null) : current.place_id,
assignment_id !== undefined ? (assignment_id || null) : current.assignment_id, assignment_id !== undefined ? (assignment_id || null) : current.assignment_id,
status || null, status || null,
+1 -1
View File
@@ -649,7 +649,7 @@ describe('Link photo to entry', () => {
.send({}); .send({});
expect(res.status).toBe(400); expect(res.status).toBe(400);
expect(res.body.error).toBe('journey_photo_id required'); expect(res.body.error).toBe('photo_id required');
}); });
}); });
@@ -84,9 +84,8 @@ describe('GET /api/system-notices/active', () => {
it('returns empty array for non-first-login user with no applicable notices', async () => { it('returns empty array for non-first-login user with no applicable notices', async () => {
const { user } = createUser(testDb); const { user } = createUser(testDb);
// login_count > 1 means firstLogin condition does not match for any notice; // login_count > 1 means firstLogin condition does not match for any notice
// first_seen_version >= 3.0.0 means existingUserBeforeVersion('3.0.0') also does not match testDb.prepare('UPDATE users SET login_count = 5 WHERE id = ?').run(user.id);
testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id);
const res = await request(app) const res = await request(app)
.get('/api/system-notices/active') .get('/api/system-notices/active')
.set('Cookie', authCookie(user.id)); .set('Cookie', authCookie(user.id));
@@ -123,7 +122,7 @@ describe('GET /api/system-notices/active', () => {
SYSTEM_NOTICES.push(TEST_NOTICE); SYSTEM_NOTICES.push(TEST_NOTICE);
try { try {
const { user } = createUser(testDb); const { user } = createUser(testDb);
testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id); testDb.prepare('UPDATE users SET login_count = 5 WHERE id = ?').run(user.id);
const res = await request(app) const res = await request(app)
.get('/api/system-notices/active') .get('/api/system-notices/active')
@@ -1325,10 +1325,9 @@ describe('Edge cases', () => {
const result = deleteEntry(entry.id, user.id); const result = deleteEntry(entry.id, user.id);
expect(result).toBe(true); expect(result).toBe(true);
// Junction row must be gone (ON DELETE CASCADE from journey_entries). // Photo should be deleted with the entry
// Gallery row (journey_photos) is preserved — photo may belong to other entries. const deletedPhoto = testDb.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photo!.id) as any;
const junctionRow = testDb.prepare('SELECT * FROM journey_entry_photos WHERE entry_id = ?').get(entry.id) as any; expect(deletedPhoto).toBeUndefined();
expect(junctionRow).toBeUndefined();
}); });
it('JOURNEY-SVC-082: updateJourney can set cover_gradient', () => { it('JOURNEY-SVC-082: updateJourney can set cover_gradient', () => {
@@ -1396,12 +1395,17 @@ describe('Edge cases', () => {
addTripToJourney(journey.id, trip.id, user.id); addTripToJourney(journey.id, trip.id, user.id);
// Trip photos now go straight into the journey gallery (no wrapper entry). // Should have a [Trip Photos] entry with the imported photo
const photoEntry = testDb.prepare(
"SELECT * FROM journey_entries WHERE journey_id = ? AND title = '[Trip Photos]'"
).get(journey.id) as any;
expect(photoEntry).toBeDefined();
const photos = testDb.prepare(` const photos = testDb.prepare(`
SELECT jp.*, tkp.asset_id FROM journey_photos jp SELECT jp.*, tkp.asset_id FROM journey_photos jp
JOIN trek_photos tkp ON tkp.id = jp.photo_id JOIN trek_photos tkp ON tkp.id = jp.photo_id
WHERE jp.journey_id = ? WHERE jp.entry_id = ?
`).all(journey.id); `).all(photoEntry.id);
expect(photos.length).toBe(1); expect(photos.length).toBe(1);
expect((photos[0] as any).asset_id).toBe('immich-photo-1'); expect((photos[0] as any).asset_id).toBe('immich-photo-1');
}); });
@@ -58,7 +58,7 @@ afterAll(() => {
// -- Helpers ------------------------------------------------------------------ // -- Helpers ------------------------------------------------------------------
/** Insert a trek_photos + journey_photos (gallery) + journey_entry_photos row and return the trek_photos id (used as photoId in public URLs). */ /** Insert a trek_photos + journey_photos row and return the trek_photos id (used as photoId in public URLs). */
function insertJourneyPhoto( function insertJourneyPhoto(
entryId: number, entryId: number,
opts: { filePath?: string; assetId?: string; ownerId?: number } = {} opts: { filePath?: string; assetId?: string; ownerId?: number } = {}
@@ -70,24 +70,10 @@ function insertJourneyPhoto(
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`).run(provider, opts.assetId ?? null, filePath, opts.ownerId ?? null, Date.now()); `).run(provider, opts.assetId ?? null, filePath, opts.ownerId ?? null, Date.now());
const trekId = trekResult.lastInsertRowid as number; const trekId = trekResult.lastInsertRowid as number;
// Look up journey_id from entry so gallery row is keyed to the journey (not entry).
const entryRow = testDb.prepare('SELECT journey_id FROM journey_entries WHERE id = ?').get(entryId) as { journey_id: number };
const journeyId = entryRow.journey_id;
const now = Date.now();
testDb.prepare(` testDb.prepare(`
INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, caption, sort_order, created_at) INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
VALUES (?, ?, NULL, 0, ?) VALUES (?, ?, NULL, 0, ?)
`).run(journeyId, trekId, now); `).run(entryId, trekId, Date.now());
const galleryRow = testDb.prepare('SELECT id FROM journey_photos WHERE journey_id = ? AND photo_id = ?').get(journeyId, trekId) as { id: number };
testDb.prepare(`
INSERT OR IGNORE INTO journey_entry_photos (entry_id, journey_photo_id, sort_order, created_at)
VALUES (?, ?, 0, ?)
`).run(entryId, galleryRow.id, now);
// Return trek_photos.id — this is p.photo_id in the public API response // Return trek_photos.id — this is p.photo_id in the public API response
// and the value the client sends to /api/public/journey/:token/photos/:photoId/:kind // and the value the client sends to /api/public/journey/:token/photos/:photoId/:kind
return trekId; return trekId;
+1 -1
View File
@@ -59,7 +59,7 @@ If a toggle fails (e.g., network error), it rolls back to its previous state.
Some addons require credentials or environment variables before they are functional: Some addons require credentials or environment variables before they are functional:
- **Journey**works without any external integration. To embed photos from Immich or Synology Photos, enable the corresponding photo-provider toggle listed under Journey, then configure credentials per-user in **Settings → Integrations**. See [Photo-Providers](Photo-Providers). - **Journey**requires photo provider credentials (Immich or Synology Photos) configured per-user in their personal Settings. See [Photo-Providers](Photo-Providers).
- **MCP** — requires `APP_URL` to be set so OAuth redirect URIs resolve correctly. - **MCP** — requires `APP_URL` to be set so OAuth redirect URIs resolve correctly.
## Related pages ## Related pages
+7 -7
View File
@@ -48,7 +48,7 @@ Verified in `server/src/config.ts` (line 107):
## HTTPS / Proxy ## HTTPS / Proxy
These three variables work together behind a TLS-terminating reverse proxy. See [Reverse-Proxy](Reverse-Proxy) for the full explanation. These three variables work together behind a TLS-terminating reverse proxy. See [Reverse-Proxy] for the full explanation.
| Variable | Description | Default | | Variable | Description | Default |
|---|---|---| |---|---|---|
@@ -62,7 +62,7 @@ These three variables work together behind a TLS-terminating reverse proxy. See
## OIDC / SSO ## OIDC / SSO
For setup instructions, see [OIDC-SSO](OIDC-SSO). For setup instructions, see [OIDC-SSO].
| Variable | Description | Default | | Variable | Description | Default |
|---|---|---| |---|---|---|
@@ -110,7 +110,7 @@ Both variables must be set together. If either is omitted, the account is create
## MCP ## MCP
For setup instructions, see [MCP-Overview](MCP-Overview). For setup instructions, see [MCP-Overview].
| Variable | Description | Default | | Variable | Description | Default |
|---|---|---| |---|---|---|
@@ -129,7 +129,7 @@ For setup instructions, see [MCP-Overview](MCP-Overview).
## Related Pages ## Related Pages
- [Reverse-Proxy](Reverse-Proxy) — HTTPS proxy setup and the `FORCE_HTTPS` / `TRUST_PROXY` / `COOKIE_SECURE` trio - [Reverse-Proxy] — HTTPS proxy setup and the `FORCE_HTTPS` / `TRUST_PROXY` / `COOKIE_SECURE` trio
- [OIDC-SSO](OIDC-SSO) — complete OIDC configuration guide - [OIDC-SSO] — complete OIDC configuration guide
- [MCP-Overview](MCP-Overview) — MCP server setup and rate limiting - [MCP-Overview] — MCP server setup and rate limiting
- [Encryption-Key-Rotation](Encryption-Key-Rotation) — rotating the `ENCRYPTION_KEY` without losing data - [Encryption-Key-Rotation] — rotating the `ENCRYPTION_KEY` without losing data
+7 -13
View File
@@ -30,23 +30,17 @@ TREK is a self-hosted, real-time collaborative travel planner licensed under AGP
- **Public Share Links** — share a read-only view of any trip - **Public Share Links** — share a read-only view of any trip
### Addons _(admin-toggleable)_ ### Addons _(admin-toggleable)_
- **Lists** — packing lists and to-dos with templates, member assignments, optional bag tracking
- **Budget Planner** — expense tracker with category breakdown, splits, multi-currency
- **Documents** — file manager for trips, places, and reservations
- **Collab** — group chat, shared notes, polls, day-by-day attendance
- **Vacay** — personal vacation day planner with calendar view, public holidays, and carry-over tracking - **Vacay** — personal vacation day planner with calendar view, public holidays, and carry-over tracking
- **Atlas** — interactive world map, bucket list, travel stats, continent breakdown - **Atlas** — interactive world map, bucket list, travel stats, continent breakdown
- **Journey** magazine-style travel journal with entries, photos (via Immich/Synology Photos), maps, and moods - **Journey** — travel journal linking entries to trips, with contributor roles
- **Naver List Import** — import places from shared Naver Maps lists - **Memories** — photo-focused trip memories
- **MCP**expose TREK to AI assistants via the Model Context Protocol (OAuth 2.1) - **Collab**group chat, shared notes, polls, and activity sign-ups
- **Dashboard Widgets** — currency converter and timezone clock, toggled per user
> Dashboard widgets (currency converter and timezone clock) are per-user preferences, not an admin-toggleable addon — see [Dashboard-Widgets](Dashboard-Widgets).
### AI / MCP Integration ### AI / MCP Integration
- **MCP Server** — built-in Model Context Protocol server with OAuth 2.1 authentication - **MCP Server** — built-in Model Context Protocol server with OAuth 2.1 authentication
- **150+ Tools** — create trips, plan itineraries, manage budgets, send messages, and more - **80+ Tools** — create trips, plan itineraries, manage budgets, send messages, and more
- **30 Resources** — read-only `trek://` URIs for trips, days, places, budget, packing, journeys, and more - **24 OAuth Scopes** — granular permissions across 13 permission groups
- **27 OAuth Scopes** — granular permissions across 13 permission groups
- **Pre-built Prompts**`trip-summary`, `packing-list`, and `budget-overview` context loaders - **Pre-built Prompts**`trip-summary`, `packing-list`, and `budget-overview` context loaders
### Admin ### Admin
@@ -54,7 +48,7 @@ TREK is a self-hosted, real-time collaborative travel planner licensed under AGP
- Addon management, API key storage, scheduled auto-backups - Addon management, API key storage, scheduled auto-backups
- System notices for onboarding and announcements - System notices for onboarding and announcements
> **Admin:** Most configuration lives in the Admin Panel. On first boot TREK seeds an admin account automatically — credentials come from `ADMIN_EMAIL` / `ADMIN_PASSWORD` if set, otherwise a random password is printed to the container log. > **Admin:** Most configuration lives in the Admin Panel. The first user to register becomes the admin automatically.
## Get Started ## Get Started
+5 -5
View File
@@ -93,7 +93,7 @@ ALLOWED_ORIGINS=https://trek.example.com
APP_URL=https://trek.example.com APP_URL=https://trek.example.com
``` ```
Uncomment and fill in the OIDC, initial setup, or MCP variables as needed. For a full description of every variable, see [Environment-Variables](Environment-Variables). Uncomment and fill in the OIDC, initial setup, or MCP variables as needed. For a full description of every variable, see [Environment-Variables].
## Start TREK ## Start TREK
@@ -111,10 +111,10 @@ docker compose logs -f
This compose file is designed for deployments where a reverse proxy (nginx, Caddy, Traefik) terminates TLS in front of TREK. To enable HTTPS redirects and secure cookies, uncomment `FORCE_HTTPS=true` and `TRUST_PROXY=1`. This compose file is designed for deployments where a reverse proxy (nginx, Caddy, Traefik) terminates TLS in front of TREK. To enable HTTPS redirects and secure cookies, uncomment `FORCE_HTTPS=true` and `TRUST_PROXY=1`.
See [Reverse-Proxy](Reverse-Proxy) for complete proxy configuration examples. See [Reverse-Proxy] for complete proxy configuration examples.
## Next Steps ## Next Steps
- [Environment-Variables](Environment-Variables) — full variable reference - [Environment-Variables] — full variable reference
- [Reverse-Proxy](Reverse-Proxy) — HTTPS configuration - [Reverse-Proxy] — HTTPS configuration
- [Updating](Updating) — how to pull a new image - [Updating] — how to pull a new image
+6 -6
View File
@@ -32,7 +32,7 @@ Pass additional `-e` flags for timezone and CORS/email link support:
-e ALLOWED_ORIGINS=https://trek.example.com \ -e ALLOWED_ORIGINS=https://trek.example.com \
``` ```
See [Environment-Variables](Environment-Variables) for the full list. See [Environment-Variables] for the full list.
## Volume Reference ## Volume Reference
@@ -66,11 +66,11 @@ docker logs trek
## Limitations of `docker run` ## Limitations of `docker run`
A bare `docker run` command has no built-in secret management and is harder to reproduce after a system reboot. For production, see [Install-Docker-Compose](Install-Docker-Compose), which adds security hardening (`read_only`, `cap_drop`, `cap_add`, `no-new-privileges`, `tmpfs`) and makes it easy to manage environment variables through a `.env` file. A bare `docker run` command has no built-in secret management and is harder to reproduce after a system reboot. For production, see [Install-Docker-Compose], which adds security hardening (`read_only`, `cap_drop`, `cap_add`, `no-new-privileges`, `tmpfs`) and makes it easy to manage environment variables through a `.env` file.
## Next Steps ## Next Steps
- [Reverse-Proxy](Reverse-Proxy) — HTTPS is required for PWA install and the `trek_session` cookie `secure` flag - [Reverse-Proxy] — HTTPS is required for PWA install and the `trek_session` cookie `secure` flag
- [Install-Docker-Compose](Install-Docker-Compose) — recommended for production - [Install-Docker-Compose] — recommended for production
- [Environment-Variables](Environment-Variables) — full list of configurable variables - [Environment-Variables] — full list of configurable variables
- [Updating](Updating) — how to pull a new image without losing data - [Updating] — how to pull a new image without losing data
+2 -2
View File
@@ -191,5 +191,5 @@ See the [`charts/README.md`](https://github.com/mauriceboe/TREK/blob/main/charts
## Next Steps ## Next Steps
- [Environment-Variables](Environment-Variables) — full variable reference - [Environment-Variables] — full variable reference
- [Reverse-Proxy](Reverse-Proxy) — proxy configuration for non-Kubernetes deployments - [Reverse-Proxy] — proxy configuration for non-Kubernetes deployments
+2 -2
View File
@@ -69,5 +69,5 @@ On first boot, TREK automatically creates an admin account. The credentials are
## Next Steps ## Next Steps
- [Environment-Variables](Environment-Variables) — complete variable reference - [Environment-Variables] — complete variable reference
- [Updating](Updating) — how to pull a new image on Unraid - [Updating] — how to pull a new image on Unraid
+1 -1
View File
@@ -2,7 +2,7 @@
TREK can browse your personal photo library on Immich or Synology Photos and attach selected photos to trips. TREK never copies the original files — it stores only a reference (provider name + asset ID) and proxies all image streams through its own server, so your provider credentials are never sent to the browser. TREK can browse your personal photo library on Immich or Synology Photos and attach selected photos to trips. TREK never copies the original files — it stores only a reference (provider name + asset ID) and proxies all image streams through its own server, so your provider credentials are never sent to the browser.
> **Admin:** Enable at least one photo provider (Immich or Synology Photos) in **Admin → Addons** photo provider toggles appear as sub-items under the **Journey** addon. Once a provider is on, a Photo Providers section appears in each user's **Settings → Integrations**. If your provider runs on a local or private network, the server must be configured to allow internal network access. See [Admin-Addons](Admin-Addons) and [Internal-Network-Access](Internal-Network-Access). > **Admin:** Two things must be enabled for photo providers to appear in Settings: the **Memories addon** and the **individual photo provider** (Immich or Synology Photos). Both are toggled separately in **Admin → Addons**. See [Admin-Addons](Admin-Addons). If your provider is on a local or private network, the server must be configured to allow internal network access. See [Internal-Network-Access](Internal-Network-Access).
--- ---
+4 -4
View File
@@ -60,7 +60,7 @@ You will be prompted to change the password on first login.
## Next Steps ## Next Steps
- [Install-Docker-Compose](Install-Docker-Compose) — production setup with security hardening - [Install-Docker-Compose] — production setup with security hardening
- [Reverse-Proxy](Reverse-Proxy) — put TREK behind HTTPS (required for PWA install and secure cookies) - [Reverse-Proxy] — put TREK behind HTTPS (required for PWA install and secure cookies)
- [Environment-Variables](Environment-Variables) — full configuration reference - [Environment-Variables] — full configuration reference
- [Admin-Panel-Overview](Admin-Panel-Overview) — explore what the admin panel can do - [Admin-Panel-Overview] — explore what the admin panel can do
+3 -3
View File
@@ -98,9 +98,9 @@ Four variables control how TREK behaves behind a proxy. They work as a group:
If you access TREK directly on `http://<host>:3000` without a proxy, leave `FORCE_HTTPS` unset and do not set `TRUST_PROXY`. If you access TREK directly on `http://<host>:3000` without a proxy, leave `FORCE_HTTPS` unset and do not set `TRUST_PROXY`.
See [Environment-Variables](Environment-Variables) for full documentation of these and all other variables. See [Environment-Variables] for full documentation of these and all other variables.
## Next Steps ## Next Steps
- [Environment-Variables](Environment-Variables) — full variable reference including OIDC - [Environment-Variables] — full variable reference including OIDC
- [Install-Docker-Compose](Install-Docker-Compose) — production compose file with proxy-ready env vars - [Install-Docker-Compose] — production compose file with proxy-ready env vars
+1 -20
View File
@@ -1,9 +1,7 @@
# Tags and Categories # Tags and Categories
TREK has two independent labelling systems for places: TREK has a labeling system: **Global Place Categories** (admin-managed, shared across all users).
- **Global Place Categories** — admin-managed, shared across every user on the instance (e.g. `Restaurant`, `Museum`).
- **Personal Tags** — user-scoped, private labels (e.g. `hidden gem`, `kid-friendly`).
<!-- TODO: screenshot: tag list on place detail --> <!-- TODO: screenshot: tag list on place detail -->
@@ -26,23 +24,6 @@ Categories appear in:
> **Admin:** Create and manage categories in [Admin-Categories](Admin-Categories). Only admins can create, edit, or delete categories. All users can read them. > **Admin:** Create and manage categories in [Admin-Categories](Admin-Categories). Only admins can create, edit, or delete categories. All users can read them.
## Personal Tags
Tags are private labels owned by each user. They attach to individual places via a many-to-many relationship (`place_tags` table), so the same tag can be applied to as many places as you like, and a single place can carry multiple tags.
**Fields per tag:**
- **Name** — free-form text.
- **Color** — hex value displayed alongside the tag name. Default: `#10b981` (emerald).
Tags are scoped to their creator — other trip members do not see your tags, and different users can create tags with identical names without conflict. Deleting a tag automatically removes it from every place it was attached to.
### Where to manage them
At the moment tags are exposed primarily through the MCP API — AI assistants connected to your instance can list, create, update, and delete tags (`list_tags`, `create_tag`, `update_tag`, `delete_tag`) and attach them to places through the place endpoints. A dedicated web UI for tag management is not yet available; the filter `tag` parameter on the places API / MCP resource does support filtering places by a tag ID once one exists.
> **AI / MCP:** See [MCP-Tools-and-Resources](MCP-Tools-and-Resources) for the full tag tool list.
## When to use which ## When to use which
| Use case | Use | | Use case | Use |
+5 -5
View File
@@ -4,7 +4,7 @@ How to update TREK to a newer version without losing data.
## Before You Update ## Before You Update
Back up your data first. Go to Admin Panel → Backups and create a manual backup, or copy your `./data` and `./uploads` directories to a safe location. See [Backups](Backups) for details. Back up your data first. Go to Admin Panel → Backups and create a manual backup, or copy your `./data` and `./uploads` directories to a safe location. See [Backups] for details.
## Docker Compose (Recommended) ## Docker Compose (Recommended)
@@ -42,7 +42,7 @@ TREK runs any pending database migrations automatically at startup. No manual mi
If you are upgrading from a version that predates the dedicated `ENCRYPTION_KEY` (i.e. you have no `ENCRYPTION_KEY` environment variable set), TREK automatically falls back to `./data/.jwt_secret` on startup and immediately promotes it to `./data/.encryption_key`. No manual steps are required — the transition is handled at first boot after the upgrade. If you are upgrading from a version that predates the dedicated `ENCRYPTION_KEY` (i.e. you have no `ENCRYPTION_KEY` environment variable set), TREK automatically falls back to `./data/.jwt_secret` on startup and immediately promotes it to `./data/.encryption_key`. No manual steps are required — the transition is handled at first boot after the upgrade.
If you want to rotate to a new key at any point (not required for a normal update), see [Encryption-Key-Rotation](Encryption-Key-Rotation) for the full procedure. If you want to rotate to a new key at any point (not required for a normal update), see [Encryption-Key-Rotation] for the full procedure.
## Unraid ## Unraid
@@ -50,6 +50,6 @@ In the Unraid Docker tab, click the TREK container and select **Update**. Unraid
## Next Steps ## Next Steps
- [Backups](Backups) — schedule automatic backups so you always have a restore point before updates - [Backups] — schedule automatic backups so you always have a restore point before updates
- [Encryption-Key-Rotation](Encryption-Key-Rotation) — if you need to rotate or migrate the encryption key - [Encryption-Key-Rotation] — if you need to rotate or migrate the encryption key
- [Install-Docker-Compose](Install-Docker-Compose) — switch to Compose for easier future updates - [Install-Docker-Compose] — switch to Compose for easier future updates