Compare commits

...

2 Commits

Author SHA1 Message Date
Maurice 72f9beffbe feat(auth): explain the plain-HTTP secure-cookie gotcha on login
When the server issues a Secure session cookie but the request arrived over
plain HTTP (the common LAN install over http://ip:3000), the browser drops
the cookie and the next request dead-ends on a bare "Access token required" —
the top source of avoidable install issues. The login response now flags this
exact case and the login page shows a localized box explaining the fix (use
HTTPS, or set COOKIE_SECURE=false) with a link to the Troubleshooting guide.
It only triggers in the real failure case, never for correct HTTPS setups.
2026-06-29 18:24:49 +02:00
Maurice 1ba8eecfbb feat(help): embed the TREK wiki as an in-app help centre
Add a Help section (profile menu, /help) that renders the GitHub wiki inside
TREK. /api/help fetches the wiki markdown — the nav from _Sidebar.md, pages,
and proxied images — from GitHub and caches it (1h TTL, serves stale on
outage), so it auto-syncs on wiki edits with no redeploy and the client never
calls GitHub directly. The page is styled to match TREK with a section
sidebar, search and react-markdown; wiki [[links]] are rewritten to in-app
routes and HTML-comment placeholders are stripped. Page state lives in a
useHelp() hook per the page pattern. Adds nav.help and a help namespace
across all locales.
2026-06-29 18:24:49 +02:00
98 changed files with 971 additions and 6 deletions
+3 -1
View File
@@ -65,4 +65,6 @@ coverage
test-data
.run
.full-review
.full-review
# Wiki offline snapshot is baked in at build, not committed (duplicates wiki/)
server/assets/wiki/
+17
View File
@@ -13,6 +13,7 @@ import FilesPage from './pages/FilesPage'
import AdminPage from './pages/AdminPage'
import SettingsPage from './pages/SettingsPage'
import VacayPage from './pages/VacayPage'
import HelpPage from './pages/HelpPage'
import AtlasPage from './pages/AtlasPage'
import JourneyPage from './pages/JourneyPage'
import JourneyDetailPage from './pages/JourneyDetailPage'
@@ -221,6 +222,22 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="/help"
element={
<ProtectedRoute>
<HelpPage />
</ProtectedRoute>
}
/>
<Route
path="/help/:slug"
element={
<ProtectedRoute>
<HelpPage />
</ProtectedRoute>
}
/>
<Route
path="/trips/:id"
element={
+11
View File
@@ -703,6 +703,17 @@ export const configApi = {
apiClient.get('/config').then(r => r.data),
}
export interface HelpNavItem { title: string; slug: string }
export interface HelpNavSection { title: string; pages: HelpNavItem[] }
export interface HelpPageData { slug: string; title: string; markdown: string }
export const helpApi = {
index: (): Promise<{ sections: HelpNavSection[] }> =>
apiClient.get('/help/index').then(r => r.data),
page: (slug: string): Promise<HelpPageData> =>
apiClient.get(`/help/page/${encodeURIComponent(slug)}`).then(r => r.data),
}
export const settingsApi = {
get: () => apiClient.get('/settings').then(r => r.data),
set: (key: string, value: unknown) => {
+9 -1
View File
@@ -5,7 +5,7 @@ import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { useTranslation } from '../../i18n'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass } from 'lucide-react'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import InAppNotificationBell from './InAppNotificationBell.tsx'
@@ -252,6 +252,14 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
{t('nav.settings')}
</Link>
<Link to="/help" onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors text-content-secondary"
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<BookOpen className="w-4 h-4" />
{t('nav.help')}
</Link>
{user.role === 'admin' && (
<Link to="/admin" onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors text-content-secondary"
+188
View File
@@ -0,0 +1,188 @@
import { Link } from 'react-router-dom'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Search, ChevronRight, Loader2, AlertCircle, BookOpen, PanelLeft, X } from 'lucide-react'
import PageShell from '../components/Layout/PageShell'
import { useTranslation } from '../i18n'
import { useHelp } from './help/useHelp'
export default function HelpPage() {
const { t } = useTranslation()
const { page, loading, pageError, query, setQuery, navOpen, setNavOpen, contentRef, activeSlug, filtered } =
useHelp()
const nav = (
<nav className="flex flex-col gap-5">
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('help.search')}
className="w-full bg-surface-tertiary text-content rounded-lg pl-9 pr-3 py-2 text-[13px] outline-none border border-transparent focus:border-edge"
/>
</div>
{filtered.map((section) => (
<div key={section.title}>
{section.title && (
<h3 className="text-[10px] font-semibold tracking-[0.1em] uppercase text-content-faint mb-1.5 px-2">
{section.title}
</h3>
)}
<div className="flex flex-col">
{section.pages.map((p) => {
const active = p.slug === activeSlug
return (
<Link
key={p.slug}
to={`/help/${p.slug}`}
className={`flex items-center gap-1.5 px-2 py-1.5 rounded-lg text-[13px] transition-colors ${
active
? 'bg-accent-subtle text-accent font-semibold'
: 'text-content-secondary hover:bg-surface-hover'
}`}
>
{active && <ChevronRight size={13} className="shrink-0" />}
<span className={active ? '' : 'pl-[18px]'}>{p.title}</span>
</Link>
)
})}
</div>
</div>
))}
{!filtered.length && <p className="text-[12px] text-content-faint px-2">{t('help.noResults')}</p>}
</nav>
)
return (
<PageShell className="bg-surface-secondary" navOffset="var(--nav-h, 56px)">
<div className="max-w-[1600px] mx-auto px-4 lg:px-10 py-6 flex gap-10">
{/* Desktop sidebar */}
<aside className="hidden lg:block w-[260px] shrink-0">
<div className="sticky top-[calc(var(--nav-h,56px)+24px)] max-h-[calc(100vh-var(--nav-h,56px)-48px)] overflow-y-auto pr-1">
<div className="flex items-center gap-2 mb-4 px-2">
<BookOpen size={16} className="text-accent" />
<span className="text-[14px] font-bold text-content">{t('help.title')}</span>
</div>
{nav}
</div>
</aside>
{/* Content */}
<main className="flex-1 min-w-0" ref={contentRef}>
{/* Mobile nav toggle */}
<button
onClick={() => setNavOpen(true)}
className="lg:hidden inline-flex items-center gap-2 mb-4 px-3 py-2 rounded-lg bg-surface-card border border-edge text-[13px] font-medium text-content"
>
<PanelLeft size={15} /> {t('help.contents')}
</button>
{loading ? (
<div className="flex items-center justify-center py-24 text-content-faint">
<Loader2 size={22} className="animate-spin" />
</div>
) : pageError ? (
<div className="flex flex-col items-center justify-center gap-2 py-24 text-center">
<AlertCircle size={28} className="text-content-faint" />
<p className="text-[14px] font-semibold text-content">{t('help.errorTitle')}</p>
<p className="text-[13px] text-content-faint max-w-sm">{t('help.errorBody')}</p>
</div>
) : page ? (
<article className="wiki-prose max-w-[1040px]">
<WikiContent markdown={page.markdown} />
</article>
) : null}
</main>
</div>
{/* Mobile sidebar drawer */}
{navOpen && (
<div className="lg:hidden fixed inset-0 z-[120]" onClick={() => setNavOpen(false)}>
<div className="absolute inset-0 bg-black/40" />
<div
className="absolute left-0 top-0 bottom-0 w-[280px] bg-surface-card p-5 overflow-y-auto shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<span className="text-[14px] font-bold text-content flex items-center gap-2">
<BookOpen size={16} className="text-accent" /> {t('help.title')}
</span>
<button onClick={() => setNavOpen(false)} className="text-content-faint">
<X size={18} />
</button>
</div>
{nav}
</div>
</div>
)}
</PageShell>
)
}
/** Markdown renderer with TREK-styled elements and SPA-internal links. */
function WikiContent({ markdown }: { markdown: string }) {
return (
<Markdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => (
<h1 className="text-[26px] font-bold text-content mt-1 mb-4 leading-tight">{children}</h1>
),
h2: ({ children }) => (
<h2 className="text-[19px] font-bold text-content mt-8 mb-3 pb-1.5 border-b border-edge-secondary">
{children}
</h2>
),
h3: ({ children }) => <h3 className="text-[15.5px] font-semibold text-content mt-6 mb-2">{children}</h3>,
h4: ({ children }) => <h4 className="text-[14px] font-semibold text-content mt-5 mb-2">{children}</h4>,
p: ({ children }) => <p className="text-[14px] text-content-secondary leading-[1.7] my-3">{children}</p>,
ul: ({ children }) => <ul className="list-disc pl-5 my-3 space-y-1.5 text-[14px] text-content-secondary">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal pl-5 my-3 space-y-1.5 text-[14px] text-content-secondary">{children}</ol>,
li: ({ children }) => <li className="leading-[1.6]">{children}</li>,
a: ({ href, children }) => {
const url = href ?? ''
if (url.startsWith('#')) return <a href={url} className="text-accent hover:underline">{children}</a>
if (url.startsWith('/')) return <Link to={url} className="text-accent hover:underline font-medium">{children}</Link>
return (
<a href={url} target="_blank" rel="noopener noreferrer" className="text-accent hover:underline font-medium">
{children}
</a>
)
},
img: ({ src, alt }) => (
<img src={typeof src === 'string' ? src : ''} alt={alt} loading="lazy" className="rounded-lg border border-edge my-4 max-w-full" />
),
code: ({ className, children }) => {
const isBlock = (className ?? '').includes('language-')
if (isBlock) return <code className={className}>{children}</code>
return <code className="bg-surface-tertiary text-content rounded px-1.5 py-0.5 text-[12.5px] font-mono">{children}</code>
},
pre: ({ children }) => (
<pre className="bg-surface-tertiary text-content rounded-xl p-4 my-4 overflow-x-auto text-[12.5px] font-mono leading-relaxed border border-edge-secondary">
{children}
</pre>
),
blockquote: ({ children }) => (
<blockquote className="border-l-3 border-accent bg-accent-subtle/40 rounded-r-lg px-4 py-1 my-4 text-content-secondary">
{children}
</blockquote>
),
table: ({ children }) => (
<div className="overflow-x-auto my-4">
<table className="w-full text-[13px] border-collapse">{children}</table>
</div>
),
th: ({ children }) => (
<th className="text-left font-semibold text-content border border-edge-secondary px-3 py-2 bg-surface-tertiary">
{children}
</th>
),
td: ({ children }) => <td className="text-content-secondary border border-edge-secondary px-3 py-2">{children}</td>,
hr: () => <hr className="my-6 border-edge-secondary" />,
}}
>
{markdown}
</Markdown>
)
}
+12 -1
View File
@@ -11,7 +11,7 @@ export default function LoginPage(): React.ReactElement {
navigate,
mode, setMode,
username, setUsername, email, setEmail, password, setPassword, rememberMe, setRememberMe, showPassword, setShowPassword,
isLoading, error, setError, appConfig, inviteToken,
isLoading, error, setError, insecureCookie, appConfig, inviteToken,
langDropdownOpen, setLangDropdownOpen, setLanguageLocal,
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
passwordChangeStep, newPassword, setNewPassword, confirmPassword, setConfirmPassword,
@@ -447,6 +447,17 @@ export default function LoginPage(): React.ReactElement {
</div>
)}
{insecureCookie && (
<div style={{ padding: '12px 14px', background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: '#92400e' }}>
<div style={{ fontWeight: 700, marginBottom: 4 }}>{t('login.insecureCookie.title')}</div>
<div style={{ lineHeight: 1.55 }}>{t('login.insecureCookie.body')}</div>
<a href="https://github.com/mauriceboe/TREK/wiki/Troubleshooting" target="_blank" rel="noopener noreferrer"
style={{ display: 'inline-block', marginTop: 6, fontWeight: 600, color: '#b45309', textDecoration: 'underline' }}>
{t('login.insecureCookie.link')}
</a>
</div>
)}
{passwordChangeStep && (
<>
<div style={{ padding: '10px 14px', background: '#fefce8', border: '1px solid #fde68a', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: '#92400e' }}>
+56
View File
@@ -0,0 +1,56 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useParams } from 'react-router-dom'
import { helpApi, type HelpNavSection, type HelpPageData } from '../../api/client'
/** State + data loading for the in-app help wiki (see PATTERN.md). */
export function useHelp() {
const { slug } = useParams<{ slug: string }>()
const [sections, setSections] = useState<HelpNavSection[]>([])
const [page, setPage] = useState<HelpPageData | null>(null)
const [loading, setLoading] = useState(true)
const [pageError, setPageError] = useState(false)
const [query, setQuery] = useState('')
const [navOpen, setNavOpen] = useState(false)
const contentRef = useRef<HTMLDivElement>(null)
useEffect(() => {
helpApi.index().then((d) => setSections(d.sections)).catch(() => setSections([]))
}, [])
const homeSlug = sections[0]?.pages[0]?.slug ?? 'Home'
const activeSlug = slug ?? homeSlug
useEffect(() => {
let alive = true
setLoading(true)
setPageError(false)
helpApi
.page(activeSlug)
.then((p) => {
if (!alive) return
setPage(p)
setLoading(false)
})
.catch(() => {
if (!alive) return
setPageError(true)
setLoading(false)
})
contentRef.current?.scrollTo?.({ top: 0 })
window.scrollTo?.({ top: 0 })
setNavOpen(false)
return () => {
alive = false
}
}, [activeSlug])
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return sections
return sections
.map((s) => ({ ...s, pages: s.pages.filter((p) => p.title.toLowerCase().includes(q)) }))
.filter((s) => s.pages.length > 0)
}, [sections, query])
return { page, loading, pageError, query, setQuery, navOpen, setNavOpen, contentRef, activeSlug, filtered }
}
+12 -1
View File
@@ -41,6 +41,9 @@ export function useLogin() {
const [showPassword, setShowPassword] = useState<boolean>(false)
const [isLoading, setIsLoading] = useState<boolean>(false)
const [error, setError] = useState<string>('')
// Set when the server signals it just issued a Secure cookie over plain HTTP —
// the browser drops it, so we explain the fix instead of a bare 401 later.
const [insecureCookie, setInsecureCookie] = useState(false)
const [appConfig, setAppConfig] = useState<AppConfig | null>(null)
const [inviteToken, setInviteToken] = useState<string>('')
const [inviteValid, setInviteValid] = useState<boolean>(false)
@@ -225,6 +228,7 @@ export function useLogin() {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault()
setError('')
setInsecureCookie(false)
setIsLoading(true)
try {
if (passwordChangeStep) {
@@ -260,6 +264,13 @@ export function useLogin() {
await register(username, email, password, inviteToken || undefined)
} else {
const result = await login(email, password, rememberMe)
if ((result as { insecureCookie?: boolean }).insecureCookie) {
// Credentials were correct, but the secure cookie won't survive plain
// HTTP — proceeding would just dead-end on "Access token required".
setInsecureCookie(true)
setIsLoading(false)
return
}
if ('mfa_required' in result && result.mfa_required && 'mfa_token' in result) {
setMfaToken(result.mfa_token)
setMfaStep(true)
@@ -291,7 +302,7 @@ export function useLogin() {
navigate,
mode, setMode,
username, setUsername, email, setEmail, password, setPassword, rememberMe, setRememberMe, showPassword, setShowPassword,
isLoading, error, setError, appConfig, inviteToken,
isLoading, error, setError, insecureCookie, appConfig, inviteToken,
langDropdownOpen, setLangDropdownOpen, setLanguageLocal,
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
passwordChangeStep, newPassword, setNewPassword, confirmPassword, setConfirmPassword,
+2 -1
View File
@@ -4,6 +4,7 @@ import { DatabaseModule } from './database/database.module';
import { HealthController } from './health/health.controller';
import { HealthService } from './health/health.service';
import { WeatherModule } from './weather/weather.module';
import { HelpModule } from './help/help.module';
import { AirportsModule } from './airports/airports.module';
import { ConfigModule } from './config/config.module';
import { SystemNoticesModule } from './system-notices/system-notices.module';
@@ -46,7 +47,7 @@ import { IdempotencyInterceptor } from './common/idempotency.interceptor';
* migrated.
*/
@Module({
imports: [DatabaseModule, WeatherModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, ShareModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, BookingImportModule],
imports: [DatabaseModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, ShareModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, BookingImportModule],
controllers: [HealthController],
providers: [
HealthService,
@@ -4,6 +4,7 @@ import { AuthService } from './auth.service';
import { RateLimitService } from './rate-limit.service';
import { OptionalJwtGuard } from './optional-jwt.guard';
import { writeAudit, getClientIp } from '../../services/auditLog';
import { willDropSecureCookie } from '../../services/cookie';
import type { User } from '../../types';
const WINDOW = 15 * 60 * 1000;
@@ -88,7 +89,13 @@ export class AuthPublicController {
return { mfa_required: true, mfa_token: result.mfa_token };
}
this.auth.setAuthCookie(res, result.token!, req, result.remember);
return { token: result.token, user: result.user };
return {
token: result.token,
user: result.user,
// Surfaced so the client can explain the plain-HTTP cookie gotcha instead
// of the user hitting a bare "Access token required" on the next request.
...(willDropSecureCookie(req) ? { insecureCookie: true } : {}),
};
}
@Post('forgot-password')
+50
View File
@@ -0,0 +1,50 @@
import { Controller, Get, Param, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import {
getWikiIndex,
getWikiPage,
getWikiAsset,
WikiNotFound,
type WikiPage,
type WikiNavSection,
} from '../../services/wikiService';
/**
* /api/help — embedded TREK wiki. Content is public docs (the wiki is public on
* GitHub), so these endpoints are unauthenticated; that also lets <img> tags load
* the proxied assets without sending credentials. All upstream calls go to the
* fixed TREK wiki path and are cached server-side.
*/
@Controller('api/help')
export class HelpController {
@Get('index')
index(): Promise<{ sections: WikiNavSection[] }> {
return getWikiIndex();
}
@Get('page/:slug')
async page(@Param('slug') slug: string, @Res() res: Response): Promise<void> {
try {
const page: WikiPage = await getWikiPage(slug);
res.json(page);
} catch (err) {
res.status(err instanceof WikiNotFound ? 404 : 502).json({ error: 'Help page unavailable' });
}
}
@Get('asset/*')
async asset(@Req() req: Request, @Res() res: Response): Promise<void> {
// Take everything after `/asset/` straight from the URL — the Express
// wildcard param isn't reliably populated through the Nest adapter.
const after = (req.originalUrl || req.url).split('/asset/')[1] ?? '';
const assetPath = decodeURIComponent(after.split('?')[0]);
try {
const { buf, type } = await getWikiAsset(assetPath);
res.setHeader('Content-Type', type);
res.setHeader('Cache-Control', 'public, max-age=3600');
res.end(buf);
} catch {
res.status(404).end();
}
}
}
+8
View File
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { HelpController } from './help.controller';
/** /api/help — embedded GitHub wiki (fetched + cached in wikiService). */
@Module({
controllers: [HelpController],
})
export class HelpModule {}
+16
View File
@@ -54,6 +54,22 @@ function buildOptions(clear: boolean, secure: boolean, remember?: RememberOption
};
}
/**
* True when we are about to set a `Secure` session cookie but the request did
* NOT arrive over HTTPS — the browser silently drops the cookie, so the next
* request has no session and the server answers "Access token required". This is
* the classic plain-HTTP install gotcha; callers surface it to the user with a
* concrete fix (use HTTPS or set COOKIE_SECURE=false) instead of a bare 401.
*/
export function willDropSecureCookie(req?: Request): boolean {
if (process.env.COOKIE_SECURE?.toLowerCase() === 'false') return false;
if (req?.secure === true) return false;
return (
process.env.NODE_ENV?.toLowerCase() === 'production' ||
process.env.FORCE_HTTPS?.toLowerCase() === 'true'
);
}
export function setAuthCookie(res: Response, token: string, req?: Request, remember?: RememberOption): void {
res.cookie(COOKIE_NAME, token, cookieOptions(false, req, remember));
}
+180
View File
@@ -0,0 +1,180 @@
import path from 'path';
import { promises as fs } from 'fs';
/**
* In-app Help/Wiki content, sourced from the TREK GitHub wiki (kept in the repo
* under `wiki/**` and mirrored to the GitHub wiki on push to main). The server
* fetches the markdown from GitHub and caches it, so the embedded help stays in
* sync with wiki edits without a redeploy — and the client never talks to GitHub
* directly (images are proxied too). A bundled snapshot under server/assets/wiki
* is the cold-start / offline fallback.
*/
const REPO = 'mauriceboe/TREK';
const RAW_BASE = `https://raw.githubusercontent.com/${REPO}/main/wiki`;
const TTL_MS = 60 * 60 * 1000; // refresh from GitHub at most hourly
const SNAPSHOT_DIR = path.join(__dirname, '..', '..', 'assets', 'wiki');
const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
export class WikiNotFound extends Error {
status = 404;
}
interface TextEntry {
data: string;
ts: number;
}
const textCache = new Map<string, TextEntry>();
const assetCache = new Map<string, { buf: Buffer; type: string; ts: number }>();
const fresh = (ts: number): boolean => Date.now() - ts < TTL_MS;
async function readSnapshot(file: string): Promise<string | null> {
try {
return await fs.readFile(path.join(SNAPSHOT_DIR, file), 'utf8');
} catch {
return null;
}
}
/** Fetch a wiki text file with cache → stale-cache → bundled-snapshot fallback. */
async function fetchText(file: string): Promise<string> {
const cached = textCache.get(file);
if (cached && fresh(cached.ts)) return cached.data;
try {
const res = await fetch(`${RAW_BASE}/${encodeURIComponent(file)}`, {
headers: { 'User-Agent': 'TREK-help', Accept: 'text/plain' },
});
if (res.ok) {
const text = await res.text();
textCache.set(file, { data: text, ts: Date.now() });
return text;
}
if (res.status === 404) throw new WikiNotFound(file);
} catch (err) {
if (err instanceof WikiNotFound) throw err;
// network/parse error — fall through to stale cache or snapshot
}
if (cached) return cached.data; // serve stale rather than fail
const snap = await readSnapshot(file);
if (snap != null) {
textCache.set(file, { data: snap, ts: Date.now() });
return snap;
}
throw new WikiNotFound(file);
}
export interface WikiNavItem {
title: string;
slug: string;
}
export interface WikiNavSection {
title: string;
pages: WikiNavItem[];
}
/** Parse the wiki `_Sidebar.md` into ordered sections of `[[Title|Slug]]` links. */
function parseSidebar(md: string): WikiNavSection[] {
const sections: WikiNavSection[] = [];
let current: WikiNavSection | null = null;
for (const raw of md.split('\n')) {
const heading = raw.match(/^#{1,4}\s+(.+?)\s*$/);
if (heading) {
current = { title: heading[1].replace(/[*_`]/g, '').trim(), pages: [] };
sections.push(current);
continue;
}
const link = raw.match(/^\s*[-*]\s*\[\[([^\]]+)\]\]/);
if (link) {
if (!current) {
current = { title: '', pages: [] };
sections.push(current);
}
const inner = link[1];
const [title, slugRaw] = inner.includes('|') ? inner.split('|') : [inner, inner];
const slug = slugRaw.trim().replace(/\s+/g, '-');
if (SLUG_RE.test(slug)) current.pages.push({ title: title.trim(), slug });
}
}
return sections.filter((s) => s.pages.length > 0);
}
/** Rewrite GitHub-wiki `[[..]]` links to /help routes and proxy relative images. */
function processMarkdown(md: string): string {
// Strip HTML comments (e.g. `<!-- TODO: screenshot … -->` placeholders) — the
// markdown renderer would otherwise surface them as raw text.
let out = md.replace(/<!--[\s\S]*?-->/g, '');
out = out.replace(/\[\[([^\]]+)\]\]/g, (_m, inner: string) => {
const [title, slugRaw] = inner.includes('|') ? inner.split('|') : [inner, inner];
const slug = slugRaw.trim().replace(/\s+/g, '-');
return `[${title.trim()}](/help/${slug})`;
});
out = out.replace(/!\[([^\]]*)\]\(([^)\s]+)([^)]*)\)/g, (m, alt: string, url: string) => {
if (/^https?:\/\//i.test(url) || url.startsWith('/api/help/asset/')) return m;
const clean = url.replace(/^\.?\//, '').replace(/^wiki\//, '');
return `![${alt}](/api/help/asset/${clean})`;
});
return out;
}
function extractTitle(md: string, fallback: string): string {
const h1 = md.match(/^#\s+(.+?)\s*$/m);
return h1 ? h1[1].replace(/[*_`]/g, '').trim() : fallback.replace(/-/g, ' ');
}
export interface WikiPage {
slug: string;
title: string;
markdown: string;
}
export async function getWikiIndex(): Promise<{ sections: WikiNavSection[] }> {
const md = await fetchText('_Sidebar.md');
return { sections: parseSidebar(md) };
}
export async function getWikiPage(slug: string): Promise<WikiPage> {
if (!SLUG_RE.test(slug)) throw new WikiNotFound(slug);
const md = await fetchText(`${slug}.md`);
return { slug, title: extractTitle(md, slug), markdown: processMarkdown(md) };
}
const ASSET_TYPES: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
};
/** Proxy a wiki image so the browser never calls GitHub directly. */
export async function getWikiAsset(assetPath: string): Promise<{ buf: Buffer; type: string }> {
// Defend against traversal; allow nested image folders.
if (assetPath.includes('..') || !/^[A-Za-z0-9/._-]+$/.test(assetPath)) throw new WikiNotFound(assetPath);
const ext = path.extname(assetPath).toLowerCase();
const type = ASSET_TYPES[ext];
if (!type) throw new WikiNotFound(assetPath);
const cached = assetCache.get(assetPath);
if (cached && fresh(cached.ts)) return { buf: cached.buf, type: cached.type };
try {
const res = await fetch(`${RAW_BASE}/${assetPath.split('/').map(encodeURIComponent).join('/')}`, {
headers: { 'User-Agent': 'TREK-help' },
});
if (res.ok) {
const buf = Buffer.from(await res.arrayBuffer());
assetCache.set(assetPath, { buf, type, ts: Date.now() });
return { buf, type };
}
} catch {
/* fall through */
}
if (cached) return { buf: cached.buf, type: cached.type };
try {
const buf = await fs.readFile(path.join(SNAPSHOT_DIR, assetPath));
return { buf, type };
} catch {
throw new WikiNotFound(assetPath);
}
}
+13
View File
@@ -0,0 +1,13 @@
import type { TranslationStrings } from '../types';
// English fallback until 'ar' is translated.
const help: TranslationStrings = {
'help.title': "Help & Docs",
'help.search': "Search docs…",
'help.contents': "Contents",
'help.noResults': "No matching pages.",
'help.errorTitle': "Couldn't load this page",
'help.errorBody': "The help content is fetched from the TREK wiki. Check your connection and try again.",
};
export default help;
+2
View File
@@ -39,6 +39,7 @@ import trip from './trip';
import trips from './trips';
import undo from './undo';
import vacay from './vacay';
import help from './help';
const locale = {
...common,
@@ -82,5 +83,6 @@ const locale = {
...oauth,
...system_notice,
...transport,
...help,
};
export default locale;
+3
View File
@@ -85,5 +85,8 @@ const login: TranslationStrings = {
'login.emailPlaceholder': 'your@email.com', // en-fallback
'login.passkey.signIn': 'تسجيل الدخول باستخدام مفتاح المرور',
'login.passkey.failed': 'فشل تسجيل الدخول بمفتاح المرور. يرجى المحاولة مرة أخرى.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+1
View File
@@ -16,5 +16,6 @@ const nav: TranslationStrings = {
'nav.bottomAdmin': 'إعدادات المسؤول',
'nav.bottomLogout': 'تسجيل الخروج',
'nav.bottomAdminBadge': 'مسؤول',
'nav.help': 'Help',
};
export default nav;
+13
View File
@@ -0,0 +1,13 @@
import type { TranslationStrings } from '../types';
// English fallback until 'br' is translated.
const help: TranslationStrings = {
'help.title': "Help & Docs",
'help.search': "Search docs…",
'help.contents': "Contents",
'help.noResults': "No matching pages.",
'help.errorTitle': "Couldn't load this page",
'help.errorBody': "The help content is fetched from the TREK wiki. Check your connection and try again.",
};
export default help;
+2
View File
@@ -39,6 +39,7 @@ import trip from './trip';
import trips from './trips';
import undo from './undo';
import vacay from './vacay';
import help from './help';
const locale = {
...common,
@@ -82,5 +83,6 @@ const locale = {
...oauth,
...system_notice,
...transport,
...help,
};
export default locale;
+3
View File
@@ -84,5 +84,8 @@ const login: TranslationStrings = {
'login.resetPasswordFailed': 'Falha na redefinição. O link pode ter expirado.',
'login.passkey.signIn': 'Entrar com uma passkey',
'login.passkey.failed': 'Falha ao entrar com passkey. Tente novamente.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+1
View File
@@ -16,5 +16,6 @@ const nav: TranslationStrings = {
'nav.bottomAdmin': 'Administração',
'nav.bottomLogout': 'Sair',
'nav.bottomAdminBadge': 'Admin',
'nav.help': 'Help',
};
export default nav;
+13
View File
@@ -0,0 +1,13 @@
import type { TranslationStrings } from '../types';
// English fallback until 'cs' is translated.
const help: TranslationStrings = {
'help.title': "Help & Docs",
'help.search': "Search docs…",
'help.contents': "Contents",
'help.noResults': "No matching pages.",
'help.errorTitle': "Couldn't load this page",
'help.errorBody': "The help content is fetched from the TREK wiki. Check your connection and try again.",
};
export default help;
+2
View File
@@ -39,6 +39,7 @@ import trip from './trip';
import trips from './trips';
import undo from './undo';
import vacay from './vacay';
import help from './help';
const locale = {
...common,
@@ -82,5 +83,6 @@ const locale = {
...oauth,
...system_notice,
...transport,
...help,
};
export default locale;
+3
View File
@@ -84,5 +84,8 @@ const login: TranslationStrings = {
'login.resetPasswordFailed': 'Obnovení se nezdařilo. Odkaz mohl vypršet.',
'login.passkey.signIn': 'Přihlásit se pomocí přístupového klíče',
'login.passkey.failed': 'Přihlášení přístupovým klíčem se nezdařilo. Zkuste to prosím znovu.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+1
View File
@@ -16,5 +16,6 @@ const nav: TranslationStrings = {
'nav.bottomAdmin': 'Nastavení správce',
'nav.bottomLogout': 'Odhlásit se',
'nav.bottomAdminBadge': 'Správce',
'nav.help': 'Help',
};
export default nav;
+13
View File
@@ -0,0 +1,13 @@
import type { TranslationStrings } from '../types';
const help: TranslationStrings = {
'help.title': 'Hilfe & Doku',
'help.search': 'Doku durchsuchen…',
'help.contents': 'Inhalt',
'help.noResults': 'Keine passenden Seiten.',
'help.errorTitle': 'Seite konnte nicht geladen werden',
'help.errorBody':
'Die Hilfe-Inhalte kommen aus dem TREK-Wiki. Prüfe deine Verbindung und versuch es erneut.',
};
export default help;
+2
View File
@@ -39,6 +39,7 @@ import trip from './trip';
import trips from './trips';
import undo from './undo';
import vacay from './vacay';
import help from './help';
const locale = {
...common,
@@ -82,5 +83,6 @@ const locale = {
...oauth,
...system_notice,
...transport,
...help,
};
export default locale;
+3
View File
@@ -86,5 +86,8 @@ const login: TranslationStrings = {
'login.resetPasswordFailed': 'Zurücksetzen fehlgeschlagen. Der Link ist möglicherweise abgelaufen.',
'login.passkey.signIn': 'Mit Passkey anmelden',
'login.passkey.failed': 'Anmeldung mit Passkey fehlgeschlagen. Bitte erneut versuchen.',
'login.insecureCookie.title': "Login hält über HTTP nicht",
'login.insecureCookie.body': "Du verbindest dich über reines HTTP, daher verwirft dein Browser TREKs sicheren Session-Cookie — die nächste Anfrage scheitert mit „Access token required\". Lösung: HTTPS nutzen, oder für ein Heim-Setup COOKIE_SECURE=false setzen.",
'login.insecureCookie.link': "Zur Troubleshooting-Anleitung",
};
export default login;
+1
View File
@@ -16,5 +16,6 @@ const nav: TranslationStrings = {
'nav.bottomAdmin': 'Admin-Einstellungen',
'nav.bottomLogout': 'Abmelden',
'nav.bottomAdminBadge': 'Admin',
'nav.help': 'Hilfe',
};
export default nav;
+13
View File
@@ -0,0 +1,13 @@
import type { TranslationStrings } from '../types';
const help: TranslationStrings = {
'help.title': 'Help & Docs',
'help.search': 'Search docs…',
'help.contents': 'Contents',
'help.noResults': 'No matching pages.',
'help.errorTitle': "Couldn't load this page",
'help.errorBody':
'The help content is fetched from the TREK wiki. Check your connection and try again.',
};
export default help;
+2
View File
@@ -39,6 +39,7 @@ import trip from './trip';
import trips from './trips';
import undo from './undo';
import vacay from './vacay';
import help from './help';
const locale = {
...common,
@@ -82,5 +83,6 @@ const locale = {
...oauth,
...system_notice,
...transport,
...help,
};
export default locale;
+3
View File
@@ -85,5 +85,8 @@ const login: TranslationStrings = {
'login.resetPasswordFailed': 'Reset failed. The link may have expired.',
'login.passkey.signIn': 'Sign in with a passkey',
'login.passkey.failed': 'Passkey sign-in failed. Please try again.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+1
View File
@@ -16,5 +16,6 @@ const nav: TranslationStrings = {
'nav.bottomAdmin': 'Admin Settings',
'nav.bottomLogout': 'Logout',
'nav.bottomAdminBadge': 'Admin',
'nav.help': 'Help',
};
export default nav;
+13
View File
@@ -0,0 +1,13 @@
import type { TranslationStrings } from '../types';
// English fallback until 'es' is translated.
const help: TranslationStrings = {
'help.title': "Help & Docs",
'help.search': "Search docs…",
'help.contents': "Contents",
'help.noResults': "No matching pages.",
'help.errorTitle': "Couldn't load this page",
'help.errorBody': "The help content is fetched from the TREK wiki. Check your connection and try again.",
};
export default help;
+2
View File
@@ -39,6 +39,7 @@ import trip from './trip';
import trips from './trips';
import undo from './undo';
import vacay from './vacay';
import help from './help';
const locale = {
...common,
@@ -82,5 +83,6 @@ const locale = {
...oauth,
...system_notice,
...transport,
...help,
};
export default locale;
+3
View File
@@ -86,5 +86,8 @@ const login: TranslationStrings = {
'login.oidcLoggedOut': 'Has cerrado sesión. Vuelve a iniciar sesión con tu proveedor SSO.',
'login.passkey.signIn': 'Iniciar sesión con una passkey',
'login.passkey.failed': 'Error al iniciar sesión con la passkey. Inténtalo de nuevo.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+1
View File
@@ -16,5 +16,6 @@ const nav: TranslationStrings = {
'nav.bottomAdmin': 'Administración',
'nav.bottomLogout': 'Cerrar sesión',
'nav.bottomAdminBadge': 'Admin',
'nav.help': 'Help',
};
export default nav;
+13
View File
@@ -0,0 +1,13 @@
import type { TranslationStrings } from '../types';
// English fallback until 'fr' is translated.
const help: TranslationStrings = {
'help.title': "Help & Docs",
'help.search': "Search docs…",
'help.contents': "Contents",
'help.noResults': "No matching pages.",
'help.errorTitle': "Couldn't load this page",
'help.errorBody': "The help content is fetched from the TREK wiki. Check your connection and try again.",
};
export default help;
+2
View File
@@ -39,6 +39,7 @@ import trip from './trip';
import trips from './trips';
import undo from './undo';
import vacay from './vacay';
import help from './help';
const locale = {
...common,
@@ -82,5 +83,6 @@ const locale = {
...oauth,
...system_notice,
...transport,
...help,
};
export default locale;
+3
View File
@@ -88,5 +88,8 @@ const login: TranslationStrings = {
'login.demoHint': 'Essayez la démo — aucune inscription nécessaire',
'login.passkey.signIn': 'Se connecter avec une passkey',
'login.passkey.failed': 'Échec de la connexion par passkey. Veuillez réessayer.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+1
View File
@@ -16,5 +16,6 @@ const nav: TranslationStrings = {
'nav.bottomAdmin': 'Administration',
'nav.bottomLogout': 'Déconnexion',
'nav.bottomAdminBadge': 'Admin',
'nav.help': 'Help',
};
export default nav;
+13
View File
@@ -0,0 +1,13 @@
import type { TranslationStrings } from '../types';
// English fallback until 'gr' is translated.
const help: TranslationStrings = {
'help.title': "Help & Docs",
'help.search': "Search docs…",
'help.contents': "Contents",
'help.noResults': "No matching pages.",
'help.errorTitle': "Couldn't load this page",
'help.errorBody': "The help content is fetched from the TREK wiki. Check your connection and try again.",
};
export default help;
+2
View File
@@ -39,6 +39,7 @@ import trip from './trip';
import trips from './trips';
import undo from './undo';
import vacay from './vacay';
import help from './help';
const locale = {
...common,
@@ -82,5 +83,6 @@ const locale = {
...oauth,
...system_notice,
...transport,
...help,
};
export default locale;
+3
View File
@@ -88,5 +88,8 @@ const login: TranslationStrings = {
'login.resetPasswordFailed': 'Η επαναφορά απέτυχε. Ο σύνδεσμος μπορεί να έχει λήξει.',
'login.passkey.signIn': 'Σύνδεση με passkey',
'login.passkey.failed': 'Η σύνδεση με passkey απέτυχε. Παρακαλώ δοκιμάστε ξανά.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+1
View File
@@ -16,5 +16,6 @@ const nav: TranslationStrings = {
'nav.bottomAdmin': 'Ρυθμίσεις Διαχειριστή',
'nav.bottomLogout': 'Αποσύνδεση',
'nav.bottomAdminBadge': 'Διαχειριστής',
'nav.help': 'Help',
};
export default nav;
+13
View File
@@ -0,0 +1,13 @@
import type { TranslationStrings } from '../types';
// English fallback until 'hu' is translated.
const help: TranslationStrings = {
'help.title': "Help & Docs",
'help.search': "Search docs…",
'help.contents': "Contents",
'help.noResults': "No matching pages.",
'help.errorTitle': "Couldn't load this page",
'help.errorBody': "The help content is fetched from the TREK wiki. Check your connection and try again.",
};
export default help;
+2
View File
@@ -39,6 +39,7 @@ import trip from './trip';
import trips from './trips';
import undo from './undo';
import vacay from './vacay';
import help from './help';
const locale = {
...common,
@@ -82,5 +83,6 @@ const locale = {
...oauth,
...system_notice,
...transport,
...help,
};
export default locale;
+3
View File
@@ -86,5 +86,8 @@ const login: TranslationStrings = {
'login.resetPasswordFailed': 'A visszaállítás nem sikerült. A link lehet, hogy lejárt.',
'login.passkey.signIn': 'Bejelentkezés passkey-jel',
'login.passkey.failed': 'A passkey-bejelentkezés sikertelen. Kérjük, próbáld újra.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+1
View File
@@ -16,5 +16,6 @@ const nav: TranslationStrings = {
'nav.bottomAdmin': 'Adminisztráció',
'nav.bottomLogout': 'Kijelentkezés',
'nav.bottomAdminBadge': 'Admin',
'nav.help': 'Help',
};
export default nav;
+13
View File
@@ -0,0 +1,13 @@
import type { TranslationStrings } from '../types';
// English fallback until 'id' is translated.
const help: TranslationStrings = {
'help.title': "Help & Docs",
'help.search': "Search docs…",
'help.contents': "Contents",
'help.noResults': "No matching pages.",
'help.errorTitle': "Couldn't load this page",
'help.errorBody': "The help content is fetched from the TREK wiki. Check your connection and try again.",
};
export default help;
+2
View File
@@ -39,6 +39,7 @@ import trip from './trip';
import trips from './trips';
import undo from './undo';
import vacay from './vacay';
import help from './help';
const locale = {
...common,
@@ -82,5 +83,6 @@ const locale = {
...oauth,
...system_notice,
...transport,
...help,
};
export default locale;
+3
View File
@@ -83,5 +83,8 @@ const login: TranslationStrings = {
'login.resetPasswordFailed': 'Reset gagal. Tautan mungkin sudah kedaluwarsa.',
'login.passkey.signIn': 'Masuk dengan passkey',
'login.passkey.failed': 'Masuk dengan passkey gagal. Silakan coba lagi.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+1
View File
@@ -16,5 +16,6 @@ const nav: TranslationStrings = {
'nav.bottomAdmin': 'Pengaturan Admin',
'nav.bottomLogout': 'Keluar',
'nav.bottomAdminBadge': 'Admin',
'nav.help': 'Help',
};
export default nav;
+13
View File
@@ -0,0 +1,13 @@
import type { TranslationStrings } from '../types';
// English fallback until 'it' is translated.
const help: TranslationStrings = {
'help.title': "Help & Docs",
'help.search': "Search docs…",
'help.contents': "Contents",
'help.noResults': "No matching pages.",
'help.errorTitle': "Couldn't load this page",
'help.errorBody': "The help content is fetched from the TREK wiki. Check your connection and try again.",
};
export default help;
+2
View File
@@ -39,6 +39,7 @@ import trip from './trip';
import trips from './trips';
import undo from './undo';
import vacay from './vacay';
import help from './help';
const locale = {
...common,
@@ -82,5 +83,6 @@ const locale = {
...oauth,
...system_notice,
...transport,
...help,
};
export default locale;
+3
View File
@@ -85,5 +85,8 @@ const login: TranslationStrings = {
'login.resetPasswordFailed': 'Reset non riuscito. Il link potrebbe essere scaduto.',
'login.passkey.signIn': 'Accedi con una passkey',
'login.passkey.failed': 'Accesso con passkey non riuscito. Riprova.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+1
View File
@@ -16,5 +16,6 @@ const nav: TranslationStrings = {
'nav.bottomAdmin': 'Amministrazione',
'nav.bottomLogout': 'Disconnetti',
'nav.bottomAdminBadge': 'Admin',
'nav.help': 'Help',
};
export default nav;
+13
View File
@@ -0,0 +1,13 @@
import type { TranslationStrings } from '../types';
// English fallback until 'ja' is translated.
const help: TranslationStrings = {
'help.title': "Help & Docs",
'help.search': "Search docs…",
'help.contents': "Contents",
'help.noResults': "No matching pages.",
'help.errorTitle': "Couldn't load this page",
'help.errorBody': "The help content is fetched from the TREK wiki. Check your connection and try again.",
};
export default help;
+2
View File
@@ -39,6 +39,7 @@ import trip from './trip';
import trips from './trips';
import undo from './undo';
import vacay from './vacay';
import help from './help';
const locale = {
...common,
@@ -82,5 +83,6 @@ const locale = {
...oauth,
...system_notice,
...transport,
...help,
};
export default locale;
+3
View File
@@ -85,5 +85,8 @@ const login: TranslationStrings = {
'login.resetPasswordFailed': 'リセットに失敗しました。リンクの有効期限が切れている可能性があります。',
'login.passkey.signIn': 'パスキーでサインイン',
'login.passkey.failed': 'パスキーでのサインインに失敗しました。もう一度お試しください。',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+1
View File
@@ -16,5 +16,6 @@ const nav: TranslationStrings = {
'nav.bottomAdmin': '管理者設定',
'nav.bottomLogout': 'ログアウト',
'nav.bottomAdminBadge': '管理者',
'nav.help': 'Help',
};
export default nav;
+13
View File
@@ -0,0 +1,13 @@
import type { TranslationStrings } from '../types';
// English fallback until 'ko' is translated.
const help: TranslationStrings = {
'help.title': "Help & Docs",
'help.search': "Search docs…",
'help.contents': "Contents",
'help.noResults': "No matching pages.",
'help.errorTitle': "Couldn't load this page",
'help.errorBody': "The help content is fetched from the TREK wiki. Check your connection and try again.",
};
export default help;
+2
View File
@@ -39,6 +39,7 @@ import trip from './trip';
import trips from './trips';
import undo from './undo';
import vacay from './vacay';
import help from './help';
const locale = {
...common,
@@ -82,5 +83,6 @@ const locale = {
...oauth,
...system_notice,
...transport,
...help,
};
export default locale;
+3
View File
@@ -83,5 +83,8 @@ const login: TranslationStrings = {
'login.resetPasswordFailed': '재설정 실패. 링크가 만료되었을 수 있습니다.',
'login.passkey.signIn': '패스키로 로그인',
'login.passkey.failed': '패스키 로그인에 실패했습니다. 다시 시도하세요.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+1
View File
@@ -16,5 +16,6 @@ const nav: TranslationStrings = {
'nav.bottomAdmin': '관리자 설정',
'nav.bottomLogout': '로그아웃',
'nav.bottomAdminBadge': '관리자',
'nav.help': 'Help',
};
export default nav;
+13
View File
@@ -0,0 +1,13 @@
import type { TranslationStrings } from '../types';
// English fallback until 'nl' is translated.
const help: TranslationStrings = {
'help.title': "Help & Docs",
'help.search': "Search docs…",
'help.contents': "Contents",
'help.noResults': "No matching pages.",
'help.errorTitle': "Couldn't load this page",
'help.errorBody': "The help content is fetched from the TREK wiki. Check your connection and try again.",
};
export default help;
+2
View File
@@ -39,6 +39,7 @@ import trip from './trip';
import trips from './trips';
import undo from './undo';
import vacay from './vacay';
import help from './help';
const locale = {
...common,
@@ -82,5 +83,6 @@ const locale = {
...oauth,
...system_notice,
...transport,
...help,
};
export default locale;
+3
View File
@@ -85,5 +85,8 @@ const login: TranslationStrings = {
'login.demoHint': 'Probeer de demo — geen registratie nodig',
'login.passkey.signIn': 'Inloggen met een passkey',
'login.passkey.failed': 'Inloggen met passkey mislukt. Probeer het opnieuw.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+1
View File
@@ -16,5 +16,6 @@ const nav: TranslationStrings = {
'nav.bottomAdmin': 'Beheerdersinstellingen',
'nav.bottomLogout': 'Uitloggen',
'nav.bottomAdminBadge': 'Beheerder',
'nav.help': 'Help',
};
export default nav;
+13
View File
@@ -0,0 +1,13 @@
import type { TranslationStrings } from '../types';
// English fallback until 'pl' is translated.
const help: TranslationStrings = {
'help.title': "Help & Docs",
'help.search': "Search docs…",
'help.contents': "Contents",
'help.noResults': "No matching pages.",
'help.errorTitle': "Couldn't load this page",
'help.errorBody': "The help content is fetched from the TREK wiki. Check your connection and try again.",
};
export default help;
+2
View File
@@ -39,6 +39,7 @@ import trip from './trip';
import trips from './trips';
import undo from './undo';
import vacay from './vacay';
import help from './help';
const locale = {
...common,
@@ -82,5 +83,6 @@ const locale = {
...oauth,
...system_notice,
...transport,
...help,
};
export default locale;
+3
View File
@@ -85,5 +85,8 @@ const login: TranslationStrings = {
'login.setNewPasswordHint': 'Musisz zmienić hasło.',
'login.passkey.signIn': 'Zaloguj się kluczem dostępu',
'login.passkey.failed': 'Logowanie kluczem dostępu nie powiodło się. Spróbuj ponownie.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+1
View File
@@ -16,5 +16,6 @@ const nav: TranslationStrings = {
'nav.bottomAdmin': 'Ustawienia administratora',
'nav.bottomLogout': 'Wyloguj się',
'nav.bottomAdminBadge': 'Administrator',
'nav.help': 'Help',
};
export default nav;
+13
View File
@@ -0,0 +1,13 @@
import type { TranslationStrings } from '../types';
// English fallback until 'ru' is translated.
const help: TranslationStrings = {
'help.title': "Help & Docs",
'help.search': "Search docs…",
'help.contents': "Contents",
'help.noResults': "No matching pages.",
'help.errorTitle': "Couldn't load this page",
'help.errorBody': "The help content is fetched from the TREK wiki. Check your connection and try again.",
};
export default help;
+2
View File
@@ -39,6 +39,7 @@ import trip from './trip';
import trips from './trips';
import undo from './undo';
import vacay from './vacay';
import help from './help';
const locale = {
...common,
@@ -82,5 +83,6 @@ const locale = {
...oauth,
...system_notice,
...transport,
...help,
};
export default locale;
+3
View File
@@ -86,5 +86,8 @@ const login: TranslationStrings = {
'login.demoHint': 'Попробуйте демо — регистрация не требуется',
'login.passkey.signIn': 'Войти с помощью passkey',
'login.passkey.failed': 'Не удалось войти с помощью passkey. Попробуйте ещё раз.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+1
View File
@@ -16,5 +16,6 @@ const nav: TranslationStrings = {
'nav.bottomAdmin': 'Администрирование',
'nav.bottomLogout': 'Выйти',
'nav.bottomAdminBadge': 'Админ',
'nav.help': 'Help',
};
export default nav;
+13
View File
@@ -0,0 +1,13 @@
import type { TranslationStrings } from '../types';
// English fallback until 'sv' is translated.
const help: TranslationStrings = {
'help.title': "Help & Docs",
'help.search': "Search docs…",
'help.contents': "Contents",
'help.noResults': "No matching pages.",
'help.errorTitle': "Couldn't load this page",
'help.errorBody': "The help content is fetched from the TREK wiki. Check your connection and try again.",
};
export default help;
+2
View File
@@ -39,6 +39,7 @@ import trip from './trip';
import trips from './trips';
import undo from './undo';
import vacay from './vacay';
import help from './help';
const locale = {
...common,
@@ -82,5 +83,6 @@ const locale = {
...oauth,
...system_notice,
...transport,
...help,
};
export default locale;
+3
View File
@@ -85,5 +85,8 @@ const login: TranslationStrings = {
'login.resetPasswordFailed': 'Återställningen misslyckades. Länken kan ha gått ut.',
'login.passkey.signIn': 'Logga in med en inloggningsnyckel',
'login.passkey.failed': 'Inloggningsnyckel misslyckades. Försök igen.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+1
View File
@@ -16,5 +16,6 @@ const nav: TranslationStrings = {
'nav.bottomAdmin': 'Admin Inställningar',
'nav.bottomLogout': 'Logga ut',
'nav.bottomAdminBadge': 'Admin',
'nav.help': 'Help',
};
export default nav;
+13
View File
@@ -0,0 +1,13 @@
import type { TranslationStrings } from '../types';
// English fallback until 'tr' is translated.
const help: TranslationStrings = {
'help.title': "Help & Docs",
'help.search': "Search docs…",
'help.contents': "Contents",
'help.noResults': "No matching pages.",
'help.errorTitle': "Couldn't load this page",
'help.errorBody': "The help content is fetched from the TREK wiki. Check your connection and try again.",
};
export default help;
+2
View File
@@ -39,6 +39,7 @@ import trip from './trip';
import trips from './trips';
import undo from './undo';
import vacay from './vacay';
import help from './help';
const locale = {
...common,
@@ -82,5 +83,6 @@ const locale = {
...oauth,
...system_notice,
...transport,
...help,
};
export default locale;
+3
View File
@@ -86,5 +86,8 @@ const login: TranslationStrings = {
'login.resetPasswordFailed': 'Sıfırlama başarısız oldu. Bağlantının süresi dolmuş olabilir.',
'login.passkey.signIn': 'Passkey ile oturum açın',
'login.passkey.failed': 'Passkey ile oturum açma başarısız oldu. Lütfen tekrar deneyin.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+1
View File
@@ -16,5 +16,6 @@ const nav: TranslationStrings = {
'nav.bottomAdmin': 'Yönetici Ayarları',
'nav.bottomLogout': 'Çıkış',
'nav.bottomAdminBadge': 'Yönetici',
'nav.help': 'Help',
};
export default nav;
+13
View File
@@ -0,0 +1,13 @@
import type { TranslationStrings } from '../types';
// English fallback until 'uk' is translated.
const help: TranslationStrings = {
'help.title': "Help & Docs",
'help.search': "Search docs…",
'help.contents': "Contents",
'help.noResults': "No matching pages.",
'help.errorTitle': "Couldn't load this page",
'help.errorBody': "The help content is fetched from the TREK wiki. Check your connection and try again.",
};
export default help;
+2
View File
@@ -39,6 +39,7 @@ import trip from './trip';
import trips from './trips';
import undo from './undo';
import vacay from './vacay';
import help from './help';
const locale = {
...common,
@@ -82,5 +83,6 @@ const locale = {
...oauth,
...system_notice,
...transport,
...help,
};
export default locale;
+3
View File
@@ -85,5 +85,8 @@ const login: TranslationStrings = {
'login.demoHint': 'Спробуйте демо — реєстрація не потрібна',
'login.passkey.signIn': 'Увійти за допомогою passkey',
'login.passkey.failed': 'Не вдалося увійти за допомогою passkey. Спробуйте ще раз.',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+1
View File
@@ -16,5 +16,6 @@ const nav: TranslationStrings = {
'nav.bottomAdmin': 'Адміністрування',
'nav.bottomLogout': 'Вийти',
'nav.bottomAdminBadge': 'Адмін',
'nav.help': 'Help',
};
export default nav;
+13
View File
@@ -0,0 +1,13 @@
import type { TranslationStrings } from '../types';
// English fallback until 'zh-TW' is translated.
const help: TranslationStrings = {
'help.title': "Help & Docs",
'help.search': "Search docs…",
'help.contents': "Contents",
'help.noResults': "No matching pages.",
'help.errorTitle': "Couldn't load this page",
'help.errorBody': "The help content is fetched from the TREK wiki. Check your connection and try again.",
};
export default help;
+2
View File
@@ -39,6 +39,7 @@ import trip from './trip';
import trips from './trips';
import undo from './undo';
import vacay from './vacay';
import help from './help';
const locale = {
...common,
@@ -82,5 +83,6 @@ const locale = {
...oauth,
...system_notice,
...transport,
...help,
};
export default locale;
+3
View File
@@ -82,5 +82,8 @@ const login: TranslationStrings = {
'login.demoHint': '試用演示——無需註冊',
'login.passkey.signIn': '使用 Passkey 登入',
'login.passkey.failed': 'Passkey 登入失敗,請重試。',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+1
View File
@@ -16,5 +16,6 @@ const nav: TranslationStrings = {
'nav.bottomAdmin': '管理設定',
'nav.bottomLogout': '退出登入',
'nav.bottomAdminBadge': '管理員',
'nav.help': 'Help',
};
export default nav;
+13
View File
@@ -0,0 +1,13 @@
import type { TranslationStrings } from '../types';
// English fallback until 'zh' is translated.
const help: TranslationStrings = {
'help.title': "Help & Docs",
'help.search': "Search docs…",
'help.contents': "Contents",
'help.noResults': "No matching pages.",
'help.errorTitle': "Couldn't load this page",
'help.errorBody': "The help content is fetched from the TREK wiki. Check your connection and try again.",
};
export default help;
+2
View File
@@ -39,6 +39,7 @@ import trip from './trip';
import trips from './trips';
import undo from './undo';
import vacay from './vacay';
import help from './help';
const locale = {
...common,
@@ -82,5 +83,6 @@ const locale = {
...oauth,
...system_notice,
...transport,
...help,
};
export default locale;
+3
View File
@@ -82,5 +82,8 @@ const login: TranslationStrings = {
'login.demoHint': '试用演示——无需注册',
'login.passkey.signIn': '使用通行密钥登录',
'login.passkey.failed': '通行密钥登录失败,请重试。',
'login.insecureCookie.title': "Login won't stick over HTTP",
'login.insecureCookie.body': "Youre connecting over plain HTTP, so your browser drops TREKs secure session cookie — the next request fails with \"Access token required\". Fix: use HTTPS, or for a home-lab set COOKIE_SECURE=false.",
'login.insecureCookie.link': "Open the Troubleshooting guide",
};
export default login;
+1
View File
@@ -16,5 +16,6 @@ const nav: TranslationStrings = {
'nav.bottomAdmin': '管理设置',
'nav.bottomLogout': '退出登录',
'nav.bottomAdminBadge': '管理员',
'nav.help': 'Help',
};
export default nav;