+
+{s.carried_over} {t('vacay.carriedOver', { year: selectedYear - 1 })}
)}
diff --git a/client/src/components/shared/EmptyState.tsx b/client/src/components/shared/EmptyState.tsx
new file mode 100644
index 000000000..53d2133e6
--- /dev/null
+++ b/client/src/components/shared/EmptyState.tsx
@@ -0,0 +1,38 @@
+import type { CSSProperties } from 'react'
+import MDancingTrek, { type TrekScene, type TrekMood } from '../../mobile/components/MDancingTrek'
+
+/**
+ * The one desktop empty state: the TREK mascot acting out the page's scene with
+ * a single title beneath it — no subtitle, one uniform look everywhere.
+ *
+ * The mascot is monochrome and drives its colours off two mobile tokens
+ * (`--m-ink` body / `--m-bg` cutouts) that only exist inside the mobile shell,
+ * so we map them onto the desktop palette here. Its `.trek-*` choreography is
+ * global CSS, so it animates outside the shell as-is. `surface` should match the
+ * background the state sits on so the cut-out eyes read as holes (default: card).
+ */
+export default function EmptyState({
+ scene = 'idle',
+ mood,
+ title,
+ size = 104,
+ surface = 'var(--bg-card)',
+ className = '',
+}: {
+ scene?: TrekScene
+ mood?: TrekMood
+ title: string
+ size?: number
+ surface?: string
+ className?: string
+}) {
+ return (
+
+ )
+}
diff --git a/client/src/components/shared/TripLoadingSplash.tsx b/client/src/components/shared/TripLoadingSplash.tsx
new file mode 100644
index 000000000..47f626432
--- /dev/null
+++ b/client/src/components/shared/TripLoadingSplash.tsx
@@ -0,0 +1,220 @@
+import { useEffect, useState } from 'react'
+import { useTranslation } from '../../i18n'
+import MDancingTrek, { type TrekScene } from '../../mobile/components/MDancingTrek'
+
+/**
+ * Trip-open splash for desktop — the same little journey the mobile splash
+ * plays: the TREK mascot packs, hits the road, cruises in and drops the
+ * destination pin while the trip loads. It now takes over the viewport as a
+ * calm, Apple-style scene: a slowly drifting pastel gradient wash (soft
+ * pink/lavender/periwinkle, toned right down) whose tiles pan and whose hue
+ * gently breathes, dusted with a faint filmic grain, and the whole journey
+ * floating on a frosted "liquid glass" card. Monochrome mascot tokens map onto
+ * the desktop palette; the mascot's eye cut-outs (--m-bg) are pinned to the
+ * card surface so they read as clean holes. Under reduced motion the background
+ * freezes and the mascot parks on "loading photos". Palette + glass tint swap
+ * for light/dark via the .dark class the app puts on .
+ */
+const STEPS: { scene: TrekScene; key: string }[] = [
+ { scene: 'packing', key: 'trip.loadingSteps.pack' },
+ { scene: 'transport', key: 'trip.loadingSteps.road' },
+ { scene: 'dashboard', key: 'trip.loadingPhotos' },
+ { scene: 'collections', key: 'trip.loadingSteps.arrive' },
+]
+const STEP_MS = 1400
+const STILL_INDEX = 2
+
+const SPLASH_CSS = `
+.m-splash-root { background: #eef0fb; }
+.dark .m-splash-root { background: #0c0c12; }
+
+/* ── Drifting gradient wash ──────────────────────────────────────────────────
+ A layered pastel mesh whose tiles pan on a long ease-in-out loop, so nothing
+ ever snaps or races. Toned-down opacity keeps it airy rather than garish. */
+.m-splash-bg {
+ position: absolute;
+ inset: 0;
+ background-color: #eef0fb;
+ background-image:
+ radial-gradient(60% 60% at 50% 50%, rgba(244, 199, 221, 0.55), transparent 70%),
+ radial-gradient(56% 56% at 50% 50%, rgba(206, 201, 245, 0.50), transparent 72%),
+ radial-gradient(60% 60% at 50% 50%, rgba(190, 209, 247, 0.52), transparent 72%),
+ radial-gradient(52% 52% at 50% 50%, rgba(238, 214, 236, 0.44), transparent 72%);
+ background-repeat: no-repeat;
+ background-size: 122% 122%, 132% 132%, 126% 126%, 116% 116%;
+ background-position: 12% 20%, 82% 18%, 74% 82%, 22% 78%;
+ animation: m-splash-drift 30s ease-in-out infinite alternate;
+ will-change: background-position;
+}
+.dark .m-splash-bg {
+ background-color: #0c0c12;
+ background-image:
+ radial-gradient(60% 60% at 50% 50%, rgba(158, 116, 192, 0.30), transparent 70%),
+ radial-gradient(56% 56% at 50% 50%, rgba(112, 122, 196, 0.28), transparent 72%),
+ radial-gradient(60% 60% at 50% 50%, rgba(90, 132, 194, 0.26), transparent 72%),
+ radial-gradient(52% 52% at 50% 50%, rgba(164, 112, 176, 0.24), transparent 72%);
+}
+@keyframes m-splash-drift {
+ 0% { background-position: 12% 20%, 82% 18%, 74% 82%, 22% 78%; }
+ 100% { background-position: 26% 36%, 66% 30%, 58% 68%, 36% 62%; }
+}
+
+/* Hue veil — a heavily blurred conic sweep that gently hue-rotates, so the
+ palette breathes without any visible spinning. */
+.m-splash-veil {
+ position: absolute;
+ inset: -25%;
+ background: conic-gradient(from 210deg at 50% 50%,
+ rgba(255, 214, 232, 0), rgba(214, 205, 248, 0.16),
+ rgba(198, 214, 248, 0.16), rgba(245, 214, 236, 0),
+ rgba(255, 214, 232, 0));
+ filter: blur(46px) hue-rotate(0deg);
+ mix-blend-mode: soft-light;
+ animation: m-splash-hue 26s ease-in-out infinite alternate;
+ will-change: filter;
+}
+.dark .m-splash-veil { opacity: 0.55; }
+@keyframes m-splash-hue {
+ from { filter: blur(46px) hue-rotate(0deg); }
+ to { filter: blur(46px) hue-rotate(24deg); }
+}
+
+/* Filmic grain — an inline (self-contained) fractal-noise texture at a whisper
+ of opacity for that reference-shot texture. Static, so it never adds motion. */
+.m-splash-grain {
+ position: absolute;
+ inset: 0;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
+ background-size: 160px 160px;
+ opacity: 0.05;
+ mix-blend-mode: overlay;
+}
+.dark .m-splash-grain { opacity: 0.09; mix-blend-mode: soft-light; }
+
+/* A breath of vignette for depth. */
+.m-splash-vignette {
+ position: absolute;
+ inset: 0;
+ background: radial-gradient(120% 100% at 50% 40%, transparent 55%, rgba(48, 38, 72, 0.10) 100%);
+}
+.dark .m-splash-vignette {
+ background: radial-gradient(120% 100% at 50% 42%, transparent 46%, rgba(0, 0, 0, 0.42) 100%);
+}
+
+/* ── Frosted "liquid glass" card ─────────────────────────────────────────────
+ Translucent, blurred + saturated, a hairline light edge, soft lift, generous
+ radius. Painted after the background layers so the frost blurs them. */
+.m-splash-card {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 44px 56px 40px;
+ border-radius: 32px;
+ background: rgba(255, 255, 255, 0.55);
+ border: 1px solid rgba(255, 255, 255, 0.6);
+ box-shadow:
+ 0 24px 70px -26px rgba(60, 48, 96, 0.38),
+ 0 2px 8px -4px rgba(60, 48, 96, 0.20),
+ inset 0 1px 0 rgba(255, 255, 255, 0.75);
+ backdrop-filter: blur(30px) saturate(1.7);
+ -webkit-backdrop-filter: blur(30px) saturate(1.7);
+ animation: m-splash-rise 700ms cubic-bezier(0.22, 1, 0.36, 1) both;
+}
+.dark .m-splash-card {
+ background: rgba(26, 24, 34, 0.52);
+ border-color: rgba(255, 255, 255, 0.09);
+ box-shadow:
+ 0 28px 80px -28px rgba(0, 0, 0, 0.65),
+ 0 2px 10px -4px rgba(0, 0, 0, 0.45),
+ inset 0 1px 0 rgba(255, 255, 255, 0.08);
+}
+@keyframes m-splash-rise {
+ from { opacity: 0; transform: translateY(10px) scale(0.985); }
+ to { opacity: 1; transform: none; }
+}
+
+/* Monochrome mascot tokens live on the content wrapper. --m-bg is pinned to the
+ frosted card's surface tone (not the page) so the eye cut-outs read clean. */
+.m-splash-content {
+ --m-ink: var(--text-primary);
+ --m-bg: #f3f1f8;
+}
+.dark .m-splash-content { --m-bg: #1b1a22; }
+
+@keyframes m-trek-beat {
+ from { opacity: 0; transform: translateX(20px); }
+ to { opacity: 1; transform: translateX(0); }
+}
+
+/* ── Motion prefs: freeze the drifting background ────────────────────────── */
+@media (prefers-reduced-motion: reduce) {
+ .m-splash-bg,
+ .m-splash-veil,
+ .m-splash-card { animation: none !important; }
+}
+`
+
+export default function TripLoadingSplash({ title }: { title?: string }) {
+ const { t } = useTranslation()
+ const reduceMotion =
+ typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
+
+ const [index, setIndex] = useState(0)
+ useEffect(() => {
+ if (reduceMotion) return
+ const id = setInterval(() => setIndex((n) => (n + 1) % STEPS.length), STEP_MS)
+ return () => clearInterval(id)
+ }, [reduceMotion])
+
+ const activeIndex = reduceMotion ? STILL_INDEX : index
+ const step = STEPS[activeIndex]
+
+ return (
+
+ {/* Slowly drifting glass gradient — painted behind the card so the frost
+ blurs it. */}
+
+
+
+
+
+
+
+ {/* Fixed stage so the travelling mascot never nudges the layout. */}
+
+
+
{title || 'TREK'}
+
+
+
+ {t(step.key)}
+
+
+
+ {/* Beat dots — the active stage widens into a pill. */}
+
+ {STEPS.map((_, i) => (
+
+ ))}
+
+
+
+
+
+
+ )
+}
diff --git a/client/src/hooks/useRouteCalculation.ts b/client/src/hooks/useRouteCalculation.ts
index 2ad5ec7ea..d5b0955d8 100644
--- a/client/src/hooks/useRouteCalculation.ts
+++ b/client/src/hooks/useRouteCalculation.ts
@@ -1,11 +1,11 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
import { useTripStore } from '../store/tripStore'
import { useSettingsStore } from '../store/settingsStore'
-import { calculateRouteWithLegs, withHotelBookends } from '../components/Map/RouteCalculator'
+import { calculateRouteWithLegs, withHotelBookends, type RouteProfileKey } from '../components/Map/RouteCalculator'
import { getTransportRouteEndpoints } from '../utils/dayMerge'
import { getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg } from '../utils/dayOrder'
import type { TripStoreState } from '../store/tripStore'
-import type { RouteSegment, RouteResult, Accommodation } from '../types'
+import type { RouteSegment, RouteResult, RouteVia, Accommodation } from '../types'
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other']
@@ -16,10 +16,12 @@ const NO_ACCOMMODATIONS: Accommodation[] = []
* day assignments, draws a straight-line route immediately, then upgrades it to real OSRM
* road geometry with per-segment durations. Aborts in-flight requests when the day changes.
*/
-export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null, enabled: boolean = true, profile: 'driving' | 'walking' | 'cycling' = 'driving', accommodations: Accommodation[] = NO_ACCOMMODATIONS) {
+export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null, enabled: boolean = true, profile: RouteProfileKey = 'driving', accommodations: Accommodation[] = NO_ACCOMMODATIONS) {
const [route, setRoute] = useState<[number, number][][] | null>(null)
const [routeInfo, setRouteInfo] = useState
(null)
const [routeSegments, setRouteSegments] = useState([])
+ // Charging stops / rest areas a plugin route places on the drawn line.
+ const [routeVias, setRouteVias] = useState([])
const routeAbortRef = useRef(null)
const reservationsForSignature = useTripStore((s) => s.reservations)
// Draw the day's accommodation bookend legs (hotel → first stop, last stop →
@@ -32,7 +34,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
const updateRouteForDay = useCallback(async (dayId: number | null) => {
if (routeAbortRef.current) routeAbortRef.current.abort()
// Route is manual: only compute when explicitly enabled (the "show route" toggle).
- if (!dayId || !enabled) { setRoute(null); setRouteSegments([]); return }
+ if (!dayId || !enabled) { setRoute(null); setRouteSegments([]); setRouteVias([]); return }
// Read directly from store (not a render-phase ref) so callers after optimistic
// updates or non-optimistic deletes always see the latest assignments.
const currentAssignments = useTripStore.getState().assignments || {}
@@ -160,32 +162,35 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
const straightLines = (): [number, number][][] =>
runsWithHotel.map(r => r.map(p => [p.lat, p.lng] as [number, number]))
- if (runsWithHotel.length === 0) { setRoute(null); setRouteSegments([]); return }
+ if (runsWithHotel.length === 0) { setRoute(null); setRouteSegments([]); setRouteVias([]); return }
// Draw straight lines immediately for snappiness, then upgrade to the real
- // OSRM road geometry.
+ // OSRM (or plugin-provided) road geometry.
setRoute(straightLines())
+ const tripId = useTripStore.getState().trip?.id ?? null
const controller = new AbortController()
routeAbortRef.current = controller
try {
const polylines: [number, number][][] = []
const allLegs: RouteSegment[] = []
+ const allVias: RouteVia[] = []
for (const run of runsWithHotel) {
try {
- const r = await calculateRouteWithLegs(run, { signal: controller.signal, profile })
+ const r = await calculateRouteWithLegs(run, { signal: controller.signal, profile, tripId, dayId })
polylines.push(r.coordinates.length >= 2 ? r.coordinates : run.map(p => [p.lat, p.lng] as [number, number]))
allLegs.push(...r.legs)
+ if (r.vias) allVias.push(...r.vias)
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') throw err
- // OSRM failed for this run — fall back to a straight line, no times.
+ // Routing failed for this run — fall back to a straight line, no times.
polylines.push(run.map(p => [p.lat, p.lng] as [number, number]))
}
}
- if (!controller.signal.aborted) { setRoute(polylines); setRouteSegments(allLegs) }
+ if (!controller.signal.aborted) { setRoute(polylines); setRouteSegments(allLegs); setRouteVias(allVias) }
} catch (err: unknown) {
// Aborted (day changed) — newer call owns the state. Anything else: keep straight lines.
- if (!(err instanceof Error) || err.name !== 'AbortError') setRouteSegments([])
+ if (!(err instanceof Error) || err.name !== 'AbortError') { setRouteSegments([]); setRouteVias([]) }
}
}, [enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit])
@@ -208,10 +213,10 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
// Recalculate when assignments or transport positions for the SELECTED day change
const selectedDayAssignments = selectedDayId ? tripStore.assignments?.[String(selectedDayId)] : null
useEffect(() => {
- if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
+ if (!selectedDayId) { setRoute(null); setRouteSegments([]); setRouteVias([]); return }
updateRouteForDay(selectedDayId)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit])
- return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
+ return { route, routeSegments, routeVias, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
}
diff --git a/client/src/index.css b/client/src/index.css
index 6533c2dcd..e420a06af 100644
--- a/client/src/index.css
+++ b/client/src/index.css
@@ -562,6 +562,24 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
--mood-neutral: #94928C;
--mood-tired: #6B9BD2;
--mood-rough: #9B8EC4;
+
+ /* ── Vacay "liquid glass" facelift tokens (desktop) ──────────────
+ Prefixed --vg-*; only the Vacay surface consumes them. Values are
+ the light palette; .dark overrides below. Kept global (not scoped)
+ so the tablet sidebar drawer — which portals to — still
+ resolves them in dark mode. */
+ --vg-ink: oklch(0.22 0.012 65);
+ --vg-ink2: oklch(0.42 0.012 65);
+ --vg-ink3: oklch(0.62 0.01 65);
+ --vg-bg: #f8fafc;
+ --vg-surf: #ffffff;
+ --vg-surf2: oklch(0.968 0.003 247);
+ --vg-line: oklch(0.92 0.008 70);
+ --vg-line2: oklch(0.88 0.01 70);
+ --vg-glass-bg: linear-gradient(135deg, oklch(1 0 0 / .72) 0%, oklch(0.99 0.006 75 / .5) 100%);
+ --vg-glass-br: oklch(0.88 0.008 70 / .7);
+ --vg-glass-sh: 0 1px 2px oklch(0.4 0.02 60 / .05), 0 12px 32px -14px oklch(0.3 0.02 60 / .2);
+ --vg-hi: inset 0 1px 0 oklch(1 0 0 / .8);
}
@media (max-width: 767px) {
@@ -630,6 +648,29 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
--mood-neutral: #9a9a94;
--mood-tired: #6db3f0;
--mood-rough: #a9a3f0;
+
+ /* Vacay "liquid glass" facelift tokens (dark) */
+ --vg-ink: oklch(0.96 0 0);
+ --vg-ink2: oklch(0.78 0 0);
+ --vg-ink3: oklch(0.6 0 0);
+ --vg-bg: oklch(0.17 0 0);
+ --vg-surf: oklch(0.225 0 0);
+ --vg-surf2: oklch(0.255 0 0);
+ --vg-line: oklch(0.32 0 0);
+ --vg-line2: oklch(0.38 0 0);
+ --vg-glass-bg: linear-gradient(135deg, oklch(0.31 0 0 / .58) 0%, oklch(0.25 0 0 / .42) 100%);
+ --vg-glass-br: oklch(1 0 0 / .1);
+ --vg-glass-sh: 0 1px 2px oklch(0 0 0 / .3), 0 12px 32px -14px oklch(0 0 0 / .55);
+ --vg-hi: inset 0 1px 0 oklch(1 0 0 / .09);
+}
+
+/* Vacay glass card + button helpers (consume --vg-* above). */
+.vg-card {
+ background: var(--vg-glass-bg);
+ border: 1px solid var(--vg-glass-br);
+ box-shadow: var(--vg-glass-sh), var(--vg-hi);
+ -webkit-backdrop-filter: blur(22px) saturate(1.7);
+ backdrop-filter: blur(22px) saturate(1.7);
}
/* ── Color schemes (data-scheme) ──────────────────────────────────
diff --git a/client/src/mobile/MobileShell.tsx b/client/src/mobile/MobileShell.tsx
new file mode 100644
index 000000000..0642676c1
--- /dev/null
+++ b/client/src/mobile/MobileShell.tsx
@@ -0,0 +1,48 @@
+import { ReactNode } from 'react'
+import { useMatch } from 'react-router-dom'
+import BottomNav from '../components/Layout/BottomNav'
+import MBottomNav from './components/MBottomNav'
+import MToastHost from './components/MToastHost'
+import './mobile.css'
+
+interface MobileShellProps {
+ isPhone: boolean
+ children: ReactNode
+}
+
+/**
+ * Shell around every protected page. Below the md breakpoint it is the root
+ * of the new mobile experience: scopes the --m-* design tokens, paints the
+ * screen gradient, hosts the bottom dock, the sheet portal target and the
+ * mobile toast presenter. From 768px up it renders the exact legacy wrapper
+ * unchanged. Both branches share the div > div > children shape so pages keep
+ * their state when the viewport crosses the breakpoint (rotation,
+ * split-screen) instead of remounting. The content area keeps the exact
+ * scroll semantics of the legacy ProtectedRoute wrapper, so not-yet-migrated
+ * pages render unchanged inside.
+ */
+export default function MobileShell({ isPhone, children }: MobileShellProps) {
+ // The trip planner (/trips/:id) is a full-screen takeover that ships its own
+ // in-trip dock (MTripShell). The global dock must not render underneath it:
+ // on iOS Safari a position:fixed nav paints THROUGH the trip overlay's higher
+ // stacking context, so the wrong (global) bar shows on top of the trip screen.
+ const inTripPlanner = useMatch('/trips/:id')
+
+ if (!isPhone) {
+ return (
+
+ )
+ }
+
+ return (
+
+
{children}
+ {!inTripPlanner &&
}
+
+
+
+ )
+}
diff --git a/client/src/mobile/components/MBottomNav.tsx b/client/src/mobile/components/MBottomNav.tsx
new file mode 100644
index 000000000..460ce5b43
--- /dev/null
+++ b/client/src/mobile/components/MBottomNav.tsx
@@ -0,0 +1,181 @@
+import { useEffect, useState } from 'react'
+import { useNavigate, useLocation, useMatch } from 'react-router-dom'
+import { useSettingsStore } from '../../store/settingsStore'
+import { useTranslation } from '../../i18n'
+import { ChevronRight, MoreHorizontal, Plus, Search } from 'lucide-react'
+import type { LucideIcon } from 'lucide-react'
+import { normalizeAppearance } from '@trek/shared'
+import { useNavItems, splitMobileNav } from '../../components/Layout/navItems'
+import MFab from './MFab'
+
+interface NavItem { to: string; label: string; icon: LucideIcon }
+
+// The centre "+" means something different per context: inside a trip it adds a
+// place, on the journey list it starts a journey (deliberate deviation from the
+// demo, which reserves the FAB for entries — the list has no journey to add
+// into yet), inside a journey it adds an entry, on the atlas it opens the
+// country search, on collections it adds a place to the active list —
+// everywhere else it creates a new trip. Pages pick the intent up from the
+// query params.
+function useCreateAction(): { label: string; run: () => void } {
+ const navigate = useNavigate()
+ const { t } = useTranslation()
+ const inTrip = useMatch('/trips/:id')
+ const inJourney = useMatch('/journey/:id')
+ const onJourneyList = useMatch('/journey')
+ const onAtlas = useMatch('/atlas')
+ const onCollections = useMatch('/collections')
+
+ if (inTrip) {
+ // The "+" is context-aware per active tab: Bookings → reservation,
+ // Transports → transport, Costs → expense. Tabs without a create modal
+ // (lists / files / collab) fall through to adding a place. #1349
+ const id = inTrip.params.id
+ const tripTab = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(`trip-tab-${id}`) : null
+ if (tripTab === 'finanzplan') return { label: t('costs.addExpense'), run: () => navigate(`/trips/${id}?create=expense`) }
+ if (tripTab === 'buchungen') return { label: t('reservations.addManual'), run: () => navigate(`/trips/${id}?create=reservation`) }
+ if (tripTab === 'transports') return { label: t('transport.addManual'), run: () => navigate(`/trips/${id}?create=transport`) }
+ return { label: t('places.addPlace'), run: () => navigate(`/trips/${id}?create=place`) }
+ }
+ if (inJourney) {
+ return { label: t('journey.detail.addEntry'), run: () => navigate(`/journey/${inJourney.params.id}?create=entry`) }
+ }
+ if (onJourneyList) {
+ return { label: t('journey.new'), run: () => navigate('/journey?create=1') }
+ }
+ if (onAtlas) {
+ return { label: t('atlas.searchCountry'), run: () => navigate('/atlas?search=1') }
+ }
+ if (onCollections) {
+ return { label: t('collections.addPlace'), run: () => navigate('/collections?create=place') }
+ }
+ return { label: t('dashboard.newTrip'), run: () => navigate('/dashboard?create=1') }
+}
+
+/**
+ * Floating glass dock of the mobile shell. Same tab/gating/"+" logic as the
+ * legacy BottomNav (addons, ?create= contract), redesigned as the demo's icon
+ * dock: 42px circles, active on the --m-act pill, Journey/Collections and page
+ * plugins behind the "More" popover, and a context FAB in the middle (search
+ * on the atlas, disabled logo slot on settings/admin).
+ */
+export default function MBottomNav() {
+ const { t } = useTranslation()
+ const navigate = useNavigate()
+ const location = useLocation()
+ const create = useCreateAction()
+ const [moreOpen, setMoreOpen] = useState(false)
+
+ // Close the popover when the route changes underneath it (browser back etc.).
+ useEffect(() => setMoreOpen(false), [location.pathname])
+
+ // The dock split + order is user-configurable (Settings → Appearance → Mobile);
+ // an un-customised account falls back to the built-in Dashboard + vacay/atlas
+ // dock with everything else under "More".
+ const navItems = useNavItems()
+ const appearance = useSettingsStore(s => s.settings.appearance)
+ const split = splitMobileNav(navItems, normalizeAppearance(appearance).mobileNav)
+
+ const dockItems: NavItem[] = split.bar
+ const moreItems: NavItem[] = split.more
+
+ const isActive = (to: string) =>
+ to === '/dashboard' ? location.pathname === '/dashboard' : location.pathname.startsWith(to)
+ const moreActive = moreItems.some(item => isActive(item.to))
+
+ // The FAB gives way to a decorative logo slot on screens without an add
+ // action (settings/admin, demo Z. 1372/1429).
+ const logoSlot = location.pathname.startsWith('/settings') || location.pathname.startsWith('/admin')
+ const searchFab = location.pathname.startsWith('/atlas')
+
+ // Split so the raised centre slot sits dead centre; the More slot always
+ // closes the right group.
+ const slotCount = dockItems.length + (moreItems.length > 0 ? 1 : 0)
+ const splitAt = Math.ceil(slotCount / 2)
+ const left = dockItems.slice(0, splitAt)
+ const right = dockItems.slice(splitAt)
+
+ const circleCls = (active: boolean) =>
+ `flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full ${
+ active ? 'bg-m-act text-m-actfg' : 'text-m-muted'
+ }`
+
+ const renderItem = ({ to, label, icon: Icon }: NavItem) => {
+ const active = isActive(to)
+ // Fixed sizes per slot (demo): the dashboard grid is 18/2.1, every other
+ // slot 21/1.9 — independent of the active state.
+ const dash = to === '/dashboard'
+ return (
+ navigate(to)}
+ aria-label={label}
+ aria-current={active ? 'page' : undefined}
+ className={circleCls(active)}
+ >
+
+
+ )
+ }
+
+ return (
+ <>
+ {moreOpen && (
+ // Invisible scrim (the popover sits on the UI without dimming it).
+ setMoreOpen(false)}>
+
e.stopPropagation()}
+ >
+ {moreItems.map(({ to, label, icon: Icon }) => (
+ { setMoreOpen(false); navigate(to) }}
+ className="flex items-center gap-[13px] rounded-[18px] bg-[color:var(--m-ic)] px-4 py-[14px] text-left"
+ >
+
+
+
+ {label}
+
+
+ ))}
+
+
+ )}
+
+
+ {left.map(renderItem)}
+
+ {logoSlot ? (
+
+
+
+
+ ) : (
+
+ {searchFab ? : }
+
+ )}
+
+
+ {right.map(renderItem)}
+ {moreItems.length > 0 && (
+ setMoreOpen(v => !v)}
+ aria-label={t('mobileNav.more')}
+ aria-expanded={moreOpen}
+ aria-current={moreActive ? 'page' : undefined}
+ className={circleCls(moreActive)}
+ >
+
+
+ )}
+
+
+ >
+ )
+}
diff --git a/client/src/mobile/components/MChip.tsx b/client/src/mobile/components/MChip.tsx
new file mode 100644
index 000000000..eee99f143
--- /dev/null
+++ b/client/src/mobile/components/MChip.tsx
@@ -0,0 +1,25 @@
+import { ReactNode } from 'react'
+
+interface MChipProps {
+ active?: boolean
+ onClick?: () => void
+ className?: string
+ children: ReactNode
+}
+
+/** Small pill chip: --m-act when active, neutral --m-ic surface otherwise. */
+export default function MChip({ active = false, onClick, className = '', children }: MChipProps) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/client/src/mobile/components/MDancingTrek.tsx b/client/src/mobile/components/MDancingTrek.tsx
new file mode 100644
index 000000000..13f0e9d5a
--- /dev/null
+++ b/client/src/mobile/components/MDancingTrek.tsx
@@ -0,0 +1,358 @@
+import { useState } from 'react'
+
+/**
+ * The TREK mascot — an empty-state character that IS the TREK mark: the logo is
+ * the whole body, no arms, no legs. Two eyes give it life; a soft idle keeps it
+ * breathing and blinking, and a ground shadow anchors it. Monochrome
+ * (currentColor, theme-driven) and it holds a single pose under reduced motion.
+ *
+ * Two orthogonal props drive personality:
+ * - `scene` picks the prop + body motion for a page (skateboard, suitcase, …).
+ * - `mood` picks the facial expression (default / happy / sleepy / confused /
+ * error) so loading and error states can reuse the same face. Scenes carry a
+ * sensible default mood (notifications → sleepy, search → confused) that an
+ * explicit `mood` overrides.
+ *
+ * Body motion (`.trek-bounce--`) and each prop animate independently, so
+ * the mascot can react to a prop without the prop moving with it. Choreography
+ * lives in mobile.css (.trek-*). Poking (tap) remounts the svg to replay the
+ * entrance pop-in as a bounce.
+ */
+export type TrekScene =
+ | 'idle'
+ | 'chat'
+ | 'transport'
+ | 'bookings'
+ | 'guide'
+ | 'packing'
+ | 'files'
+ | 'notes'
+ | 'polls'
+ | 'journey'
+ | 'collections'
+ | 'dashboard'
+ | 'atlas'
+ | 'notifications'
+ | 'costs'
+ | 'search'
+ | 'tasks'
+
+export type TrekMood = 'default' | 'happy' | 'sleepy' | 'confused' | 'error'
+
+const MARK =
+ 'M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.75 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.75 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z'
+
+const SCENE_MOOD: Partial> = {
+ notifications: 'sleepy',
+ search: 'confused',
+}
+
+export default function MDancingTrek({
+ size = 96,
+ scene = 'idle',
+ mood,
+ className = '',
+}: {
+ size?: number
+ scene?: TrekScene
+ mood?: TrekMood
+ className?: string
+}) {
+ const face = mood ?? SCENE_MOOD[scene] ?? 'default'
+ // Ground shadow sits under whatever the mascot stands on this scene.
+ const shadowCy = scene === 'transport' ? 86 : 73
+ // Poke to react: remounting the svg replays the entrance pop-in as a tap bounce.
+ const [poke, setPoke] = useState(0)
+ return (
+ setPoke(p => p + 1)}
+ width={size}
+ height={(size * 96) / 88}
+ viewBox="0 0 88 96"
+ fill="none"
+ aria-hidden="true"
+ className={`trek trek--${scene} cursor-pointer text-m-ink ${className}`}
+ >
+
+
+ {/* scene prop behind / under the body — animates on its own */}
+
+
+ {/* body = the TREK mark, with its own per-scene bounce (hop, nod, …) */}
+
+
+
+
+
+
+
+
+
+ {/* scene prop in front of the body — animates on its own */}
+
+
+
+ )
+}
+
+/* ── Eyes ────────────────────────────────────────────────────────────────────
+ Default eyes are surface-colour cutouts with ink pupils that blink and glance.
+ Expression eyes are cut into the body as m-bg strokes so they read on the
+ monochrome mark. */
+
+function Eyes({ mood }: { mood: TrekMood }) {
+ if (mood === 'happy') {
+ return (
+
+
+
+
+ )
+ }
+ if (mood === 'sleepy') {
+ return (
+
+
+
+
+ )
+ }
+ if (mood === 'error') {
+ return (
+
+
+
+
+ )
+ }
+ if (mood === 'confused') {
+ // one eye open, one a raised squint — reads puzzled
+ return (
+
+
+
+
+
+ )
+ }
+ // default — open eyes that blink and glance around
+ return (
+
+
+
+
+
+
+
+
+ )
+}
+
+/* ── Scene props ─────────────────────────────────────────────────────────────
+ Monochrome props built from a few shapes. Animated elements never carry a
+ `transform` attribute (a CSS-animation transform would override it) — any
+ static rotation lives on a wrapping instead. */
+
+function SceneBack({ scene }: { scene: TrekScene }) {
+ switch (scene) {
+ case 'transport':
+ // skateboard under the body — deck + two wheels spinning in place
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+ case 'guide':
+ // tall staff with a big pennant fluttering near the top
+ return (
+
+
+
+
+
+
+
+ )
+ case 'packing':
+ // rolling suitcase parked beside the body — rocks on its wheels,
+ // handle telescoping up and down
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+ case 'polls':
+ // vote bars gently rising and settling like live tallies
+ return (
+
+
+
+
+
+ )
+ case 'collections':
+ // a map pin dropping in and bouncing beside the body
+ return (
+
+
+
+
+ )
+ case 'atlas':
+ // a globe beside the body, meridians drifting
+ return (
+
+
+
+
+
+ )
+ case 'costs':
+ // a stack of coins beside the body, top one bouncing on
+ return (
+
+
+
+
+
+
+
+
+
+ )
+ case 'chat':
+ // a speech bubble beside the body with three typing dots pulsing in turn
+ return (
+
+
+
+
+
+
+
+ )
+ default:
+ return null
+ }
+}
+
+function SceneFront({ scene }: { scene: TrekScene }) {
+ switch (scene) {
+ // Files reuse the booking ticket per design — a card getting written on.
+ case 'bookings':
+ case 'files':
+ return (
+
+
+
+
+
+
+
+
+
+
+ )
+ case 'notes':
+ // a paper note (surface colour) with ink lines being written and a
+ // scribbling pencil — pencil is ink (light in dark theme, dark in light)
+ return (
+
+
+
+
+
+
+
+
+ {/* body + tip modelled vertically as one piece, then leaned together
+ so the point always stays attached to the shaft */}
+
+
+
+
+
+
+ )
+ case 'journey':
+ // an open book with a fluttering page and a ribbon bookmark
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+ case 'dashboard':
+ // a little paper plane looping around, ready for a first trip
+ return (
+
+
+
+
+ )
+ case 'notifications':
+ // all caught up — a stack of Zzz drifting up while the mascot dozes
+ return (
+
+
+
+
+
+ )
+ case 'search':
+ // a magnifying glass scanning back and forth
+ return (
+
+
+
+
+
+ )
+ case 'tasks':
+ // a clipboard checklist with a checkmark ticking on
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+ default:
+ return null
+ }
+}
diff --git a/client/src/mobile/components/MDropdownPanel.tsx b/client/src/mobile/components/MDropdownPanel.tsx
new file mode 100644
index 000000000..8adb8a44b
--- /dev/null
+++ b/client/src/mobile/components/MDropdownPanel.tsx
@@ -0,0 +1,30 @@
+import { ReactNode } from 'react'
+
+interface MDropdownPanelProps {
+ open: boolean
+ onClose: () => void
+ /** Positioning classes (e.g. `top-[100px] right-4`); panel is fixed. */
+ className?: string
+ children: ReactNode
+}
+
+/**
+ * Floating popover panel (user menu, filter dropdowns): heavy glass, r20,
+ * closed by a transparent full-screen backdrop — no dim, unlike sheets.
+ * Layered above the scrolling content but below the bottom nav (z-40) and
+ * sheets (z-60), matching the design's stacking order.
+ */
+export default function MDropdownPanel({ open, onClose, className = '', children }: MDropdownPanelProps) {
+ if (!open) return null
+
+ return (
+ <>
+
+
+ {children}
+
+ >
+ )
+}
diff --git a/client/src/mobile/components/MFab.tsx b/client/src/mobile/components/MFab.tsx
new file mode 100644
index 000000000..ec6be20a3
--- /dev/null
+++ b/client/src/mobile/components/MFab.tsx
@@ -0,0 +1,22 @@
+import { ReactNode } from 'react'
+
+interface MFabProps {
+ onClick?: () => void
+ ariaLabel: string
+ className?: string
+ children: ReactNode
+}
+
+/** 56px round action button on the --m-act surface (bottom-nav "+", screen FABs). */
+export default function MFab({ onClick, ariaLabel, className = '', children }: MFabProps) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/client/src/mobile/components/MGlassBar.tsx b/client/src/mobile/components/MGlassBar.tsx
new file mode 100644
index 000000000..18b0c440e
--- /dev/null
+++ b/client/src/mobile/components/MGlassBar.tsx
@@ -0,0 +1,21 @@
+import { ReactNode } from 'react'
+
+interface MGlassBarProps {
+ /** Pins the bar below the status bar (default). Pass false to embed it in flow. */
+ floating?: boolean
+ className?: string
+ children: ReactNode
+}
+
+/** Top bar glass pill: blur 26 / saturate 1.7 on --m-glass with --m-gbr border. */
+export default function MGlassBar({ floating = true, className = '', children }: MGlassBarProps) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/client/src/mobile/components/MIconBtn.tsx b/client/src/mobile/components/MIconBtn.tsx
new file mode 100644
index 000000000..989d6c587
--- /dev/null
+++ b/client/src/mobile/components/MIconBtn.tsx
@@ -0,0 +1,38 @@
+import { ReactNode } from 'react'
+
+interface MIconBtnProps {
+ onClick?: () => void
+ ariaLabel: string
+ /** glass = topbar-style glass circle, neutral = flat --m-ic circle (sheet close) */
+ variant?: 'glass' | 'neutral'
+ /** Diameter in px (topbar 38, sheet close 34) */
+ size?: number
+ className?: string
+ children: ReactNode
+}
+
+/** Round icon button used in the top bar (glass) and sheet headers (neutral). */
+export default function MIconBtn({
+ onClick,
+ ariaLabel,
+ variant = 'glass',
+ size = 38,
+ className = '',
+ children,
+}: MIconBtnProps) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/client/src/mobile/components/MListRow.tsx b/client/src/mobile/components/MListRow.tsx
new file mode 100644
index 000000000..36079b9b3
--- /dev/null
+++ b/client/src/mobile/components/MListRow.tsx
@@ -0,0 +1,35 @@
+import { ReactNode } from 'react'
+import type { LucideIcon } from 'lucide-react'
+
+interface MListRowProps {
+ label: ReactNode
+ icon?: LucideIcon
+ trailing?: ReactNode
+ danger?: boolean
+ onClick?: () => void
+ className?: string
+}
+
+/** Menu/list row (user menu style): icon 16 in --m-muted, 13.5px semibold label. */
+export default function MListRow({
+ label,
+ icon: Icon,
+ trailing,
+ danger = false,
+ onClick,
+ className = '',
+}: MListRowProps) {
+ return (
+
+ {Icon && }
+ {label}
+ {trailing}
+
+ )
+}
diff --git a/client/src/mobile/components/MProgress.tsx b/client/src/mobile/components/MProgress.tsx
new file mode 100644
index 000000000..3c094b581
--- /dev/null
+++ b/client/src/mobile/components/MProgress.tsx
@@ -0,0 +1,28 @@
+interface MProgressProps {
+ /** 0–100 */
+ value: number
+ /** Optional data-driven fill color (bag/category colors); defaults to --m-act. */
+ color?: string
+ /** Bar height in px: 5 is the --m-ic track canon, 4 the spotlight bar, 6 the bag bar. */
+ height?: 4 | 5 | 6
+ className?: string
+}
+
+const HEIGHT: Record<4 | 5 | 6, string> = {
+ 4: 'h-[4px]',
+ 5: 'h-[5px]',
+ 6: 'h-[6px]',
+}
+
+/** Thin progress bar on the neutral --m-ic track. */
+export default function MProgress({ value, color, height = 5, className = '' }: MProgressProps) {
+ const clamped = Math.max(0, Math.min(100, value))
+ return (
+
+ )
+}
diff --git a/client/src/mobile/components/MSegmented.tsx b/client/src/mobile/components/MSegmented.tsx
new file mode 100644
index 000000000..470128a4e
--- /dev/null
+++ b/client/src/mobile/components/MSegmented.tsx
@@ -0,0 +1,78 @@
+import { ReactNode, useLayoutEffect, useRef, useState } from 'react'
+
+export interface MSegmentedOption {
+ value: T
+ label: ReactNode
+}
+
+interface MSegmentedProps {
+ options: MSegmentedOption[]
+ value: T
+ onChange: (value: T) => void
+ /** stretch = equal-width segments (costs tabs), intrinsic = pill hugs its label (dashboard filter) */
+ variant?: 'stretch' | 'intrinsic'
+ className?: string
+}
+
+/**
+ * Segment pill with a sliding active indicator. Active segment reads in
+ * --m-actfg on the --m-act pill, idle segments in --m-ink (Spec 07 §6).
+ */
+export default function MSegmented({
+ options,
+ value,
+ onChange,
+ variant = 'stretch',
+ className = '',
+}: MSegmentedProps) {
+ const trackRef = useRef(null)
+ const [indicator, setIndicator] = useState<{ left: number; width: number } | null>(null)
+
+ useLayoutEffect(() => {
+ const track = trackRef.current
+ if (!track) return
+ const measure = () => {
+ const active = track.querySelector('[data-active="true"]')
+ if (active) setIndicator({ left: active.offsetLeft, width: active.offsetWidth })
+ }
+ measure()
+ const ro = new ResizeObserver(measure)
+ ro.observe(track)
+ return () => ro.disconnect()
+ }, [value, options.length])
+
+ return (
+
+ {indicator && (
+
+ )}
+ {options.map((opt) => {
+ const active = opt.value === value
+ return (
+
onChange(opt.value)}
+ className={`relative z-[1] rounded-full py-[7px] text-center ${
+ variant === 'intrinsic'
+ ? 'whitespace-nowrap px-[13px] text-[0.75rem]'
+ : 'flex-1 text-[0.71875rem]'
+ } ${active ? 'font-semibold text-m-actfg' : 'font-medium text-m-ink'}`}
+ >
+ {opt.label}
+
+ )
+ })}
+
+ )
+}
diff --git a/client/src/mobile/components/MSheet.tsx b/client/src/mobile/components/MSheet.tsx
new file mode 100644
index 000000000..0fc4a6b7f
--- /dev/null
+++ b/client/src/mobile/components/MSheet.tsx
@@ -0,0 +1,238 @@
+import React, { ReactNode, useEffect, useRef, useState } from 'react'
+import ReactDOM from 'react-dom'
+
+export type MSheetVariant = 'card' | 'bottom' | 'drawer'
+export type MSheetMaterial = 'glass' | 'bar-glass' | 'opaque'
+
+interface MSheetProps {
+ open: boolean
+ onClose: () => void
+ /** card = centred floating card, bottom = docked panel above the nav, drawer = left drawer */
+ variant?: MSheetVariant
+ /**
+ * glass = translucent sheet (--m-sheet), bar-glass = lighter bar glass
+ * (--m-glass, default for the bottom variant), opaque = solid (--m-sheetop)
+ */
+ material?: MSheetMaterial
+ /** Transparent scrim (the "Mehr" sheet sits on the UI without dimming it) */
+ dimTransparent?: boolean
+ ariaLabel?: string
+ className?: string
+ children?: ReactNode
+}
+
+const EXIT_MS = 280
+
+const FOCUSABLE =
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
+
+const POSITION: Record = {
+ card: 'absolute left-[14px] right-[14px] top-1/2 -translate-y-1/2',
+ bottom: 'absolute left-4 right-4 bottom-[calc(var(--bottom-nav-h,84px)+16px)]',
+ drawer: 'absolute left-0 top-0 bottom-0 w-[78%] max-w-[320px]',
+}
+
+const SHAPE: Record = {
+ card: 'rounded-[28px] shadow-[0_30px_80px_-24px_rgba(0,0,0,.6)] max-h-[calc(100dvh-var(--m-safe-top,12px)-96px)]',
+ bottom: 'rounded-[26px] shadow-[0_-8px_40px_-14px_rgba(0,0,0,.45)] max-h-[calc(100dvh-var(--bottom-nav-h,84px)-var(--m-safe-top,12px)-72px)]',
+ drawer: 'h-full shadow-[0_0_60px_-10px_rgba(0,0,0,.5)]',
+}
+
+// Deliberate normalisation: the demo borders a few opaque sheets (journey
+// create/entry/settings, drawer) with --rowbr instead of --shbr; both hairlines
+// are near-identical on the solid sheet surface, so every material keeps one
+// border token here.
+const MATERIAL: Record = {
+ glass:
+ 'bg-[color:var(--m-sheet)] backdrop-blur-[40px] backdrop-saturate-[1.8] border border-[color:var(--m-shbr)]',
+ 'bar-glass':
+ 'bg-[color:var(--m-glass)] backdrop-blur-[30px] backdrop-saturate-[1.8] border border-[color:var(--m-gbr)]',
+ opaque: 'bg-[color:var(--m-sheetop)] border border-[color:var(--m-shbr)]',
+}
+
+function sheetRoot(): HTMLElement {
+ return document.getElementById('m-sheet-root') ?? document.body
+}
+
+// Body scroll lock shared across stacked sheets: only the first lock saves the
+// original overflow and only the last unlock restores it.
+let scrollLocks = 0
+let savedBodyOverflow = ''
+
+function lockBodyScroll() {
+ if (scrollLocks === 0) {
+ savedBodyOverflow = document.body.style.overflow
+ document.body.style.overflow = 'hidden'
+ }
+ scrollLocks++
+}
+
+function unlockBodyScroll() {
+ scrollLocks = Math.max(0, scrollLocks - 1)
+ if (scrollLocks === 0) document.body.style.overflow = savedBodyOverflow
+}
+
+/**
+ * The one sheet primitive of the mobile design system. Handles portal,
+ * scrim, ESC/backdrop dismissal, body scroll lock, focus trapping, 280ms
+ * enter/exit and — for the bottom variant — drag-to-dismiss on the top edge.
+ */
+export default function MSheet({
+ open,
+ onClose,
+ variant = 'card',
+ material,
+ dimTransparent = false,
+ ariaLabel,
+ className = '',
+ children,
+}: MSheetProps) {
+ const [rendered, setRendered] = useState(open)
+ // Once the enter animation has played it is removed again: its forwards
+ // fill would otherwise override the inline drag transform for good.
+ const [entered, setEntered] = useState(false)
+ const [dragY, setDragY] = useState(null)
+ const panelRef = useRef(null)
+ const dragRef = useRef<{ startY: number; lastY: number; lastT: number; velocity: number } | null>(null)
+
+ // Keep the sheet mounted through the exit animation.
+ useEffect(() => {
+ if (open) {
+ setRendered(true)
+ return
+ }
+ setEntered(false)
+ setDragY(null)
+ const t = setTimeout(() => setRendered(false), EXIT_MS)
+ return () => clearTimeout(t)
+ }, [open])
+
+ useEffect(() => {
+ if (!open) return
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onClose()
+ }
+ document.addEventListener('keydown', onKey)
+ return () => document.removeEventListener('keydown', onKey)
+ }, [open, onClose])
+
+ useEffect(() => {
+ if (!open) return
+ lockBodyScroll()
+ return unlockBodyScroll
+ }, [open])
+
+ // Move focus into the dialog on open, hand it back on close.
+ useEffect(() => {
+ if (!open || !rendered) return
+ const previous = document.activeElement as HTMLElement | null
+ panelRef.current?.focus()
+ return () => previous?.focus()
+ }, [open, rendered])
+
+ if (!rendered) return null
+
+ const handlePointerDown = (e: React.PointerEvent) => {
+ ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
+ dragRef.current = { startY: e.clientY, lastY: e.clientY, lastT: e.timeStamp, velocity: 0 }
+ setDragY(0)
+ }
+
+ const handlePointerMove = (e: React.PointerEvent) => {
+ const d = dragRef.current
+ if (!d) return
+ d.velocity = (e.clientY - d.lastY) / Math.max(1, e.timeStamp - d.lastT)
+ d.lastY = e.clientY
+ d.lastT = e.timeStamp
+ setDragY(Math.max(0, e.clientY - d.startY))
+ }
+
+ const handlePointerEnd = () => {
+ const d = dragRef.current
+ if (!d) return
+ dragRef.current = null
+ const height = panelRef.current?.offsetHeight ?? 0
+ const dy = Math.max(0, d.lastY - d.startY)
+ setDragY(null)
+ // Past 30% of the panel or a decisive flick → dismiss.
+ if ((height > 0 && dy > height * 0.3) || d.velocity > 0.6) onClose()
+ }
+
+ // Keep Tab inside the dialog while it is open.
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key !== 'Tab') return
+ const panel = panelRef.current
+ if (!panel) return
+ const focusable = panel.querySelectorAll(FOCUSABLE)
+ if (focusable.length === 0) {
+ e.preventDefault()
+ return
+ }
+ const first = focusable[0]
+ const last = focusable[focusable.length - 1]
+ if (e.shiftKey && (document.activeElement === first || document.activeElement === panel)) {
+ e.preventDefault()
+ last.focus()
+ } else if (!e.shiftKey && document.activeElement === last) {
+ e.preventDefault()
+ first.focus()
+ }
+ }
+
+ const enterClass = variant === 'drawer' ? 'm-drawer-in' : 'm-sheet-in'
+ const exitClass = variant === 'drawer' ? 'm-drawer-out' : 'm-sheet-out'
+ const animation = open ? (entered ? '' : enterClass) : exitClass
+
+ const resolvedMaterial = material ?? (variant === 'bottom' ? 'bar-glass' : 'glass')
+
+ const dragStyle: React.CSSProperties =
+ dragY !== null
+ ? { transform: `translateY(${dragY}px)`, transition: 'none' }
+ : { transition: 'transform 280ms var(--ease-drawer)' }
+
+ return ReactDOM.createPortal(
+ // m-root on the overlay so the --m-* tokens resolve even when the portal
+ // has to fall back to document.body (sheet mounted in the same commit as
+ // the shell).
+ // A transparent scrim skips the overlay's opacity animation: an animating
+ // opacity<1 ancestor becomes a backdrop-root that cancels the sheet's own
+ // backdrop-blur (the "liquid glass" frost). The sheet still slides via m-sheet-in.
+ {
+ if (e.target === e.currentTarget) onClose()
+ }}
+ >
+
+
e.stopPropagation()}
+ onKeyDown={handleKeyDown}
+ onAnimationEnd={(e) => {
+ if (e.target === e.currentTarget && open) setEntered(true)
+ }}
+ >
+ {variant === 'bottom' && (
+ // Invisible drag strip along the top edge — the design shows no
+ // handle, the panel starts directly with its content.
+
+ )}
+ {children}
+
+
+
,
+ sheetRoot(),
+ )
+}
diff --git a/client/src/mobile/components/MStatusDot.tsx b/client/src/mobile/components/MStatusDot.tsx
new file mode 100644
index 000000000..d3ceebf47
--- /dev/null
+++ b/client/src/mobile/components/MStatusDot.tsx
@@ -0,0 +1,27 @@
+export type MStatus = 'confirmed' | 'pending' | 'info' | 'danger' | 'neutral'
+
+interface MStatusDotProps {
+ status: MStatus
+ /** Diameter in px */
+ size?: number
+ className?: string
+}
+
+const STATUS_VAR: Record = {
+ confirmed: 'var(--m-st-confirmed)',
+ pending: 'var(--m-st-pending)',
+ info: 'var(--m-st-info)',
+ danger: 'var(--m-st-danger)',
+ neutral: 'var(--m-st-neutral)',
+}
+
+/** Status dot in the canonical status colors (confirmed/pending/info/danger/neutral). */
+export default function MStatusDot({ status, size = 7, className = '' }: MStatusDotProps) {
+ return (
+
+ )
+}
diff --git a/client/src/mobile/components/MToastHost.tsx b/client/src/mobile/components/MToastHost.tsx
new file mode 100644
index 000000000..231ed6931
--- /dev/null
+++ b/client/src/mobile/components/MToastHost.tsx
@@ -0,0 +1,92 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import MStatusDot, { MStatus } from './MStatusDot'
+
+type ToastType = 'success' | 'error' | 'warning' | 'info'
+
+interface MToast {
+ id: number
+ message: string
+ type: ToastType
+ /** duration <= 0: no auto-dismiss, the pill closes on tap instead. */
+ sticky: boolean
+ removing: boolean
+}
+
+let toastId = 0
+const EXIT_MS = 220
+
+// info stays the plain act-pill; the other types get a status dot.
+const TYPE_DOT: Partial> = {
+ success: 'confirmed',
+ error: 'danger',
+ warning: 'pending',
+}
+
+/**
+ * Mobile toast presenter. Takes over the global `window.__addToast` bridge
+ * (fed by useToast / store/notify) while the mobile shell is mounted, so every
+ * existing toast call renders as the design's act-pill above the bottom nav.
+ * The desktop ToastContainer handler is restored on unmount.
+ */
+export default function MToastHost() {
+ const [toasts, setToasts] = useState([])
+ const timersRef = useRef[]>([])
+
+ useEffect(() => {
+ const timers = timersRef.current
+ return () => timers.forEach(clearTimeout)
+ }, [])
+
+ const dismissToast = useCallback((id: number) => {
+ setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, removing: true } : t)))
+ const t = setTimeout(() => {
+ setToasts((prev) => prev.filter((t) => t.id !== id))
+ }, EXIT_MS)
+ timersRef.current.push(t)
+ }, [])
+
+ const addToast = useCallback(
+ (message: string, type: ToastType = 'info', duration: number = 3000) => {
+ const id = ++toastId
+ setToasts((prev) => [...prev, { id, message, type, sticky: duration <= 0, removing: false }])
+
+ if (duration > 0) {
+ const t = setTimeout(() => dismissToast(id), duration)
+ timersRef.current.push(t)
+ }
+
+ return id
+ },
+ [dismissToast],
+ )
+
+ useEffect(() => {
+ const previous = window.__addToast
+ window.__addToast = addToast
+ return () => {
+ window.__addToast = previous
+ }
+ }, [addToast])
+
+ if (toasts.length === 0) return null
+
+ return (
+
+ {toasts.map((toast) => {
+ const dot = TYPE_DOT[toast.type]
+ return (
+
dismissToast(toast.id) : undefined}
+ className={`flex max-w-full items-center gap-2 rounded-full bg-m-act px-4 py-2 text-[0.75rem] font-semibold text-m-actfg shadow-[0_10px_30px_-8px_rgba(0,0,0,.5)] ${
+ toast.sticky ? 'pointer-events-auto cursor-pointer' : ''
+ } ${toast.removing ? 'm-toast-out' : 'm-toast-in'}`}
+ >
+ {dot && }
+ {toast.message}
+
+ )
+ })}
+
+ )
+}
diff --git a/client/src/mobile/components/MToggle.tsx b/client/src/mobile/components/MToggle.tsx
new file mode 100644
index 000000000..0963101e3
--- /dev/null
+++ b/client/src/mobile/components/MToggle.tsx
@@ -0,0 +1,30 @@
+interface MToggleProps {
+ checked: boolean
+ onChange: (checked: boolean) => void
+ ariaLabel?: string
+ disabled?: boolean
+ className?: string
+}
+
+/** iOS-style switch: --m-act track when on, --m-trackoff when off, 18px knob. */
+export default function MToggle({ checked, onChange, ariaLabel, disabled = false, className = '' }: MToggleProps) {
+ return (
+ onChange(!checked)}
+ className={`box-border h-[22px] w-10 flex-none rounded-full p-[2px] transition-colors duration-[180ms] disabled:opacity-50 ${
+ checked ? 'bg-m-act' : 'bg-[color:var(--m-trackoff)]'
+ } ${className}`}
+ >
+
+
+ )
+}
diff --git a/client/src/mobile/mobile.css b/client/src/mobile/mobile.css
new file mode 100644
index 000000000..3ed4be622
--- /dev/null
+++ b/client/src/mobile/mobile.css
@@ -0,0 +1,323 @@
+/* ── Mobile rewrite design tokens ─────────────────────────────────────────────
+ * Scoped to .m-root (the MobileShell wrapper) so the mobile design system
+ * never leaks into desktop chrome. Values are the mobile design ground truth;
+ * dark values follow the app-wide `.dark` class strategy.
+ */
+.m-root {
+ --m-ink: #101013;
+ --m-muted: #68686F;
+ --m-faint: #9A9AA1;
+ --m-bg: #F3F2EF;
+ --m-scr: radial-gradient(120% 80% at 50% -10%, #FBFAF7 0%, #F1EFEA 55%, #E9E7E1 100%);
+ --m-glass: rgba(255,255,255,.6);
+ --m-gbr: rgba(255,255,255,.75);
+ --m-card: rgba(255,255,255,.55);
+ --m-cbr: rgba(255,255,255,.75);
+ --m-inner: rgba(255,255,255,.68);
+ --m-inbr: rgba(255,255,255,.9);
+ --m-act: #101013;
+ --m-actfg: #fff;
+ --m-dim: rgba(16,16,19,.4);
+ --m-sheet: rgba(250,250,248,.94);
+ --m-sheetop: #FAFAF8;
+ --m-shbr: rgba(255,255,255,.95);
+ --m-ic: rgba(16,16,19,.04);
+ --m-avbr: rgba(255,255,255,.9);
+ --m-rowbr: rgba(16,16,19,.045);
+ --m-conn: #9A9AA1;
+ --m-mapb: #E8E6E1;
+ --m-mapr: #FBFAF8;
+ --m-mapw: #DFE2E4;
+ --m-mkr: #101013;
+ --m-mkfg: #fff;
+ --m-trackoff: rgba(16,16,19,.14);
+
+ /* Toggle knob is white on both themes (off-track is translucent enough). */
+ --m-knob: #fff;
+
+ /* Status color canon — theme independent. */
+ --m-st-confirmed: #2FA37A;
+ --m-st-pending: #E8A13A;
+ --m-st-info: #4A7DDB;
+ --m-st-danger: #D6273B;
+ --m-st-neutral: #9A9AA1;
+
+ /* Chrome metrics. --bottom-nav-h stays the single source of truth for
+ * content clearance (same value the legacy shell uses). The demo's fixed
+ * 52px/26px offsets stand in for the phone status bar / home indicator, so
+ * real devices derive them from the safe-area insets. */
+ --bottom-nav-h: calc(84px + env(safe-area-inset-bottom, 0px));
+ --m-safe-top: calc(env(safe-area-inset-top, 0px) + 12px);
+}
+
+.dark .m-root {
+ --m-ink: #F5F5F7;
+ --m-muted: #9C9CA3;
+ --m-faint: #6E6E76;
+ --m-bg: #0A0A0C;
+ --m-scr: radial-gradient(120% 80% at 50% -10%, #17171C 0%, #0C0C0F 55%, #08080A 100%);
+ --m-glass: rgba(26,26,31,.6);
+ --m-gbr: rgba(255,255,255,.12);
+ --m-card: rgba(24,24,29,.55);
+ --m-cbr: rgba(255,255,255,.1);
+ --m-inner: rgba(255,255,255,.07);
+ --m-inbr: rgba(255,255,255,.1);
+ --m-act: #F5F5F7;
+ --m-actfg: #101013;
+ --m-dim: rgba(0,0,0,.55);
+ --m-sheet: rgba(22,22,27,.94);
+ --m-sheetop: #191920;
+ --m-shbr: rgba(255,255,255,.14);
+ --m-ic: rgba(255,255,255,.09);
+ --m-avbr: rgba(255,255,255,.14);
+ --m-rowbr: rgba(255,255,255,.07);
+ --m-conn: #6E6E76;
+ --m-mapb: #121215;
+ --m-mapr: #1E1F24;
+ --m-mapw: #0D1013;
+ --m-mkr: #F5F5F7;
+ --m-mkfg: #101013;
+ --m-trackoff: rgba(255,255,255,.14);
+}
+
+/* Geist "subtext" tier weight — single knob. The design's Geist labels/numbers
+ * read heavy at their declared bold; render the whole tier at Medium (500) for
+ * the fine, low-key look. Higher specificity than Tailwind's font-* utilities,
+ * so it caps font-medium / font-semibold / font-bold / font-extrabold on any
+ * .font-geist span. Retune here if a weight feels too light or heavy. */
+.m-root .font-geist { font-weight: 500; }
+
+/* ── Keyframes ──────────────────────────────────────────────────────────── */
+
+@keyframes m-sheet-in {
+ from { opacity: 0; transform: translateY(24px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+@keyframes m-sheet-out {
+ from { opacity: 1; transform: translateY(0); }
+ to { opacity: 0; transform: translateY(24px); }
+}
+@keyframes m-drawer-in {
+ from { transform: translateX(-100%); }
+ to { transform: translateX(0); }
+}
+@keyframes m-drawer-out {
+ from { transform: translateX(0); }
+ to { transform: translateX(-100%); }
+}
+@keyframes m-fade-in {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+@keyframes m-fade-out {
+ from { opacity: 1; }
+ to { opacity: 0; }
+}
+@keyframes m-pop-in {
+ from { opacity: 0; transform: translateY(-6px) scale(.97); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
+}
+@keyframes m-toast-in {
+ from { opacity: 0; transform: translateY(10px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+@keyframes m-toast-out {
+ from { opacity: 1; transform: translateY(0); }
+ to { opacity: 0; transform: translateY(6px); }
+}
+
+.m-sheet-in { animation: m-sheet-in 280ms var(--ease-drawer) both; }
+.m-sheet-out { animation: m-sheet-out 280ms var(--ease-drawer) both; }
+.m-drawer-in { animation: m-drawer-in 280ms var(--ease-drawer) both; }
+.m-drawer-out { animation: m-drawer-out 280ms var(--ease-drawer) both; }
+.m-fade-in { animation: m-fade-in 280ms var(--ease-drawer) both; }
+.m-fade-out { animation: m-fade-out 280ms var(--ease-drawer) both; }
+.m-pop-in { animation: m-pop-in 220ms var(--ease-out-quint) both; }
+.m-toast-in { animation: m-toast-in 260ms var(--ease-out-quint) both; }
+.m-toast-out { animation: m-toast-out 220ms ease both; }
+
+/* Reduced motion: gentler, not zero (same rule the desktop keyframes follow). */
+@media (prefers-reduced-motion: reduce) {
+ .m-sheet-in, .m-drawer-in, .m-pop-in, .m-toast-in {
+ animation: m-fade-in 120ms ease-out both;
+ }
+ .m-sheet-out, .m-drawer-out, .m-toast-out {
+ animation: m-fade-out 120ms ease-out both;
+ }
+}
+
+/* ── TREK mascot (empty-state) — logo-as-body character, per-scene props ───── */
+@keyframes trek-idle { 0%, 100% { transform: translateY(0) rotate(-1.1deg); } 50% { transform: translateY(-2px) rotate(1.1deg); } }
+@keyframes trek-breathe { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.012, 1.024); } }
+@keyframes trek-shadow { 0%, 100% { transform: scaleX(1); opacity: .15; } 50% { transform: scaleX(1.05); opacity: .17; } }
+/* body hop, occasional: long rest, then anticipation squash → airborne → land → settle */
+@keyframes trek-hop {
+ 0%, 74%, 100% { transform: translateY(0) scale(1, 1); }
+ 79% { transform: translateY(1px) scale(1.05, .92); }
+ 87% { transform: translateY(-10px) scale(.95, 1.07); }
+ 94% { transform: translateY(0) scale(1.07, .93); }
+ 98% { transform: translateY(0) scale(.99, 1.01); }
+}
+/* body nod — a pleased look left-and-right, e.g. cheering the poll results */
+@keyframes trek-nod {
+ 0%, 100% { transform: translateY(0) rotate(0); }
+ 25% { transform: translateY(-2px) rotate(-4deg); }
+ 50% { transform: translateY(0) rotate(0); }
+ 75% { transform: translateY(-2px) rotate(4deg); }
+}
+/* slow blink: eyes squeeze shut for a couple of frames each cycle */
+@keyframes trek-blink { 0%, 90%, 100% { transform: scaleY(1); } 94%, 97% { transform: scaleY(.12); } }
+/* entrance / poke-to-react pop-in (plays on mount and on each remount/tap) */
+@keyframes trek-in {
+ 0% { opacity: 0; transform: translateY(7px) scale(.85); }
+ 60% { opacity: 1; }
+ 100% { opacity: 1; transform: translateY(0) scale(1); }
+}
+/* pupils glancing around — centred most of the time, occasional look down/side */
+@keyframes trek-look {
+ 0%, 34%, 100% { transform: translate(0, 0); }
+ 42%, 52% { transform: translate(0, .9px); }
+ 64%, 72% { transform: translate(.9px, .1px); }
+ 84%, 90% { transform: translate(-.8px, .2px); }
+}
+/* wheels rolling in place */
+@keyframes trek-roll { to { transform: rotate(360deg); } }
+/* skateboard ollie — body and board pop off the ground together, now and then */
+@keyframes trek-sk-body {
+ 0%, 68%, 100% { transform: translateY(0) scale(1, 1); }
+ 73% { transform: translateY(1.5px) scale(1.04, .95); }
+ 84% { transform: translateY(-9px) scale(.97, 1.05); }
+ 92% { transform: translateY(0) scale(1.05, .95); }
+ 96% { transform: translateY(0) scale(1, 1); }
+}
+@keyframes trek-sk-board {
+ 0%, 68%, 100% { transform: translateY(0) rotate(0); }
+ 73% { transform: translateY(0) rotate(-6deg); }
+ 84% { transform: translateY(-8px) rotate(7deg); }
+ 90% { transform: translateY(-3px) rotate(-3deg); }
+ 95% { transform: translateY(0) rotate(0); }
+}
+/* guide pennant fluttering — pivots at the pole */
+@keyframes trek-flag {
+ 0%, 100% { transform: skewY(0) scaleX(1); }
+ 30% { transform: skewY(-7deg) scaleX(.96); }
+ 60% { transform: skewY(6deg) scaleX(1.03); }
+}
+/* poll bars staying up, gently rising and settling like live tallies */
+@keyframes trek-bar { 0%, 100% { transform: scaleY(.78); } 50% { transform: scaleY(1); } }
+@keyframes trek-typing { 0%, 70%, 100% { transform: scale(.5); opacity: .4; } 35% { transform: scale(1); opacity: 1; } }
+/* card / note rocking gently */
+@keyframes trek-ticket { 0%, 100% { transform: rotate(-2.5deg); } 50% { transform: rotate(2.5deg); } }
+/* a line being written: draws in from the left, holds, then a fresh pass */
+@keyframes trek-write {
+ 0% { transform: scaleX(0); }
+ 30%, 82% { transform: scaleX(1); }
+ 92%, 100% { transform: scaleX(0); }
+}
+/* pencil scribbling as the lines appear */
+@keyframes trek-pencil {
+ 0%, 100% { transform: translate(0, 0) rotate(0); }
+ 20% { transform: translate(-1.5px, 1.5px) rotate(-4deg); }
+ 45% { transform: translate(1.5px, -.5px) rotate(3deg); }
+ 70% { transform: translate(-1px, 1px) rotate(-2deg); }
+}
+/* suitcase rocking on its wheels, restless to get going */
+@keyframes trek-suitcase { 0%, 100% { transform: rotate(-2.5deg); } 50% { transform: rotate(2.5deg); } }
+/* suitcase handle telescoping up and down */
+@keyframes trek-handle { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-2px); } }
+/* collections map pin bobbing */
+@keyframes trek-pin { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }
+/* atlas globe meridian drifting to fake a slow spin */
+@keyframes trek-globe { 0%, 100% { transform: translateX(-2px); } 50% { transform: translateX(2px); } }
+/* costs top coin bouncing on */
+@keyframes trek-coin { 0%, 100% { transform: translateY(0); } 30% { transform: translateY(-5px); } 55% { transform: translateY(0); } 70% { transform: translateY(-2px); } 84% { transform: translateY(0); } }
+/* journey page turning over the spine */
+@keyframes trek-page { 0%, 55%, 100% { transform: scaleX(1); } 78% { transform: scaleX(-.9); } }
+/* dashboard paper plane bobbing in flight */
+@keyframes trek-plane { 0%, 100% { transform: translate(0, 0) rotate(0); } 25% { transform: translate(-1px, -2px) rotate(-4deg); } 50% { transform: translate(0, 0) rotate(0); } 75% { transform: translate(1px, 2px) rotate(4deg); } }
+/* notifications Zzz drifting up and fading */
+@keyframes trek-z { 0% { opacity: 0; transform: translate(0, 4px) scale(.7); } 30% { opacity: .9; } 100% { opacity: 0; transform: translate(3px, -8px) scale(1.1); } }
+/* search magnifier scanning */
+@keyframes trek-magnifier { 0%, 100% { transform: translateX(-3px) rotate(-3deg); } 50% { transform: translateX(3px) rotate(3deg); } }
+/* to-do checkmark ticking on, holding, then resetting */
+@keyframes trek-check { 0%, 8% { transform: scale(0); } 22% { transform: scale(1.2); } 32%, 88% { transform: scale(1); } 96%, 100% { transform: scale(0); } }
+
+.trek { animation: trek-in 480ms cubic-bezier(.34, 1.4, .64, 1) both; transform-origin: 50% 100%; }
+.trek-root { animation: trek-idle 3.4s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 100%; }
+.trek-body { animation: trek-breathe 3.8s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 100%; }
+.trek-shadow { animation: trek-shadow 3.4s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 50%; opacity: .15; filter: blur(1px); }
+/* eyes read as cutouts in the surface behind the mascot, so they stay visible on the monochrome body */
+.trek-eye { fill: var(--m-bg); }
+.trek-eyes { animation: trek-blink 5.2s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
+.trek-pupils { animation: trek-look 7s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
+/* body bounce — per scene */
+.trek-bounce { transform-box: fill-box; transform-origin: 50% 100%; }
+.trek-bounce--idle { animation: trek-hop 4.6s ease-in-out infinite; }
+.trek-bounce--polls { animation: trek-nod 3s ease-in-out infinite; }
+.trek-bounce--transport { animation: trek-sk-body 3.6s ease-in-out infinite; }
+/* per-scene props */
+.trek-board { animation: trek-sk-board 3.6s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 65%; }
+.trek-wheel { animation: trek-roll 1.1s linear infinite; transform-box: fill-box; transform-origin: center; }
+.trek-flag { animation: trek-flag 1.8s ease-in-out infinite; transform-box: fill-box; transform-origin: 0% 50%; }
+.trek-ticket { animation: trek-ticket 3.6s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 100%; }
+.trek-note { animation: trek-ticket 3.9s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 100%; }
+.trek-pencil { animation: trek-pencil 1.15s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
+.trek-write { animation: trek-write 3.8s ease-in-out infinite; transform-box: fill-box; transform-origin: 0% 50%; }
+.trek-bar { animation: trek-bar 2.6s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 100%; }
+.trek-dot { animation: trek-typing 1.4s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 50%; }
+.trek-suitcase { animation: trek-suitcase 1.5s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 100%; }
+.trek-handle { animation: trek-handle 1.5s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 100%; }
+.trek-pin { animation: trek-pin 2.2s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 100%; }
+.trek-globe { animation: trek-globe 3s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
+.trek-coin { animation: trek-coin 2s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 100%; }
+.trek-page { animation: trek-page 3.4s ease-in-out infinite; transform-box: fill-box; transform-origin: 0% 50%; }
+.trek-plane { animation: trek-plane 3s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
+.trek-magnifier { animation: trek-magnifier 2.4s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
+.trek-z { animation: trek-z 2.8s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
+.trek-check { animation: trek-check 3s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
+/* staggered starts for written lines / poll bars / Zzz */
+.trek-d2, .trek-z2 { animation-delay: .5s; }
+.trek-d3, .trek-z3 { animation-delay: 1s; }
+
+/* Mobile range slider — the --m-* twin of the desktop .trek-range. */
+.m-range {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 100%;
+ height: 5px;
+ border-radius: 999px;
+ background: linear-gradient(to right, var(--m-act) var(--fill, 50%), var(--m-trackoff) var(--fill, 50%));
+ outline: none;
+ cursor: pointer;
+}
+.m-range::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ background: var(--m-knob);
+ border: 1px solid var(--m-rowbr);
+ box-shadow: 0 1px 4px rgba(0, 0, 0, .25);
+ transition: transform .12s ease;
+}
+.m-range::-webkit-slider-thumb:hover { transform: scale(1.1); }
+.m-range::-webkit-slider-thumb:active { transform: scale(1.18); }
+.m-range::-moz-range-thumb {
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ background: var(--m-knob);
+ border: 1px solid var(--m-rowbr);
+ box-shadow: 0 1px 4px rgba(0, 0, 0, .25);
+}
+.m-range::-moz-range-track { height: 5px; border-radius: 999px; background: transparent; }
+
+@media (prefers-reduced-motion: reduce) {
+ .trek, .trek-root, .trek-body, .trek-shadow, .trek-eyes, .trek-pupils,
+ .trek-bounce--idle, .trek-bounce--polls, .trek-bounce--transport,
+ .trek-board, .trek-wheel, .trek-flag, .trek-ticket, .trek-note, .trek-pencil, .trek-write, .trek-bar,
+ .trek-suitcase, .trek-handle, .trek-pin, .trek-globe, .trek-coin, .trek-page, .trek-plane,
+ .trek-magnifier, .trek-z, .trek-check { animation: none; }
+ /* Zzz would sit invisible at opacity 0 without their animation — pin them visible */
+ .trek-z { opacity: 1; }
+}
diff --git a/client/src/mobile/screens/admin/MAdmin.tsx b/client/src/mobile/screens/admin/MAdmin.tsx
new file mode 100644
index 000000000..09de72f1a
--- /dev/null
+++ b/client/src/mobile/screens/admin/MAdmin.tsx
@@ -0,0 +1,246 @@
+import { useState } from 'react'
+import {
+ ArrowUp,
+ Bell,
+ Blocks,
+ Briefcase,
+ Bug,
+ ChevronDown,
+ ChevronLeft,
+ Database,
+ FileText,
+ Github,
+ Map,
+ Plug,
+ Puzzle,
+ ScrollText,
+ Settings as SettingsIcon,
+ Shield,
+ SlidersHorizontal,
+ UserCog,
+ UserPlus,
+ Users,
+} from 'lucide-react'
+import type { LucideIcon } from 'lucide-react'
+import { adminApi } from '../../../api/client'
+import { useTranslation } from '../../../i18n'
+import { useCountUp } from '../../../hooks/useCountUp'
+import { useAdmin } from '../../../pages/admin/useAdmin'
+import MAdminAddonManager from './MAdminAddonManager'
+import MAdminMcpTokensPanel from './MAdminMcpTokensPanel'
+import MAdminPluginsPanel from './MAdminPluginsPanel'
+import MAdminAuditLogPanel from './MAdminAuditLogPanel'
+import MAdminBackupPanel from './MAdminBackupPanel'
+import MAdminCategoryManager from './MAdminCategoryManager'
+import MAdminDefaultUserSettings from './MAdminDefaultUserSettings'
+import MAdminDevNotificationsPanel from './MAdminDevNotificationsPanel'
+import MAdminGitHubPanel from './MAdminGitHubPanel'
+import MAdminPackingTemplateManager from './MAdminPackingTemplateManager'
+import MAdminNotificationsSection from './MAdminNotificationsSection'
+import MAdminSettingsSection from './MAdminSettingsSection'
+import MAdminSheets from './MAdminSheets'
+import MAdminUsersSection from './MAdminUsersSection'
+import { MAdminButton } from './MAdminUi'
+
+// Stat card of the 2×2 grid (design §6.3): 34px icon tile, 19px/800 number,
+// small tracking label. Count-up animation matches the desktop stat cards.
+function MAdminStat({ label, value, icon: Icon }: { label: string; value: number; icon: LucideIcon }) {
+ const animated = useCountUp(value, 900)
+ return (
+
+ )
+}
+
+/**
+ * Mobile admin screen (design §6): section switcher pill with the 11-entry
+ * dropdown, update banner, stats grid and the users section; every other
+ * desktop admin section stays reachable through the dropdown.
+ */
+export default function MAdmin() {
+ const { t, locale } = useTranslation()
+ const admin = useAdmin()
+ const {
+ demoMode, mcpEnabled, devMode, toast, navigate,
+ activeTab, setActiveTab, stats, serverTimezone,
+ bagTrackingEnabled, setBagTrackingEnabled,
+ collabFeatures, setCollabFeatures,
+ setShowCreateUser,
+ updateInfo, setShowUpdateModal,
+ } = admin
+ const [sectionsOpen, setSectionsOpen] = useState(false)
+
+ // Same sections and gating as the desktop sidebar, ordered and iconed like
+ // the design's dropdown (§6.1).
+ const sections: { id: string; label: string; icon: LucideIcon }[] = [
+ { id: 'users', label: t('admin.tabs.users'), icon: Users },
+ { id: 'defaults', label: t('admin.tabs.defaults'), icon: UserCog },
+ { id: 'config', label: t('admin.tabs.config'), icon: SlidersHorizontal },
+ { id: 'settings', label: t('admin.tabs.settings'), icon: SettingsIcon },
+ { id: 'addons', label: t('admin.tabs.addons'), icon: Blocks },
+ { id: 'plugins', label: t('admin.tabs.plugins'), icon: Puzzle },
+ { id: 'notifications', label: t('admin.tabs.notifications'), icon: Bell },
+ ...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens'), icon: Plug }] : []),
+ { id: 'github', label: t('admin.tabs.github'), icon: Github },
+ { id: 'backup', label: t('admin.tabs.backup'), icon: Database },
+ { id: 'audit', label: t('admin.tabs.audit'), icon: ScrollText },
+ ...(devMode ? [{ id: 'dev-notifications', label: 'Dev: Notifications', icon: Bug }] : []),
+ ]
+ const activeSection = sections.find((s) => s.id === activeTab) ?? sections[0]
+
+ const saveDemoBaseline = async () => {
+ try {
+ await adminApi.saveDemoBaseline()
+ toast.success('Baseline saved! Resets will restore to this state.')
+ } catch (e) {
+ toast.error(e.response?.data?.error || 'Failed to save baseline')
+ }
+ }
+
+ return (
+
+ {/* Header: back · section switcher · create user */}
+
+ navigate('/dashboard')}
+ className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_12px_-8px_rgba(0,0,0,.18)]"
+ >
+
+
+ setSectionsOpen((v) => !v)}
+ className="flex h-[38px] min-w-0 flex-1 items-center gap-[7px] rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] px-[14px] text-[0.8125rem] font-bold text-m-ink shadow-[0_5px_12px_-8px_rgba(0,0,0,.18)]"
+ >
+
+ {activeSection.label}
+
+
+ setShowCreateUser(true)}
+ className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full bg-m-act text-m-actfg shadow-[0_5px_12px_-8px_rgba(0,0,0,.3)]"
+ >
+
+
+
+
+ {/* Section dropdown */}
+ {sectionsOpen && (
+
+ {sections.map((section) => (
+ {
+ setActiveTab(section.id)
+ setSectionsOpen(false)
+ }}
+ className={`flex w-full items-center gap-[10px] rounded-[11px] p-[10px] text-left text-[0.8125rem] font-semibold text-m-ink ${
+ section.id === activeSection.id ? 'bg-[color:var(--m-ic)]' : ''
+ }`}
+ >
+
+ {section.label}
+
+ ))}
+
+ )}
+
+ {/* Update banner (§6.2) — real update check, shown on every section */}
+ {updateInfo && (
+
+
+
+
+
+
{t('admin.update.available')}
+
+ {t('mobileAdmin.updateLine', { version: `v${updateInfo.latest}`, current: `v${updateInfo.current}` })}
+
+
+
setShowUpdateModal(true)}>{t('mobileAdmin.updateAction')}
+
+ )}
+
+ {/* Demo baseline (demo instances only) */}
+ {demoMode && (
+
+
+
Demo Baseline
+
+ Save current state as the hourly reset point. All admin trips and settings will be preserved.
+
+
+
Save Baseline
+
+ )}
+
+ {/* Stats grid (§6.3) */}
+ {stats && (
+
+
+
+
+
+
+ )}
+
+ {/* Active section */}
+ {activeTab === 'users' &&
}
+ {activeTab === 'settings' &&
}
+ {activeTab === 'notifications' &&
}
+ {activeTab === 'defaults' &&
}
+ {activeTab === 'config' && (
+
+
+
+
+ )}
+ {activeTab === 'addons' && (
+
{
+ const next = !bagTrackingEnabled
+ setBagTrackingEnabled(next)
+ try {
+ await adminApi.updateBagTracking(next)
+ } catch {
+ setBagTrackingEnabled(!next)
+ }
+ }}
+ collabFeatures={collabFeatures}
+ onToggleCollabFeature={async (key: string) => {
+ const next = { ...collabFeatures, [key]: !collabFeatures[key] }
+ setCollabFeatures(next)
+ try {
+ await adminApi.updateCollabFeatures({ [key]: next[key] })
+ } catch {
+ setCollabFeatures(collabFeatures)
+ }
+ }}
+ />
+ )}
+ {activeTab === 'plugins' && }
+ {activeTab === 'mcp-tokens' && }
+ {activeTab === 'github' && }
+ {activeTab === 'backup' && }
+ {activeTab === 'audit' && }
+ {activeTab === 'dev-notifications' && }
+
+
+
+ )
+}
diff --git a/client/src/mobile/screens/admin/MAdminAddonManager.tsx b/client/src/mobile/screens/admin/MAdminAddonManager.tsx
new file mode 100644
index 000000000..62b889a0a
--- /dev/null
+++ b/client/src/mobile/screens/admin/MAdminAddonManager.tsx
@@ -0,0 +1,610 @@
+import { useEffect, useState } from 'react'
+import { adminApi } from '../../../api/client'
+import { useTranslation } from '../../../i18n'
+import { useSettingsStore } from '../../../store/settingsStore'
+import { useAddonStore } from '../../../store/addonStore'
+import { useToast } from '../../../components/shared/Toast'
+import {
+ Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen,
+ MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Check, Loader2,
+} from 'lucide-react'
+import MToggle from '../../components/MToggle'
+import { MAdminButton, MAdminCard, MAdminField, MAdminInput, MAdminSecretInput } from './MAdminUi'
+
+const ICON_MAP = {
+ ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark,
+}
+
+function ImmichIcon({ size = 14 }: { size?: number }) {
+ return (
+
+
+
+ )
+}
+
+function SynologyIcon({ size = 14 }: { size?: number }) {
+ return (
+
+
+
+ )
+}
+
+const PROVIDER_ICONS: Record> = {
+ immich: ImmichIcon,
+ synologyphotos: SynologyIcon,
+}
+
+interface Addon {
+ id: string
+ name: string
+ description: string
+ icon: string
+ type: string
+ enabled: boolean
+ config?: Record
+}
+
+interface ProviderOption {
+ key: string
+ label: string
+ description: string
+ enabled: boolean
+ toggle: () => Promise
+}
+
+interface AddonIconProps {
+ name: string
+ size?: number
+}
+
+function AddonIcon({ name, size = 18 }: AddonIconProps) {
+ const Icon = ICON_MAP[name] || Puzzle
+ return
+}
+
+interface CollabFeatures { chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }
+
+const COLLAB_SUB_FEATURES = [
+ { key: 'chat', icon: MessageCircle, titleKey: 'admin.collab.chat.title', subtitleKey: 'admin.collab.chat.subtitle' },
+ { key: 'notes', icon: StickyNote, titleKey: 'admin.collab.notes.title', subtitleKey: 'admin.collab.notes.subtitle' },
+ { key: 'polls', icon: BarChart3, titleKey: 'admin.collab.polls.title', subtitleKey: 'admin.collab.polls.subtitle' },
+ { key: 'whatsnext', icon: Sparkles, titleKey: 'admin.collab.whatsnext.title', subtitleKey: 'admin.collab.whatsnext.subtitle' },
+] as const
+
+export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTracking, collabFeatures, onToggleCollabFeature }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void; collabFeatures?: CollabFeatures; onToggleCollabFeature?: (key: string) => void }) {
+ const { t } = useTranslation()
+ const dm = useSettingsStore(s => s.settings.dark_mode)
+ const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
+ const toast = useToast()
+ const refreshGlobalAddons = useAddonStore(s => s.loadAddons)
+ const [addons, setAddons] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ loadAddons()
+ }, [])
+
+ const loadAddons = async () => {
+ setLoading(true)
+ try {
+ const data = await adminApi.addons()
+ setAddons(data.addons)
+ } catch (err: unknown) {
+ toast.error(t('admin.addons.toast.error'))
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleToggle = async (addon: Addon) => {
+ const newEnabled = !addon.enabled
+ // Optimistic update
+ setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a))
+ try {
+ await adminApi.updateAddon(addon.id, { enabled: newEnabled })
+ refreshGlobalAddons()
+ toast.success(t('admin.addons.toast.updated'))
+ } catch (err: unknown) {
+ // Rollback
+ setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: !newEnabled } : a))
+ toast.error(t('admin.addons.toast.error'))
+ }
+ }
+
+ const isPhotoProviderAddon = (addon: Addon) => {
+ return addon.type === 'photo_provider'
+ }
+
+ const isPhotosAddon = (addon: Addon) => {
+ const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase()
+ return addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories'))
+ }
+
+ const handleTogglePhotoProvider = async (providerAddon: Addon) => {
+ const enableProvider = !providerAddon.enabled
+ const prev = addons
+
+ setAddons(current => current.map(a => a.id === providerAddon.id ? { ...a, enabled: enableProvider } : a))
+
+ try {
+ await adminApi.updateAddon(providerAddon.id, { enabled: enableProvider })
+ refreshGlobalAddons()
+ toast.success(t('admin.addons.toast.updated'))
+ } catch {
+ setAddons(prev)
+ toast.error(t('admin.addons.toast.error'))
+ }
+ }
+
+ const photoProviderAddons = addons.filter(isPhotoProviderAddon)
+ const tripAddons = addons.filter(a => a.type === 'trip' && !isPhotosAddon(a))
+ const globalAddons = addons.filter(a => a.type === 'global')
+ const integrationAddons = addons.filter(a => a.type === 'integration')
+ const providerOptions: ProviderOption[] = photoProviderAddons.map((provider) => ({
+ key: provider.id,
+ label: provider.name,
+ description: provider.description,
+ enabled: provider.enabled,
+ toggle: () => handleTogglePhotoProvider(provider),
+ }))
+
+ if (loading) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+ {/* Header */}
+
+ {t('admin.addons.title')}
+
+ {t('admin.addons.subtitleBefore')}
+
+ {t('admin.addons.subtitleAfter')}
+
+
+
+ {addons.length === 0 ? (
+
+
+ {t('admin.addons.noAddons')}
+
+
+ ) : (
+ <>
+ {/* Trip Addons */}
+ {tripAddons.length > 0 && (
+
+
+ {tripAddons.map((addon, i) => (
+
+
+ {addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
+
+ )}
+ {addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && (
+ <>
+ {COLLAB_SUB_FEATURES.map(feat => (
+ onToggleCollabFeature(feat.key)}
+ />
+ ))}
+ >
+ )}
+
+ ))}
+
+ )}
+
+ {/* Global Addons */}
+ {globalAddons.length > 0 && (
+
+
+ {globalAddons.map((addon, i) => (
+
+
+ {/* Memories providers as sub-items under Journey addon */}
+ {addon.id === 'journey' && providerOptions.length > 0 && (
+ <>
+ {providerOptions.map(provider => {
+ const ProviderIcon = PROVIDER_ICONS[provider.key]
+ return (
+
+ )
+ })}
+ >
+ )}
+
+ ))}
+
+ )}
+
+ {/* Integration Addons */}
+ {integrationAddons.length > 0 && (
+
+
+ {integrationAddons.map((addon, i) => (
+
+
+ {addon.id === 'llm_parsing' && addon.enabled && (
+
+
+
+ )}
+
+ ))}
+
+ )}
+ >
+ )}
+
+ )
+}
+
+/** Group subheader: small icon + uppercase label, matches the desktop section band. */
+function MGroupHead({ icon: Icon, label }: { icon: typeof Briefcase; label: string }) {
+ return (
+
+
+ {label}
+
+ )
+}
+
+function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } {
+ const nameKey = `admin.addons.catalog.${addon.id}.name`
+ const descKey = `admin.addons.catalog.${addon.id}.description`
+ const translatedName = t(nameKey)
+ const translatedDescription = t(descKey)
+
+ return {
+ name: translatedName !== nameKey ? translatedName : addon.name,
+ description: translatedDescription !== descKey ? translatedDescription : addon.description,
+ }
+}
+
+interface MAddonRowProps {
+ addon: Addon
+ onToggle: (addon: Addon) => void
+ t: (key: string) => string
+ first?: boolean
+}
+
+/** Addon row: icon tile, name + type badge, description, MToggle. */
+function MAddonRow({ addon, onToggle, t, first }: MAddonRowProps) {
+ const label = getAddonLabel(t, addon)
+ const typeLabel =
+ addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')
+ return (
+
+
+
+
+
+
+ {label.name}
+
+ {typeLabel}
+
+
+
{label.description}
+
+
onToggle(addon)} />
+
+ )
+}
+
+interface MSubRowProps {
+ icon?: typeof Luggage
+ providerIcon?: React.FC<{ size?: number }>
+ title: string
+ subtitle: string
+ enabled: boolean
+ onToggle: () => void
+}
+
+/** Indented sub-feature row (bag tracking, collab features, memory providers). */
+function MSubRow({ icon: Icon, providerIcon: ProviderIcon, title, subtitle, enabled, onToggle }: MSubRowProps) {
+ return (
+
+
+ {Icon ? : ProviderIcon ? : null}
+
+
+
+
+ )
+}
+
+const MASKED = '••••••••'
+const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1'
+
+/** Curated models the local extractor is tuned for, pullable via Ollama. The router drives
+ * one model per document via Ollama's grammar-constrained `format`; "thinking" is disabled
+ * automatically, so the Qwen3 family works without any tuning. A host only needs one. */
+const RECOMMENDED_MODELS: { id: string; label: string; note: string; recommended: boolean; vision: boolean }[] = [
+ { id: 'qwen3:8b', label: 'Qwen3 — 8B', note: 'Recommended · best extraction quality & speed on CPU (thinking auto-disabled) · Apache-2.0', recommended: true, vision: false },
+]
+
+/**
+ * Instance-wide AI-parsing config. When set, applies to the whole instance and
+ * overrides per-user config (see server llmConfig.ts). The API key is masked on
+ * read; an unchanged mask is treated as a no-op by the server. For the local
+ * provider, it also lists installed Ollama models and can pull NuExtract models.
+ */
+function LlmParsingConfig({ addon }: { addon: Addon }) {
+ const toast = useToast()
+ const cfg = (addon.config ?? {}) as Record
+ const [provider, setProvider] = useState((cfg.provider as string) ?? 'local')
+ const [model, setModel] = useState((cfg.model as string) ?? '')
+ const [baseUrl, setBaseUrl] = useState((cfg.baseUrl as string) ?? '')
+ const [apiKey, setApiKey] = useState((cfg.apiKey as string) ?? '')
+ const [saving, setSaving] = useState(false)
+
+ // Local-provider model management.
+ const [installed, setInstalled] = useState([])
+ const [modelsErr, setModelsErr] = useState('')
+ const [loadingModels, setLoadingModels] = useState(false)
+ const [pulling, setPulling] = useState(null)
+ const [pullPct, setPullPct] = useState(0)
+ const [pullStatus, setPullStatus] = useState('')
+
+ const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL
+ const isInstalled = (id: string) => installed.some(n => n === id || n.startsWith(id + ':') || n.startsWith(id))
+
+ const loadModels = async () => {
+ if (provider !== 'local') return
+ setLoadingModels(true)
+ setModelsErr('')
+ try {
+ const res = await adminApi.llmLocalModels(effectiveUrl)
+ setInstalled(res.models.map(m => m.name))
+ } catch (e: unknown) {
+ setModelsErr(e instanceof Error ? e.message : 'Could not reach the local LLM server')
+ setInstalled([])
+ } finally {
+ setLoadingModels(false)
+ }
+ }
+
+ // Load installed models when the local provider is active.
+ useEffect(() => {
+ if (provider === 'local') loadModels()
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [provider])
+
+ const pull = async (id: string) => {
+ if (pulling) return
+ setPulling(id)
+ setPullPct(0)
+ setPullStatus('starting…')
+ try {
+ await adminApi.llmLocalPull(effectiveUrl, id, (p) => {
+ if (p.error) throw new Error(p.error)
+ if (p.status) setPullStatus(p.status)
+ if (p.total && p.completed != null) setPullPct(Math.round((p.completed / p.total) * 100))
+ })
+ toast.success('Model pulled')
+ setModel(id)
+ await loadModels()
+ } catch (e: unknown) {
+ toast.error(e instanceof Error ? e.message : 'Pull failed')
+ } finally {
+ setPulling(null)
+ setPullPct(0)
+ setPullStatus('')
+ }
+ }
+
+ const save = async () => {
+ setSaving(true)
+ try {
+ // Send the masked sentinel unchanged so the server keeps the stored key.
+ await adminApi.updateAddon(addon.id, { config: { provider, model: model.trim(), baseUrl: baseUrl.trim(), apiKey, multimodal: cfg.multimodal === true } })
+ toast.success('Saved')
+ } catch {
+ toast.error('Failed to save')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const sectionCls = 'font-geist text-[0.625rem] font-bold uppercase tracking-[0.06em] text-m-faint'
+
+ const providerOptions = [
+ { value: 'local', label: 'Local · OpenAI-compatible', icon: , badge: 'Ollama' },
+ { value: 'openai', label: 'OpenAI', icon: },
+ { value: 'anthropic', label: 'Anthropic', icon: },
+ ]
+
+ return (
+
+
+ Set instance-wide config (applies to all users). Leave blank to let each user configure their own provider.
+
+
+ {/* Connection */}
+
+ Connection
+
+
+ {providerOptions.map((opt) => {
+ const active = provider === opt.value
+ return (
+ setProvider(opt.value)}
+ className={`flex w-full items-center gap-2 rounded-xl border px-3 py-[10px] text-left ${
+ active ? 'border-[color:var(--m-act)] bg-[color:var(--m-ic)]' : 'border-[color:var(--m-rowbr)]'
+ }`}
+ >
+ {opt.icon}
+ {opt.label}
+ {opt.badge && (
+
+ {opt.badge}
+
+ )}
+ {active && }
+
+ )
+ })}
+
+
+ {provider !== 'anthropic' && (
+
+ setBaseUrl(e.target.value)}
+ onBlur={loadModels}
+ placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'}
+ />
+
+ )}
+
+ setApiKey(e.target.value)}
+ placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'}
+ />
+
+ {provider === 'anthropic' && (
+
+ Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.
+
+ )}
+
+
+ {/* Model */}
+
+ Model
+ setModel(e.target.value)}
+ placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'}
+ />
+
+ {/* Local model management (Ollama) */}
+ {provider === 'local' && (
+
+
+ Installed on the server
+
+ {loadingModels ? 'Loading…' : 'Refresh'}
+
+
+ {modelsErr &&
{modelsErr}
}
+ {!modelsErr && installed.length === 0 && !loadingModels && (
+
No models installed yet — pull one below.
+ )}
+ {installed.length > 0 && (
+
+ {installed.map(name => (
+ setModel(name)}
+ className={`max-w-full truncate rounded-full border px-[10px] py-[5px] text-[0.6875rem] ${
+ model === name ? 'border-transparent bg-m-act text-m-actfg' : 'border-[color:var(--m-rowbr)] text-m-ink'
+ }`}
+ >
+ {name}
+
+ ))}
+
+ )}
+
+
+
Pull a recommended model
+
+ {RECOMMENDED_MODELS.map(m => {
+ const installedHere = isInstalled(m.id)
+ const isPulling = pulling === m.id
+ const active = model === m.id
+ return (
+
+
+
+ {m.label}
+ {m.recommended && (
+
+ Recommended
+
+ )}
+
+
{m.note}
+ {isPulling && (
+
+
+
{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}
+
+ )}
+
+ {installedHere ? (
+
setModel(m.id)}>
+ {active ? 'Selected' : 'Use'}
+
+ ) : (
+
pull(m.id)}>
+ {isPulling ? 'Pulling…' : 'Pull'}
+
+ )}
+
+ )
+ })}
+
+
+
+ )}
+
+
+
+ {saving ? 'Saving…' : 'Save'}
+
+
+ )
+}
diff --git a/client/src/mobile/screens/admin/MAdminAuditLogPanel.tsx b/client/src/mobile/screens/admin/MAdminAuditLogPanel.tsx
new file mode 100644
index 000000000..f7e4303ff
--- /dev/null
+++ b/client/src/mobile/screens/admin/MAdminAuditLogPanel.tsx
@@ -0,0 +1,195 @@
+import React, { useCallback, useEffect, useState } from 'react'
+import { RefreshCw, ClipboardList } from 'lucide-react'
+import { adminApi } from '../../../api/client'
+import { useTranslation } from '../../../i18n'
+import { MAdminButton } from './MAdminUi'
+
+interface AuditEntry {
+ id: number
+ created_at: string
+ user_id: number | null
+ username: string | null
+ user_email: string | null
+ action: string
+ resource: string | null
+ details: Record | null
+ ip: string | null
+}
+
+interface AuditLogPanelProps {
+ serverTimezone?: string
+}
+
+export default function MAdminAuditLogPanel({ serverTimezone }: AuditLogPanelProps): React.ReactElement {
+ const { t, locale } = useTranslation()
+ const [entries, setEntries] = useState([])
+ const [total, setTotal] = useState(0)
+ const [offset, setOffset] = useState(0)
+ const [loading, setLoading] = useState(true)
+ const limit = 100
+
+ const loadFirstPage = useCallback(async () => {
+ setLoading(true)
+ try {
+ const data = (await adminApi.auditLog({ limit, offset: 0 })) as {
+ entries: AuditEntry[]
+ total: number
+ }
+ setEntries(data.entries || [])
+ setTotal(data.total ?? 0)
+ setOffset(0)
+ } catch {
+ setEntries([])
+ setTotal(0)
+ setOffset(0)
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ const loadMore = useCallback(async () => {
+ const nextOffset = offset + limit
+ setLoading(true)
+ try {
+ const data = (await adminApi.auditLog({ limit, offset: nextOffset })) as {
+ entries: AuditEntry[]
+ total: number
+ }
+ setEntries((prev) => [...prev, ...(data.entries || [])])
+ setTotal(data.total ?? 0)
+ setOffset(nextOffset)
+ } catch {
+ /* keep existing */
+ } finally {
+ setLoading(false)
+ }
+ }, [offset])
+
+ useEffect(() => {
+ loadFirstPage()
+ }, [loadFirstPage])
+
+ const fmtTime = (iso: string) => {
+ try {
+ return new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString(locale, {
+ dateStyle: 'short',
+ timeStyle: 'medium',
+ timeZone: serverTimezone || undefined,
+ })
+ } catch {
+ return iso
+ }
+ }
+
+ const fmtDetails = (d: Record | null) => {
+ if (!d || Object.keys(d).length === 0) return '—'
+ try {
+ return JSON.stringify(d)
+ } catch {
+ return '—'
+ }
+ }
+
+ const userLabel = (e: AuditEntry) => {
+ if (e.username) return e.username
+ if (e.user_email) return e.user_email
+ if (e.user_id != null) return `#${e.user_id}`
+ return '—'
+ }
+
+ return (
+
+ {/* Header: title + subtitle + refresh */}
+
+
+
+
+ {t('admin.tabs.audit')}
+
+
+ {t('admin.audit.subtitle')}
+
+
+
loadFirstPage()}>
+ {!loading && }
+ {t('admin.audit.refresh')}
+
+
+
+
+ {t('admin.audit.showing', { count: entries.length, total })}
+
+
+ {loading && entries.length === 0 ? (
+
{t('common.loading')}
+ ) : entries.length === 0 ? (
+
{t('admin.audit.empty')}
+ ) : (
+
+ {entries.map((e) => (
+
+ {/* Action + time */}
+
+
+ {e.action}
+
+
+ {fmtTime(e.created_at)}
+
+
+
+ {/* User */}
+
+
+ {t('admin.audit.col.user')}
+
+ {userLabel(e)}
+
+
+ {/* Resource */}
+
+
+ {t('admin.audit.col.resource')}
+
+
+ {e.resource || '—'}
+
+
+
+ {/* IP */}
+
+
+ {t('admin.audit.col.ip')}
+
+
+ {e.ip || '—'}
+
+
+
+ {/* Details */}
+
+
+ {t('admin.audit.col.details')}
+
+
+ {fmtDetails(e.details)}
+
+
+
+ ))}
+
+ )}
+
+ {entries.length < total && (
+
+ loadMore()}>
+ {t('admin.audit.loadMore')}
+
+
+ )}
+
+ )
+}
diff --git a/client/src/mobile/screens/admin/MAdminBackupPanel.tsx b/client/src/mobile/screens/admin/MAdminBackupPanel.tsx
new file mode 100644
index 000000000..ca8d80584
--- /dev/null
+++ b/client/src/mobile/screens/admin/MAdminBackupPanel.tsx
@@ -0,0 +1,503 @@
+import { useState, useEffect, useRef } from 'react'
+import {
+ Check,
+ Clock,
+ Download,
+ HardDrive,
+ Plus,
+ RefreshCw,
+ RotateCcw,
+ Trash2,
+ Upload,
+} from 'lucide-react'
+import { backupApi } from '../../../api/client'
+import { useToast } from '../../../components/shared/Toast'
+import { useTranslation } from '../../../i18n'
+import { useSettingsStore } from '../../../store/settingsStore'
+import { getApiErrorMessage } from '../../../types'
+import MChip from '../../components/MChip'
+import MToggle from '../../components/MToggle'
+import MConfirmSheet from '../settings/MConfirmSheet'
+import { MAdminButton, MAdminCard, MAdminCardHead, MAdminField, MAdminRow } from './MAdminUi'
+
+const INTERVAL_OPTIONS = [
+ { value: 'hourly', labelKey: 'backup.interval.hourly' },
+ { value: 'daily', labelKey: 'backup.interval.daily' },
+ { value: 'weekly', labelKey: 'backup.interval.weekly' },
+ { value: 'monthly', labelKey: 'backup.interval.monthly' },
+]
+
+const KEEP_OPTIONS = [
+ { value: 1, labelKey: 'backup.keep.1day' },
+ { value: 3, labelKey: 'backup.keep.3days' },
+ { value: 7, labelKey: 'backup.keep.7days' },
+ { value: 14, labelKey: 'backup.keep.14days' },
+ { value: 30, labelKey: 'backup.keep.30days' },
+ { value: 0, labelKey: 'backup.keep.forever' },
+]
+
+const DAYS_OF_WEEK = [
+ { value: 0, labelKey: 'backup.dow.sunday' },
+ { value: 1, labelKey: 'backup.dow.monday' },
+ { value: 2, labelKey: 'backup.dow.tuesday' },
+ { value: 3, labelKey: 'backup.dow.wednesday' },
+ { value: 4, labelKey: 'backup.dow.thursday' },
+ { value: 5, labelKey: 'backup.dow.friday' },
+ { value: 6, labelKey: 'backup.dow.saturday' },
+]
+
+const HOURS = Array.from({ length: 24 }, (_, i) => i)
+
+const DAYS_OF_MONTH = Array.from({ length: 28 }, (_, i) => i + 1)
+
+const SELECT_CLASS =
+ 'h-[42px] w-full rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 text-[0.84375rem] text-m-ink outline-none focus:border-[color:var(--m-faint)]'
+
+export default function MAdminBackupPanel() {
+ const [backups, setBackups] = useState([])
+ const [isLoading, setIsLoading] = useState(false)
+ const [isCreating, setIsCreating] = useState(false)
+ const [restoringFile, setRestoringFile] = useState(null)
+ const [isUploading, setIsUploading] = useState(false)
+ const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 })
+ const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
+ const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
+ const [serverTimezone, setServerTimezone] = useState('')
+ const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? }
+ const [deleteConfirm, setDeleteConfirm] = useState(null) // filename pending deletion
+ const fileInputRef = useRef(null)
+ const toast = useToast()
+ const { t, locale } = useTranslation()
+ const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
+
+ const loadBackups = async () => {
+ setIsLoading(true)
+ try {
+ const data = await backupApi.list()
+ setBackups(data.backups || [])
+ } catch {
+ toast.error(t('backup.toast.loadError'))
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const loadAutoSettings = async () => {
+ try {
+ const data = await backupApi.getAutoSettings()
+ setAutoSettings(data.settings)
+ if (data.timezone) setServerTimezone(data.timezone)
+ } catch {}
+ }
+
+ useEffect(() => { loadBackups(); loadAutoSettings() }, [])
+
+ const handleCreate = async () => {
+ setIsCreating(true)
+ try {
+ await backupApi.create()
+ toast.success(t('backup.toast.created'))
+ await loadBackups()
+ } catch {
+ toast.error(t('backup.toast.createError'))
+ } finally {
+ setIsCreating(false)
+ }
+ }
+
+ const handleRestore = (filename) => {
+ setRestoreConfirm({ type: 'file', filename })
+ }
+
+ const handleUploadRestore = (e) => {
+ const file = (e.target as HTMLInputElement).files?.[0]
+ if (!file) return
+ e.target.value = ''
+ setRestoreConfirm({ type: 'upload', filename: file.name, file })
+ }
+
+ const executeRestore = async () => {
+ if (!restoreConfirm) return
+ const { type, filename, file } = restoreConfirm
+ setRestoreConfirm(null)
+
+ if (type === 'file') {
+ setRestoringFile(filename)
+ try {
+ await backupApi.restore(filename)
+ toast.success(t('backup.toast.restored'))
+ setTimeout(() => window.location.reload(), 1500)
+ } catch (err: unknown) {
+ toast.error(getApiErrorMessage(err, t('backup.toast.restoreError')))
+ setRestoringFile(null)
+ }
+ } else {
+ setIsUploading(true)
+ try {
+ await backupApi.uploadRestore(file)
+ toast.success(t('backup.toast.restored'))
+ setTimeout(() => window.location.reload(), 1500)
+ } catch (err: unknown) {
+ toast.error(getApiErrorMessage(err, t('backup.toast.uploadError')))
+ setIsUploading(false)
+ }
+ }
+ }
+
+ const handleDelete = (filename) => {
+ setDeleteConfirm(filename)
+ }
+
+ const executeDelete = async () => {
+ const filename = deleteConfirm
+ setDeleteConfirm(null)
+ if (!filename) return
+ try {
+ await backupApi.delete(filename)
+ toast.success(t('backup.toast.deleted'))
+ setBackups(prev => prev.filter(b => b.filename !== filename))
+ } catch {
+ toast.error(t('backup.toast.deleteError'))
+ }
+ }
+
+ const handleAutoSettingsChange = (key, value) => {
+ setAutoSettings(prev => ({ ...prev, [key]: value }))
+ setAutoSettingsDirty(true)
+ }
+
+ const handleSaveAutoSettings = async () => {
+ setAutoSettingsSaving(true)
+ try {
+ const data = await backupApi.setAutoSettings(autoSettings)
+ setAutoSettings(data.settings)
+ setAutoSettingsDirty(false)
+ toast.success(t('backup.toast.settingsSaved'))
+ } catch {
+ toast.error(t('backup.toast.settingsError'))
+ } finally {
+ setAutoSettingsSaving(false)
+ }
+ }
+
+ const formatSize = (bytes) => {
+ if (!bytes) return '-'
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`
+ }
+
+ const formatDate = (dateStr) => {
+ if (!dateStr) return '-'
+ try {
+ const opts: Intl.DateTimeFormatOptions = {
+ day: '2-digit', month: '2-digit', year: 'numeric',
+ hour: '2-digit', minute: '2-digit',
+ }
+ if (serverTimezone) opts.timeZone = serverTimezone
+ return new Date(dateStr).toLocaleString(locale, opts)
+ } catch { return dateStr }
+ }
+
+ const isAuto = (filename) => filename.startsWith('auto-backup-')
+
+ return (
+
+
+ {/* Manual Backups */}
+
+
+
+ {t('backup.title')}
+
+ }
+ hint={t('backup.subtitle')}
+ trailing={
+
+
+
+ }
+ />
+
+ {/* Upload & Create actions */}
+
+
+
fileInputRef.current?.click()}
+ title={isUploading ? t('backup.uploading') : t('backup.upload')}
+ >
+ {!isUploading && }
+ {isUploading ? t('backup.uploading') : t('backup.upload')}
+
+
+ {!isCreating && }
+ {isCreating ? t('backup.creating') : t('backup.create')}
+
+
+
+ {isLoading && backups.length === 0 ? (
+
+
+ {t('common.loading')}
+
+ ) : backups.length === 0 ? (
+
+
+
{t('backup.empty')}
+
+ {t('backup.createFirst')}
+
+
+ ) : (
+
+ {backups.map((backup, i) => (
+
+
+
+ {isAuto(backup.filename)
+ ?
+ :
+ }
+
+
+
+
{backup.filename}
+ {isAuto(backup.filename) && (
+
+ Auto
+
+ )}
+
+
+ {formatDate(backup.created_at)}
+ {formatSize(backup.size)}
+
+
+
+
+ backupApi.download(backup.filename).catch(() => toast.error(t('backup.toast.downloadError')))}
+ >
+
+ {t('backup.download')}
+
+ handleRestore(backup.filename)}
+ className="text-[color:var(--m-st-pending)]"
+ >
+ {restoringFile !== backup.filename && }
+ {t('backup.restore')}
+
+ handleDelete(backup.filename)}
+ aria-label={t('common.delete')}
+ className="ml-auto flex h-8 w-8 flex-none items-center justify-center rounded-full text-m-faint"
+ >
+
+
+
+
+ ))}
+
+ )}
+
+
+ {/* Auto-Backup Settings */}
+
+
+
+ {t('backup.auto.title')}
+
+ }
+ hint={t('backup.auto.subtitle')}
+ />
+
+
+ {/* Enable toggle */}
+
handleAutoSettingsChange('enabled', v)}
+ />
+ }
+ />
+
+ {autoSettings.enabled && (
+ <>
+ {/* Interval */}
+
+
+ {INTERVAL_OPTIONS.map(opt => (
+ handleAutoSettingsChange('interval', opt.value)}
+ >
+ {t(opt.labelKey)}
+
+ ))}
+
+
+
+ {/* Hour picker (for daily, weekly, monthly) */}
+ {autoSettings.interval !== 'hourly' && (
+
+ handleAutoSettingsChange('hour', parseInt(e.target.value, 10))}
+ className={SELECT_CLASS}
+ >
+ {HOURS.map(h => {
+ let label: string
+ if (is12h) {
+ const period = h >= 12 ? 'PM' : 'AM'
+ const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
+ label = `${h12}:00 ${period}`
+ } else {
+ label = `${String(h).padStart(2, '0')}:00`
+ }
+ return {label}
+ })}
+
+
+ )}
+
+ {/* Day of week (for weekly) */}
+ {autoSettings.interval === 'weekly' && (
+
+
+ {DAYS_OF_WEEK.map(opt => (
+ handleAutoSettingsChange('day_of_week', opt.value)}
+ >
+ {t(opt.labelKey)}
+
+ ))}
+
+
+ )}
+
+ {/* Day of month (for monthly) */}
+ {autoSettings.interval === 'monthly' && (
+
+ handleAutoSettingsChange('day_of_month', parseInt(e.target.value, 10))}
+ className={SELECT_CLASS}
+ >
+ {DAYS_OF_MONTH.map(d => (
+ {String(d)}
+ ))}
+
+
+ )}
+
+ {/* Keep duration */}
+
+
+ {KEEP_OPTIONS.map(opt => (
+ handleAutoSettingsChange('keep_days', opt.value)}
+ >
+ {t(opt.labelKey)}
+
+ ))}
+
+
+ >
+ )}
+
+ {/* Save button */}
+
+
+ {!autoSettingsSaving && }
+ {autoSettingsSaving ? t('common.saving') : t('common.save')}
+
+
+
+
+
+ {/* Restore Warning */}
+
setRestoreConfirm(null)}
+ title={t('backup.restoreConfirmTitle')}
+ message={
+ <>
+ {restoreConfirm?.filename}
+ {t('backup.restoreWarning')}
+ >
+ }
+ confirmLabel={t('backup.restoreConfirm')}
+ cancelLabel={t('common.cancel')}
+ danger
+ onConfirm={executeRestore}
+ >
+
+ {t('backup.restoreTip')}
+
+
+
+ {/* Delete confirm */}
+
setDeleteConfirm(null)}
+ title={t('common.delete')}
+ message={t('backup.confirm.delete', { name: deleteConfirm ?? '' })}
+ confirmLabel={t('common.delete')}
+ cancelLabel={t('common.cancel')}
+ danger
+ onConfirm={executeDelete}
+ />
+
+ )
+}
diff --git a/client/src/mobile/screens/admin/MAdminCategoryManager.tsx b/client/src/mobile/screens/admin/MAdminCategoryManager.tsx
new file mode 100644
index 000000000..8830efd40
--- /dev/null
+++ b/client/src/mobile/screens/admin/MAdminCategoryManager.tsx
@@ -0,0 +1,284 @@
+import { useState, useEffect, useRef } from 'react'
+import { Plus, Edit2, Trash2, Pipette } from 'lucide-react'
+import { categoriesApi } from '../../../api/client'
+import { useToast } from '../../../components/shared/Toast'
+import { CATEGORY_ICON_MAP, ICON_LABELS, getCategoryIcon } from '../../../components/shared/categoryIcons'
+import { useTranslation } from '../../../i18n'
+import { getApiErrorMessage } from '../../../types'
+import MConfirmSheet from '../settings/MConfirmSheet'
+import { MAdminButton, MAdminCard, MAdminCardHead } from './MAdminUi'
+
+const PRESET_COLORS = [
+ '#6366f1', '#8b5cf6', '#ec4899', '#ef4444', '#f97316',
+ '#f59e0b', '#10b981', '#06b6d4', '#3b82f6', '#84cc16',
+ '#6b7280', '#1f2937',
+]
+
+const ICON_NAMES = Object.keys(CATEGORY_ICON_MAP)
+
+export default function MAdminCategoryManager() {
+ const [categories, setCategories] = useState([])
+ const [showForm, setShowForm] = useState(false)
+ const [editingId, setEditingId] = useState(null)
+ const [form, setForm] = useState({ name: '', color: '#6366f1', icon: 'MapPin' })
+ const [isSaving, setIsSaving] = useState(false)
+ const [isLoading, setIsLoading] = useState(true)
+ const [deleteId, setDeleteId] = useState(null)
+ const [isDeleting, setIsDeleting] = useState(false)
+ const colorInputRef = useRef(null)
+ const toast = useToast()
+ const { t } = useTranslation()
+
+ useEffect(() => { loadCategories() }, [])
+
+ const loadCategories = async () => {
+ setIsLoading(true)
+ try {
+ const data = await categoriesApi.list()
+ setCategories(data.categories || [])
+ } catch (err: unknown) {
+ toast.error(t('categories.toast.loadError'))
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const handleStartEdit = (cat) => {
+ setEditingId(cat.id)
+ setForm({ name: cat.name, color: cat.color || '#6366f1', icon: cat.icon || 'MapPin' })
+ setShowForm(false)
+ }
+
+ const handleStartCreate = () => {
+ setEditingId(null)
+ setForm({ name: '', color: '#6366f1', icon: 'MapPin' })
+ setShowForm(true)
+ }
+
+ const handleCancel = () => {
+ setShowForm(false)
+ setEditingId(null)
+ }
+
+ const handleSave = async () => {
+ if (!form.name.trim()) { toast.error(t('categories.toast.nameRequired')); return }
+ setIsSaving(true)
+ try {
+ if (editingId) {
+ const result = await categoriesApi.update(editingId, form)
+ setCategories(prev => prev.map(c => c.id === editingId ? result.category : c))
+ setEditingId(null)
+ toast.success(t('categories.toast.updated'))
+ } else {
+ const result = await categoriesApi.create(form)
+ setCategories(prev => [...prev, result.category])
+ setShowForm(false)
+ toast.success(t('categories.toast.created'))
+ }
+ setForm({ name: '', color: '#6366f1', icon: 'MapPin' })
+ } catch (err: unknown) {
+ toast.error(getApiErrorMessage(err, t('categories.toast.saveError')))
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ const handleDelete = async (id) => {
+ setIsDeleting(true)
+ try {
+ await categoriesApi.delete(id)
+ setCategories(prev => prev.filter(c => c.id !== id))
+ toast.success(t('categories.toast.deleted'))
+ setDeleteId(null)
+ } catch (err: unknown) {
+ toast.error(getApiErrorMessage(err, t('categories.toast.deleteError')))
+ } finally {
+ setIsDeleting(false)
+ }
+ }
+
+ const isPresetColor = PRESET_COLORS.includes(form.color)
+ const PreviewIcon = getCategoryIcon(form.icon)
+
+ const categoryForm = (
+
+
setForm(prev => ({ ...prev, name: e.target.value }))}
+ placeholder={t('categories.namePlaceholder')}
+ className="h-[42px] w-full rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheetop)] px-3 text-[0.84375rem] text-m-ink outline-none placeholder:text-m-faint focus:border-[color:var(--m-faint)]"
+ autoFocus
+ />
+
+
+
{t('categories.icon')}
+
+
+ {ICON_NAMES.map(name => {
+ const Icon = CATEGORY_ICON_MAP[name]
+ const isSelected = form.icon === name
+ return (
+ setForm(prev => ({ ...prev, icon: name }))}
+ className={`flex h-9 w-9 items-center justify-center rounded-lg border-2 transition-colors ${
+ isSelected ? '' : 'border-transparent hover:bg-[color:var(--m-sheetop)]'
+ }`}
+ style={isSelected ? { background: `${form.color}1f`, borderColor: form.color } : undefined}
+ >
+
+
+
+
+ )
+ })}
+
+
+
+
+
+
{t('categories.color')}
+
+
+
+
+
{t('categories.preview')}:
+
+
+ {form.name || t('categories.defaultName')}
+
+
+
+
+
+ {t('common.cancel')}
+
+
+ {isSaving ? t('common.saving') : editingId ? t('categories.update') : t('categories.create')}
+
+
+
+ )
+
+ return (
+
+
+
+ {t('categories.new')}
+
+ }
+ />
+
+ {showForm && {categoryForm}
}
+
+ {isLoading ? (
+
+ ) : categories.length === 0 ? (
+
+
{t('categories.empty')}
+
+ ) : (
+
+ {categories.map(cat => {
+ const Icon = getCategoryIcon(cat.icon)
+ return (
+
+ {editingId === cat.id ? (
+
{categoryForm}
+ ) : (
+
+
+
+
+
+
+ {cat.name}
+
+ {cat.color}
+
+
+
+
+ handleStartEdit(cat)}
+ className="flex h-8 w-8 items-center justify-center rounded-lg text-m-faint hover:bg-[color:var(--m-ic)] hover:text-m-ink"
+ >
+
+
+ setDeleteId(cat.id)}
+ className="flex h-8 w-8 items-center justify-center rounded-lg text-m-faint hover:bg-[color:color-mix(in_srgb,var(--m-st-danger)_12%,transparent)] hover:text-[color:var(--m-st-danger)]"
+ >
+
+
+
+
+ )}
+
+ )
+ })}
+
+ )}
+
+ setDeleteId(null)}
+ title={t('categories.title')}
+ message={t('categories.confirm.delete')}
+ confirmLabel={t('common.delete')}
+ cancelLabel={t('common.cancel')}
+ danger
+ busy={isDeleting}
+ onConfirm={() => deleteId !== null && handleDelete(deleteId)}
+ />
+
+ )
+}
diff --git a/client/src/mobile/screens/admin/MAdminDefaultUserSettings.tsx b/client/src/mobile/screens/admin/MAdminDefaultUserSettings.tsx
new file mode 100644
index 000000000..d980b8795
--- /dev/null
+++ b/client/src/mobile/screens/admin/MAdminDefaultUserSettings.tsx
@@ -0,0 +1,416 @@
+import React, { useEffect, useMemo, useState } from 'react'
+import { ChevronDown, Settings2 } from 'lucide-react'
+import { adminApi } from '../../../api/client'
+import { useTranslation } from '../../../i18n'
+import { useToast } from '../../../components/shared/Toast'
+import { MapView } from '../../../components/Map/MapView'
+import { SYMBOLS, currenciesWith } from '../../../components/Budget/BudgetPanel.constants'
+import type { DistanceUnit, Place } from '../../../types'
+import {
+ MAPBOX_DEFAULT_STYLE,
+ defaultStyleForProvider,
+ getStylePresets,
+ isOpenFreeMapStyle,
+ normalizeStyleForProvider,
+ styleSettingKey,
+ type GlMapProvider,
+} from '../../../components/Map/glProviders'
+import MToggle from '../../components/MToggle'
+import MSegmented from '../../components/MSegmented'
+import { MAdminCard, MAdminCardHead, MAdminField, MAdminInput, MAdminRow } from './MAdminUi'
+import { MSetSelectRow } from '../settings/MSettingsUi'
+import MSetPickerSheet from '../settings/MSetPickerSheet'
+
+const MAP_PRESETS = [
+ { name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
+ { name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
+ { name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
+ { name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
+ { name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
+]
+
+type Defaults = {
+ temperature_unit?: string
+ distance_unit?: DistanceUnit
+ dark_mode?: string | boolean
+ time_format?: string
+ default_currency?: string
+ blur_booking_codes?: boolean
+ map_tile_url?: string
+ map_provider?: string
+ mapbox_access_token?: string
+ mapbox_style?: string
+ maplibre_style?: string
+ mapbox_3d_enabled?: boolean
+ mapbox_quality_mode?: boolean
+}
+
+type MapProvider = 'leaflet' | GlMapProvider
+
+function normalizeProvider(value: unknown): MapProvider {
+ return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet'
+}
+
+function styleForProvider(provider: MapProvider, style?: string | null): string {
+ if (provider === 'leaflet') return style || MAPBOX_DEFAULT_STYLE
+ if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE
+ return normalizeStyleForProvider(provider, style)
+}
+
+// Mobile-native rebuild of the desktop DefaultUserSettingsTab. Identical data
+// layer (adminApi defaults, per-change auto-save, reset-to-built-in) — only the
+// presentation is relaid on the admin mobile design system.
+export default function MAdminDefaultUserSettings(): React.ReactElement {
+ const { t } = useTranslation()
+ const toast = useToast()
+ const [defaults, setDefaults] = useState({})
+ const [loaded, setLoaded] = useState(false)
+ const [mapTileUrl, setMapTileUrl] = useState('')
+ const [mapboxToken, setMapboxToken] = useState('')
+ const [mapboxStyle, setMapboxStyle] = useState('')
+ const [currencyOpen, setCurrencyOpen] = useState(false)
+ const [presetOpen, setPresetOpen] = useState(false)
+ const [styleOpen, setStyleOpen] = useState(false)
+
+ useEffect(() => {
+ adminApi.getDefaultUserSettings().then((data: Defaults) => {
+ const provider = normalizeProvider(data.map_provider)
+ setDefaults(data)
+ setMapTileUrl(data.map_tile_url || '')
+ setMapboxToken(data.mapbox_access_token || '')
+ setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
+ setLoaded(true)
+ }).catch(() => setLoaded(true))
+ }, [])
+
+ const save = async (patch: Partial) => {
+ try {
+ const updated = await adminApi.updateDefaultUserSettings(patch as Record)
+ setDefaults(updated)
+ toast.success(t('admin.defaultSettings.saved'))
+ } catch (err: unknown) {
+ toast.error(err instanceof Error ? err.message : t('common.error'))
+ }
+ }
+
+ const reset = async (key: keyof Defaults) => {
+ try {
+ const updated = await adminApi.updateDefaultUserSettings({ [key]: null })
+ setDefaults(updated)
+ if (key === 'map_tile_url') setMapTileUrl('')
+ if (key === 'mapbox_access_token') setMapboxToken('')
+ if (key === 'mapbox_style' || key === 'maplibre_style') {
+ const provider = normalizeProvider(defaults.map_provider)
+ setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
+ }
+ toast.success(t('admin.defaultSettings.reset'))
+ } catch (err: unknown) {
+ toast.error(err instanceof Error ? err.message : t('common.error'))
+ }
+ }
+
+ const isSet = (key: keyof Defaults) => defaults[key] !== undefined
+
+ const ResetButton = ({ field }: { field: keyof Defaults }) =>
+ isSet(field) ? (
+ reset(field)}
+ className="ml-2 font-geist text-[0.625rem] font-medium text-m-faint underline"
+ >
+ {t('admin.defaultSettings.resetToBuiltIn')}
+
+ ) : null
+
+ const mapPreviewPlaces = useMemo((): Place[] => [{
+ id: 1,
+ trip_id: 1,
+ name: 'Preview center',
+ description: null,
+ notes: null,
+ lat: 48.8566,
+ lng: 2.3522,
+ address: null,
+ category_id: null,
+ price: null,
+ currency: null,
+ image_url: null,
+ google_place_id: null,
+ osm_id: null,
+ route_geometry: null,
+ place_time: null,
+ end_time: null,
+ duration_minutes: null,
+ transport_mode: null,
+ website: null,
+ phone: null,
+ created_at: Date(),
+ }], [])
+
+ if (!loaded) {
+ return (
+
+ Loading…
+
+ )
+ }
+
+ const darkMode = defaults.dark_mode
+ const mapProvider = normalizeProvider(defaults.map_provider)
+ const glStylePresets = mapProvider === 'leaflet' ? [] : getStylePresets(mapProvider)
+ const styleKey: keyof Defaults = mapProvider === 'maplibre-gl' ? 'maplibre_style' : 'mapbox_style'
+ const saveMapProvider = (nextProvider: MapProvider) => {
+ const patch: Partial = { map_provider: nextProvider }
+ if (nextProvider !== 'leaflet') {
+ // Load + save the new provider's own style slot so the other provider's style is kept.
+ const slot = nextProvider === 'maplibre-gl' ? defaults.maplibre_style : defaults.mapbox_style
+ const nextStyle = styleForProvider(nextProvider, slot)
+ setMapboxStyle(nextStyle)
+ patch[styleSettingKey(nextProvider)] = nextStyle
+ }
+ save(patch)
+ }
+
+ // No active value when the setting is unset → segmented shows no pill, matching
+ // the desktop tab (which only highlights a button once a default is chosen).
+ const colorModeValue =
+ darkMode === 'dark' || darkMode === true
+ ? 'dark'
+ : darkMode === 'light' || darkMode === false
+ ? 'light'
+ : darkMode === 'auto'
+ ? 'auto'
+ : ''
+
+ const modeOptions: { value: string; label: React.ReactNode }[] = [
+ { value: 'light', label: t('settings.light') },
+ { value: 'dark', label: t('settings.dark') },
+ { value: 'auto', label: t('settings.auto') },
+ ]
+ const tempOptions: { value: string; label: React.ReactNode }[] = [
+ { value: 'celsius', label: '°C Celsius' },
+ { value: 'fahrenheit', label: '°F Fahrenheit' },
+ ]
+ const distanceOptions: { value: string; label: React.ReactNode }[] = [
+ { value: 'metric', label: 'km Metric' },
+ { value: 'imperial', label: 'mi Imperial' },
+ ]
+ const timeOptions: { value: string; label: React.ReactNode }[] = [
+ { value: '24h', label: '24h (14:30)' },
+ { value: '12h', label: '12h (2:30 PM)' },
+ ]
+ const providerOptions: { value: string; label: React.ReactNode }[] = [
+ { value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
+ { value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
+ { value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') },
+ ]
+
+ const currencyLabel = defaults.default_currency
+ ? (SYMBOLS[defaults.default_currency] ? `${defaults.default_currency} ${SYMBOLS[defaults.default_currency]}` : defaults.default_currency)
+ : t('settings.currency')
+ const tilePresetLabel = MAP_PRESETS.find((p) => p.url === mapTileUrl)?.name || t('settings.mapTemplatePlaceholder.select')
+ const stylePresetLabel = glStylePresets.find((p) => p.url === mapboxStyle)?.name || t('admin.defaultSettings.mapboxStylePlaceholder')
+ const chevron =
+
+ return (
+
+ {/* Display + regional defaults */}
+
+
+
+
+ {/* Color Mode */}
+ {t('settings.colorMode')} >}>
+ save({ dark_mode: v })} options={modeOptions} />
+
+
+ {/* Temperature */}
+ {t('settings.temperature')} >}>
+ save({ temperature_unit: v })} options={tempOptions} />
+
+
+ {/* Distance */}
+ {t('settings.distance')} >}>
+ save({ distance_unit: v as DistanceUnit })} options={distanceOptions} />
+
+
+ {/* Time Format */}
+ {t('settings.timeFormat')} >}>
+ save({ time_format: v })} options={timeOptions} />
+
+
+ {/* Default Currency */}
+ {t('settings.currency')} >}
+ hint={t('settings.currencyHint')}
+ >
+ setCurrencyOpen(true)} />
+
+
+ {/* Blur Booking Codes */}
+ {t('settings.blurBookingCodes')} >}
+ trailing={
+ save({ blur_booking_codes: v })}
+ />
+ }
+ />
+
+
+
+ {/* Map */}
+
+
+
+ {t('settings.mapTemplate')}
+
+
+
+ {/* Map Tile URL */}
+
{t('settings.mapTemplate')} >}
+ hint={t('settings.mapDefaultHint')}
+ >
+ setPresetOpen(true)} />
+ ) => setMapTileUrl(e.target.value)}
+ onBlur={() => save({ map_tile_url: mapTileUrl })}
+ placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
+ />
+
+
+ {/* Live tile preview */}
+
+ {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
+ {React.createElement(MapView as any, {
+ places: mapPreviewPlaces,
+ dayPlaces: [],
+ route: null,
+ routeSegments: null,
+ selectedPlaceId: null,
+ onMarkerClick: null,
+ onMapClick: null,
+ onMapContextMenu: null,
+ center: [48.8566, 2.3522],
+ zoom: 10,
+ tileUrl: mapTileUrl,
+ fitKey: null,
+ dayOrderMap: [],
+ leftWidth: 0,
+ rightWidth: 0,
+ hasInspector: false,
+ })}
+
+
+ {/* Map provider */}
+
+ {t('admin.defaultSettings.mapProvider')} >}
+ hint={t('admin.defaultSettings.mapProviderHint')}
+ >
+ saveMapProvider(v as MapProvider)} options={providerOptions} />
+
+
+
+ {mapProvider !== 'leaflet' && (
+
+ {mapProvider === 'mapbox-gl' && (
+
{t('admin.defaultSettings.mapboxToken')} >}
+ hint={t('admin.defaultSettings.mapboxTokenHint')}
+ >
+ ) => setMapboxToken(e.target.value)}
+ onBlur={() => save({ mapbox_access_token: mapboxToken })}
+ placeholder="pk.eyJ…"
+ spellCheck={false}
+ autoComplete="off"
+ />
+
+ )}
+
+
{t('admin.defaultSettings.mapboxStyle')} >}>
+ setStyleOpen(true)} />
+ ) => setMapboxStyle(e.target.value)}
+ onBlur={() => {
+ const nextStyle = normalizeStyleForProvider(mapProvider, mapboxStyle)
+ setMapboxStyle(nextStyle)
+ save({ [styleKey]: nextStyle })
+ }}
+ placeholder={defaultStyleForProvider(mapProvider)}
+ />
+
+
+ {mapProvider === 'mapbox-gl' && (
+
+ {t('admin.defaultSettings.mapbox3d')} >}
+ trailing={
+ save({ mapbox_3d_enabled: v })}
+ />
+ }
+ />
+ {t('admin.defaultSettings.mapboxQuality')} >}
+ trailing={
+ save({ mapbox_quality_mode: v })}
+ />
+ }
+ />
+
+ )}
+
+ )}
+
+
+
+ {/* Pickers */}
+
setCurrencyOpen(false)}
+ title={t('settings.currency')}
+ value={defaults.default_currency || ''}
+ onSelect={(value) => { if (value) save({ default_currency: value }) }}
+ options={currenciesWith(defaults.default_currency).map((c) => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))}
+ />
+
+ setPresetOpen(false)}
+ title={t('settings.mapTemplate')}
+ value={mapTileUrl}
+ onSelect={(value) => { if (value) { setMapTileUrl(value); save({ map_tile_url: value }) } }}
+ options={MAP_PRESETS.map((p) => ({ value: p.url, label: p.name }))}
+ />
+
+ setStyleOpen(false)}
+ title={t('admin.defaultSettings.mapboxStyle')}
+ value={mapboxStyle}
+ onSelect={(value) => { if (value) { setMapboxStyle(value); save({ [styleKey]: value }) } }}
+ options={glStylePresets.map((p) => ({ value: p.url, label: p.name }))}
+ />
+
+ )
+}
diff --git a/client/src/mobile/screens/admin/MAdminDevNotificationsPanel.tsx b/client/src/mobile/screens/admin/MAdminDevNotificationsPanel.tsx
new file mode 100644
index 000000000..40319952f
--- /dev/null
+++ b/client/src/mobile/screens/admin/MAdminDevNotificationsPanel.tsx
@@ -0,0 +1,286 @@
+import React, { useState, useEffect } from 'react'
+import {
+ Bell, Zap, CheckCircle, Navigation, Calendar, Clock, Image,
+ MessageSquare, Tag, UserPlus, Download, MapPin, Loader2,
+} from 'lucide-react'
+import type { LucideIcon } from 'lucide-react'
+import { adminApi, tripsApi } from '../../../api/client'
+import { useAuthStore } from '../../../store/authStore'
+import { useToast } from '../../../components/shared/Toast'
+import { MAdminCard, MAdminCardHead } from './MAdminUi'
+
+interface Trip {
+ id: number
+ title: string
+}
+
+interface AppUser {
+ id: number
+ username: string
+ email: string
+}
+
+// Dev-only notification testing panel, re-skinned to the mobile admin system.
+// All state, fetches and fire() payloads are ported verbatim from the desktop
+// DevNotificationsPanel — only the presentation layer changes.
+export default function MAdminDevNotificationsPanel(): React.ReactElement {
+ const toast = useToast()
+ const user = useAuthStore(s => s.user)
+ const [sending, setSending] = useState(null)
+ const [trips, setTrips] = useState([])
+ const [selectedTripId, setSelectedTripId] = useState(null)
+ const [users, setUsers] = useState([])
+ const [selectedUserId, setSelectedUserId] = useState(null)
+
+ useEffect(() => {
+ tripsApi.list().then(data => {
+ const list = (data.trips || data || []) as Trip[]
+ setTrips(list)
+ if (list.length > 0) setSelectedTripId(list[0].id)
+ }).catch(() => {})
+ adminApi.users().then(data => {
+ const list = (data.users || data || []) as AppUser[]
+ setUsers(list)
+ if (list.length > 0) setSelectedUserId(list[0].id)
+ }).catch(() => {})
+ }, [])
+
+ const fire = async (label: string, payload: Record) => {
+ setSending(label)
+ try {
+ await adminApi.sendTestNotification(payload)
+ toast.success(`Sent: ${label}`)
+ } catch (err: any) {
+ toast.error(err.message || 'Failed')
+ } finally {
+ setSending(null)
+ }
+ }
+
+ const selectedTrip = trips.find(t => t.id === selectedTripId)
+ const selectedUser = users.find(u => u.id === selectedUserId)
+ const username = user?.username || 'Admin'
+ const tripTitle = selectedTrip?.title || 'Test Trip'
+
+ // ── Helpers ──────────────────────────────────────────────────────────────
+
+ const Btn = ({
+ id, label, sub, icon: Icon, color, onClick,
+ }: {
+ id: string; label: string; sub: string; icon: LucideIcon; color: string; onClick: () => void
+ }) => (
+
+
+
+
+
+ {sending === id && }
+
+ )
+
+ const TripSelector = () => (
+ setSelectedTripId(Number(e.target.value))}
+ className="mb-2 h-[42px] w-full rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 text-[0.84375rem] text-m-ink outline-none focus:border-[color:var(--m-faint)]"
+ >
+ {trips.map(trip => {trip.title} )}
+
+ )
+
+ const UserSelector = () => (
+ setSelectedUserId(Number(e.target.value))}
+ className="mb-2 h-[42px] w-full rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 text-[0.84375rem] text-m-ink outline-none focus:border-[color:var(--m-faint)]"
+ >
+ {users.map(u => {u.username} ({u.email}) )}
+
+ )
+
+ return (
+
+ {/* Dev header */}
+
+
+ DEV ONLY
+
+
+ Notification Testing
+
+
+
+ {/* ── Type Testing ─────────────────────────────────────────────────── */}
+
+
+
+ fire('simple-me', {
+ event: 'test_simple',
+ scope: 'user',
+ targetId: user?.id,
+ params: {},
+ })}
+ />
+ fire('boolean-me', {
+ event: 'test_boolean',
+ scope: 'user',
+ targetId: user?.id,
+ params: {},
+ inApp: {
+ type: 'boolean',
+ positiveCallback: { action: 'test_approve', payload: {} },
+ negativeCallback: { action: 'test_deny', payload: {} },
+ },
+ })}
+ />
+ fire('navigate-me', {
+ event: 'test_navigate',
+ scope: 'user',
+ targetId: user?.id,
+ params: {},
+ })}
+ />
+ fire('simple-admins', {
+ event: 'test_simple',
+ scope: 'admin',
+ targetId: 0,
+ params: {},
+ })}
+ />
+
+
+
+ {/* ── Trip-Scoped Events ───────────────────────────────────────────── */}
+ {trips.length > 0 && (
+
+
+
+
+
+ selectedTripId && fire('booking_change', {
+ event: 'booking_change',
+ scope: 'trip',
+ targetId: selectedTripId,
+ params: { actor: username, trip: tripTitle, booking: 'Test Hotel', type: 'hotel', tripId: String(selectedTripId) },
+ })}
+ />
+ selectedTripId && fire('trip_reminder', {
+ event: 'trip_reminder',
+ scope: 'trip',
+ targetId: selectedTripId,
+ params: { trip: tripTitle, tripId: String(selectedTripId) },
+ })}
+ />
+ selectedTripId && fire('photos_shared', {
+ event: 'photos_shared',
+ scope: 'trip',
+ targetId: selectedTripId,
+ params: { actor: username, trip: tripTitle, count: '5', tripId: String(selectedTripId) },
+ })}
+ />
+ selectedTripId && fire('collab_message', {
+ event: 'collab_message',
+ scope: 'trip',
+ targetId: selectedTripId,
+ params: { actor: username, trip: tripTitle, preview: 'This is a test message preview.', tripId: String(selectedTripId) },
+ })}
+ />
+ selectedTripId && fire('packing_tagged', {
+ event: 'packing_tagged',
+ scope: 'trip',
+ targetId: selectedTripId,
+ params: { actor: username, trip: tripTitle, category: 'Clothing', tripId: String(selectedTripId) },
+ })}
+ />
+
+
+
+ )}
+
+ {/* ── User-Scoped Events ───────────────────────────────────────────── */}
+ {users.length > 0 && (
+
+
+
+
+
+ selectedUserId && fire(`trip_invite-${selectedUserId}`, {
+ event: 'trip_invite',
+ scope: 'user',
+ targetId: selectedUserId,
+ params: { actor: username, trip: tripTitle, invitee: selectedUser?.email || '', tripId: String(selectedTripId ?? 0) },
+ })}
+ />
+ selectedUserId && fire(`vacay_invite-${selectedUserId}`, {
+ event: 'vacay_invite',
+ scope: 'user',
+ targetId: selectedUserId,
+ params: { actor: username, planId: '1' },
+ })}
+ />
+
+
+
+ )}
+
+ {/* ── Admin-Scoped Events ──────────────────────────────────────────── */}
+
+
+
+ fire('version_available', {
+ event: 'version_available',
+ scope: 'admin',
+ targetId: 0,
+ params: { version: '9.9.9-test' },
+ })}
+ />
+
+
+
+ )
+}
diff --git a/client/src/mobile/screens/admin/MAdminGitHubPanel.tsx b/client/src/mobile/screens/admin/MAdminGitHubPanel.tsx
new file mode 100644
index 000000000..1b03a6ee4
--- /dev/null
+++ b/client/src/mobile/screens/admin/MAdminGitHubPanel.tsx
@@ -0,0 +1,318 @@
+import { useState, useEffect, type ReactNode } from 'react'
+import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee, Bug, Lightbulb, BookOpen } from 'lucide-react'
+import { getLocaleForLanguage, useTranslation } from '../../../i18n'
+import apiClient from '../../../api/client'
+import { MAdminButton, MAdminCard } from './MAdminUi'
+
+const REPO = 'mauriceboe/TREK'
+const PER_PAGE = 10
+
+interface GithubRelease {
+ id: number
+ prerelease: boolean
+ tag_name: string
+ name: string | null
+ body: string | null
+ published_at: string | null
+ created_at: string
+ author: { login: string } | null
+ [key: string]: unknown
+}
+
+// Support / community link cards (design: brand-tinted icon tile + title/sub).
+// Brand hex stays as content identity; every surface/border uses --m-* tokens.
+interface LinkCard {
+ href: string
+ color: string
+ icon: ReactNode
+ title: string
+ sub: string
+}
+
+export default function MAdminGitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) {
+ const { t, language } = useTranslation()
+ const [releases, setReleases] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [expanded, setExpanded] = useState>({})
+ const [page, setPage] = useState(1)
+ const [hasMore, setHasMore] = useState(true)
+ const [loadingMore, setLoadingMore] = useState(false)
+
+ const fetchReleases = async (pageNum = 1, append = false) => {
+ try {
+ const res = await apiClient.get(`/admin/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } })
+ const data = Array.isArray(res.data) ? res.data : []
+ setReleases(prev => append ? [...prev, ...data] : data)
+ setHasMore(data.length === PER_PAGE)
+ } catch (err: unknown) {
+ setError(err instanceof Error ? err.message : 'Unknown error')
+ }
+ }
+
+ useEffect(() => {
+ setLoading(true)
+ fetchReleases(1).finally(() => setLoading(false))
+ }, [])
+
+ const handleLoadMore = async () => {
+ const next = page + 1
+ setLoadingMore(true)
+ await fetchReleases(next, true)
+ setPage(next)
+ setLoadingMore(false)
+ }
+
+ const toggleExpand = (id: number) => {
+ setExpanded(prev => ({ ...prev, [id]: !prev[id] }))
+ }
+
+ const formatDate = (dateStr: string) => {
+ const d = new Date(dateStr)
+ return d.toLocaleDateString(getLocaleForLanguage(language), { day: 'numeric', month: 'short', year: 'numeric' })
+ }
+
+ // Simple markdown-to-html for release notes (handles headers, bold, lists, links)
+ const renderBody = (body: string | null) => {
+ if (!body) return null
+ const lines = body.split('\n')
+ const elements: ReactNode[] = []
+ let listItems: string[] = []
+
+ const flushList = () => {
+ if (listItems.length > 0) {
+ elements.push(
+
+ {listItems.map((item, i) => (
+
+
+
+
+ ))}
+
+ )
+ listItems = []
+ }
+ }
+
+ const escapeHtml = (str: string) => str.replace(/&/g, '&').replace(/ /g, '>').replace(/"/g, '"')
+ const inlineFormat = (text: string) => {
+ return escapeHtml(text)
+ .replace(/\*\*(.+?)\*\*/g, '$1 ')
+ .replace(/`(.+?)`/g, '$1')
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
+ const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#'
+ return `${label} `
+ })
+ }
+
+ for (const line of lines) {
+ const trimmed = line.trim()
+ if (!trimmed) { flushList(); continue }
+
+ if (trimmed.startsWith('### ')) {
+ flushList()
+ elements.push(
+
+ {trimmed.slice(4)}
+
+ )
+ } else if (trimmed.startsWith('## ')) {
+ flushList()
+ elements.push(
+
+ {trimmed.slice(3)}
+
+ )
+ } else if (/^[-*] /.test(trimmed)) {
+ listItems.push(trimmed.slice(2))
+ } else {
+ flushList()
+ elements.push(
+
+ )
+ }
+ }
+ flushList()
+ return elements
+ }
+
+ const discordIcon = (
+
+ )
+
+ const cards: LinkCard[] = [
+ { href: 'https://ko-fi.com/mauriceboe', color: '#ff5e5b', icon: , title: 'Ko-fi', sub: t('admin.github.support') },
+ { href: 'https://buymeacoffee.com/mauriceboe', color: '#ffdd00', icon: , title: 'Buy Me a Coffee', sub: t('admin.github.support') },
+ { href: 'https://discord.gg/NhZBDSd4qW', color: '#5865F2', icon: discordIcon, title: 'Discord', sub: 'Join the community' },
+ { href: 'https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml', color: '#ef4444', icon: , title: t('settings.about.reportBug'), sub: t('settings.about.reportBugHint') },
+ { href: 'https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests', color: '#f59e0b', icon: , title: t('settings.about.featureRequest'), sub: t('settings.about.featureRequestHint') },
+ { href: 'https://github.com/mauriceboe/TREK/wiki', color: '#6366f1', icon: , title: 'Wiki', sub: t('settings.about.wikiHint') },
+ ]
+
+ const shownReleases = isPrerelease ? releases : releases.filter(r => !r.prerelease)
+
+ return (
+
+ {/* Support / community cards */}
+
+
+ {/* Loading / Error / Releases */}
+ {loading ? (
+
+
+
+
+
+ ) : error ? (
+
+
+
{t('admin.github.error')}
+
{error}
+
+
+ ) : (
+
+
+
+
{t('admin.github.title')}
+
+ {t('admin.github.subtitle').replace('{repo}', REPO)}
+
+
+
+
+ GitHub
+
+
+
+ {/* Timeline */}
+
+
+ {/* Timeline line */}
+
+
+
+ {shownReleases.map((release, idx) => {
+ const isLatest = idx === 0
+ const isExpanded = expanded[release.id]
+
+ return (
+
+ {/* Timeline dot */}
+
+
+
+
+ {/* Release content */}
+
+
+
+ {release.tag_name}
+
+ {isLatest && (
+
+ {t('admin.github.latest')}
+
+ )}
+ {release.prerelease && (
+
+ {t('admin.github.prerelease')}
+
+ )}
+
+
+ {release.name && release.name !== release.tag_name && (
+
+ {release.name}
+
+ )}
+
+
+
+
+ {formatDate(release.published_at || release.created_at)}
+
+ {release.author && (
+
+ {t('admin.github.by')} {release.author.login}
+
+ )}
+
+
+ {/* Expandable body */}
+ {release.body && (
+
+
toggleExpand(release.id)}
+ className="flex items-center gap-1 text-[0.6875rem] font-medium text-m-muted"
+ >
+ {isExpanded ? : }
+ {isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
+
+
+ {isExpanded && (
+
+ {renderBody(release.body)}
+
+ )}
+
+ )}
+
+
+ )
+ })}
+
+
+
+ {/* Load more */}
+ {hasMore && (
+
+
+ {!loadingMore && }
+ {loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
+
+
+ )}
+
+
+ )}
+
+ )
+}
diff --git a/client/src/mobile/screens/admin/MAdminMcpTokensPanel.tsx b/client/src/mobile/screens/admin/MAdminMcpTokensPanel.tsx
new file mode 100644
index 000000000..0c35dca5b
--- /dev/null
+++ b/client/src/mobile/screens/admin/MAdminMcpTokensPanel.tsx
@@ -0,0 +1,258 @@
+import { useState, useEffect } from 'react'
+import { Key, Trash2, User, Loader2, Shield } from 'lucide-react'
+import { adminApi } from '../../../api/client'
+import { useToast } from '../../../components/shared/Toast'
+import { useTranslation } from '../../../i18n'
+import { MAdminCard } from './MAdminUi'
+import MConfirmSheet from '../settings/MConfirmSheet'
+
+interface AdminOAuthSession {
+ id: number
+ client_id: string
+ client_name: string
+ user_id: number
+ username: string
+ scopes: string[]
+ access_token_expires_at: string
+ refresh_token_expires_at: string
+ created_at: string
+}
+
+interface AdminMcpToken {
+ id: number
+ name: string
+ token_prefix: string
+ created_at: string
+ last_used_at: string | null
+ user_id: number
+ username: string
+}
+
+const SCOPES_PREVIEW = 6
+
+// Mobile MCP tokens admin section: OAuth sessions + long-lived MCP tokens, each
+// as a card list with loading/empty states, expandable scope chips and a
+// delete confirm sheet. Drop-in for the desktop AdminMcpTokensPanel — no props,
+// same adminApi calls and state machine, only the presentation is mobile.
+export default function MAdminMcpTokensPanel() {
+ const [sessions, setSessions] = useState([])
+ const [sessionsLoading, setSessionsLoading] = useState(true)
+ const [tokens, setTokens] = useState([])
+ const [tokensLoading, setTokensLoading] = useState(true)
+ const [expandedScopes, setExpandedScopes] = useState>(new Set())
+ const [revokeConfirmId, setRevokeConfirmId] = useState(null)
+ const [deleteConfirmId, setDeleteConfirmId] = useState(null)
+
+ const toggleScopes = (id: number) =>
+ setExpandedScopes(prev => {
+ const next = new Set(prev)
+ next.has(id) ? next.delete(id) : next.add(id)
+ return next
+ })
+ const toast = useToast()
+ const { t, locale } = useTranslation()
+
+ useEffect(() => {
+ adminApi.oauthSessions()
+ .then(d => setSessions(d.sessions || []))
+ .catch(() => toast.error(t('admin.oauthSessions.loadError')))
+ .finally(() => setSessionsLoading(false))
+
+ adminApi.mcpTokens()
+ .then(d => setTokens(d.tokens || []))
+ .catch(() => toast.error(t('admin.mcpTokens.loadError')))
+ .finally(() => setTokensLoading(false))
+ }, [])
+
+ const handleRevoke = async (id: number) => {
+ try {
+ await adminApi.revokeOAuthSession(id)
+ setSessions(prev => prev.filter(s => s.id !== id))
+ setRevokeConfirmId(null)
+ toast.success(t('admin.oauthSessions.revokeSuccess'))
+ } catch {
+ toast.error(t('admin.oauthSessions.revokeError'))
+ }
+ }
+
+ const handleDelete = async (id: number) => {
+ try {
+ await adminApi.deleteMcpToken(id)
+ setTokens(prev => prev.filter(tk => tk.id !== id))
+ setDeleteConfirmId(null)
+ toast.success(t('admin.mcpTokens.deleteSuccess'))
+ } catch {
+ toast.error(t('admin.mcpTokens.deleteError'))
+ }
+ }
+
+ return (
+
+ {/* Section title */}
+
+
{t('admin.mcpTokens.title')}
+
+ {t('admin.mcpTokens.subtitle')}
+
+
+
+ {/* OAuth Sessions */}
+
+
+ {t('admin.oauthSessions.sectionTitle')}
+
+
+ {sessionsLoading ? (
+
+
+
+ ) : sessions.length === 0 ? (
+
+
+
{t('admin.oauthSessions.empty')}
+
+ ) : (
+ sessions.map((session, i) => {
+ const expanded = expandedScopes.has(session.id)
+ const visible = expanded ? session.scopes : session.scopes.slice(0, SCOPES_PREVIEW)
+ const hidden = session.scopes.length - SCOPES_PREVIEW
+ return (
+
+
+
{session.client_name}
+
+
+
+ {session.username}
+
+ ·
+ {new Date(session.created_at).toLocaleDateString(locale)}
+
+
+ {visible.map(scope => (
+
+ {scope}
+
+ ))}
+ {!expanded && hidden > 0 && (
+ toggleScopes(session.id)}
+ className="inline-flex items-center rounded-md border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-1.5 py-0.5 font-geist text-[0.5625rem] font-bold text-m-ink"
+ >
+ +{hidden} more
+
+ )}
+ {expanded && hidden > 0 && (
+ toggleScopes(session.id)}
+ className="inline-flex items-center rounded-md border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-1.5 py-0.5 font-geist text-[0.5625rem] font-bold text-m-ink"
+ >
+ show less
+
+ )}
+
+
+
setRevokeConfirmId(session.id)}
+ aria-label={t('common.delete')}
+ title={t('common.delete')}
+ className="flex h-8 w-8 flex-none items-center justify-center rounded-full bg-[color:color-mix(in_srgb,var(--m-st-danger)_12%,transparent)] text-[color:var(--m-st-danger)]"
+ >
+
+
+
+ )
+ })
+ )}
+
+
+
+ {/* MCP Tokens */}
+
+
+ {t('admin.mcpTokens.sectionTitle')}
+
+
+ {tokensLoading ? (
+
+
+
+ ) : tokens.length === 0 ? (
+
+
+
{t('admin.mcpTokens.empty')}
+
+ ) : (
+ tokens.map((token, i) => (
+
+
+
{token.name}
+
{token.token_prefix}...
+
+
+
+ {token.username}
+
+ ·
+ {new Date(token.created_at).toLocaleDateString(locale)}
+ ·
+
+ {token.last_used_at
+ ? new Date(token.last_used_at).toLocaleDateString(locale)
+ : t('admin.mcpTokens.never')}
+
+
+
+
setDeleteConfirmId(token.id)}
+ aria-label={t('common.delete')}
+ title={t('common.delete')}
+ className="flex h-8 w-8 flex-none items-center justify-center rounded-full bg-[color:color-mix(in_srgb,var(--m-st-danger)_12%,transparent)] text-[color:var(--m-st-danger)]"
+ >
+
+
+
+ ))
+ )}
+
+
+
+ {/* Revoke OAuth session confirm */}
+
setRevokeConfirmId(null)}
+ title={t('admin.oauthSessions.revokeTitle')}
+ message={t('admin.oauthSessions.revokeMessage')}
+ confirmLabel={t('common.delete')}
+ cancelLabel={t('common.cancel')}
+ danger
+ onConfirm={() => revokeConfirmId !== null && handleRevoke(revokeConfirmId)}
+ />
+
+ {/* Delete MCP token confirm */}
+ setDeleteConfirmId(null)}
+ title={t('admin.mcpTokens.deleteTitle')}
+ message={t('admin.mcpTokens.deleteMessage')}
+ confirmLabel={t('common.delete')}
+ cancelLabel={t('common.cancel')}
+ danger
+ onConfirm={() => deleteConfirmId !== null && handleDelete(deleteConfirmId)}
+ />
+
+ )
+}
diff --git a/client/src/mobile/screens/admin/MAdminNotificationsSection.tsx b/client/src/mobile/screens/admin/MAdminNotificationsSection.tsx
new file mode 100644
index 000000000..6abc10eba
--- /dev/null
+++ b/client/src/mobile/screens/admin/MAdminNotificationsSection.tsx
@@ -0,0 +1,355 @@
+import { authApi, notificationsApi } from '../../../api/client'
+import type { TranslationFn } from '../../../types'
+import type { useAdmin } from '../../../pages/admin/useAdmin'
+import MToggle from '../../components/MToggle'
+import MAdminNotifyMatrix from './MAdminNotifyMatrix'
+import { MAdminButton, MAdminCard, MAdminCardHead, MAdminField, MAdminInput, MAdminRow } from './MAdminUi'
+
+interface MAdminNotificationsSectionProps {
+ admin: ReturnType
+ t: TranslationFn
+}
+
+const SMTP_FIELDS = [
+ { key: 'smtp_host', label: 'SMTP Host', placeholder: 'mail.example.com' },
+ { key: 'smtp_port', label: 'SMTP Port', placeholder: '587' },
+ { key: 'smtp_user', label: 'SMTP User', placeholder: 'trek@example.com' },
+ { key: 'smtp_pass', label: 'SMTP Password', placeholder: '••••••••', type: 'password' },
+ { key: 'smtp_from', label: 'From Address', placeholder: 'trek@example.com' },
+]
+
+// Notifications section: channel toggles (email/webhook/ntfy/in-app), SMTP
+// credentials, trip reminders, admin webhook + ntfy targets and the per-event
+// preference matrix — the desktop notifications tab in mobile cards.
+export default function MAdminNotificationsSection({ admin, t }: MAdminNotificationsSectionProps) {
+ const { toast, smtpValues, setSmtpValues, smtpLoaded, setTripRemindersEnabled } = admin
+
+ // Derive active channels from notification_channels (plural) with fallback
+ // to notification_channel (singular) for existing installs.
+ const rawChannels = smtpValues.notification_channels ?? smtpValues.notification_channel ?? 'none'
+ const activeChans = rawChannels === 'none' ? [] : rawChannels.split(',').map((c: string) => c.trim())
+ const emailActive = activeChans.includes('email')
+ const webhookActive = activeChans.includes('webhook')
+ const ntfyActive = activeChans.includes('ntfy')
+ const tripRemindersActive = smtpValues.notify_trip_reminder !== 'false'
+ const smtpConfigured = !!smtpValues.smtp_host?.trim()
+
+ const setChannels = async (email: boolean, webhook: boolean, ntfy: boolean) => {
+ // Preserve channel ids this toggle doesn't know about instead of
+ // rebuilding the CSV from just these three booleans.
+ const others = activeChans.filter((c: string) => c !== 'email' && c !== 'webhook' && c !== 'ntfy')
+ const chans = [email && 'email', webhook && 'webhook', ntfy && 'ntfy', ...others].filter(Boolean).join(',') || 'none'
+ setSmtpValues((prev) => ({ ...prev, notification_channels: chans }))
+ try {
+ await authApi.updateAppSettings({ notification_channels: chans })
+ } catch {
+ const reverted =
+ [emailActive && 'email', webhookActive && 'webhook', ntfyActive && 'ntfy', ...others].filter(Boolean).join(',') ||
+ 'none'
+ setSmtpValues((prev) => ({ ...prev, notification_channels: reverted }))
+ toast.error(t('common.error'))
+ }
+ }
+
+ const saveSmtp = async () => {
+ // Saves credentials only — channel activation is auto-saved by the toggle.
+ const notifKeys = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify']
+ const payload: Record = {}
+ for (const k of notifKeys) {
+ if (smtpValues[k] !== undefined) payload[k] = smtpValues[k]
+ }
+ try {
+ await authApi.updateAppSettings(payload)
+ toast.success(t('admin.notifications.saved'))
+ authApi
+ .getAppConfig()
+ .then((c: { trip_reminders_enabled?: boolean }) => {
+ if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
+ })
+ .catch(() => {})
+ } catch {
+ toast.error(t('common.error'))
+ }
+ }
+
+ const testSmtp = async () => {
+ const smtpKeys = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify']
+ const payload: Record = {}
+ for (const k of smtpKeys) {
+ if (smtpValues[k] !== undefined) payload[k] = smtpValues[k]
+ }
+ await authApi.updateAppSettings(payload).catch(() => {})
+ try {
+ const result = await notificationsApi.testSmtp()
+ if (result.success) toast.success(t('admin.smtp.testSuccess'))
+ else toast.error(result.error || t('admin.smtp.testFailed'))
+ } catch {
+ toast.error(t('admin.smtp.testFailed'))
+ }
+ }
+
+ const toggleTripReminders = async () => {
+ const next = !tripRemindersActive
+ setSmtpValues((prev) => ({ ...prev, notify_trip_reminder: next ? 'true' : 'false' }))
+ try {
+ await authApi.updateAppSettings({ notify_trip_reminder: next ? 'true' : 'false' })
+ toast.success(
+ next ? t('admin.notifications.tripReminders.enabled') : t('admin.notifications.tripReminders.disabled'),
+ )
+ authApi
+ .getAppConfig()
+ .then((c: { trip_reminders_enabled?: boolean }) => {
+ if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
+ })
+ .catch(() => {})
+ } catch {
+ setSmtpValues((prev) => ({ ...prev, notify_trip_reminder: tripRemindersActive ? 'true' : 'false' }))
+ toast.error(t('common.error'))
+ }
+ }
+
+ const saveAdminWebhook = async () => {
+ try {
+ await authApi.updateAppSettings({ admin_webhook_url: smtpValues.admin_webhook_url || '' })
+ toast.success(t('admin.notifications.adminWebhookPanel.saved'))
+ } catch {
+ toast.error(t('common.error'))
+ }
+ }
+
+ const testAdminWebhook = async () => {
+ const url = smtpValues.admin_webhook_url === '••••••••' ? undefined : smtpValues.admin_webhook_url
+ if (!url && smtpValues.admin_webhook_url !== '••••••••') return
+ try {
+ if (url) await authApi.updateAppSettings({ admin_webhook_url: url }).catch(() => {})
+ const result = await notificationsApi.testWebhook(url)
+ if (result.success) toast.success(t('admin.notifications.adminWebhookPanel.testSuccess'))
+ else toast.error(result.error || t('admin.notifications.adminWebhookPanel.testFailed'))
+ } catch {
+ toast.error(t('admin.notifications.adminWebhookPanel.testFailed'))
+ }
+ }
+
+ const saveAdminNtfy = async () => {
+ try {
+ await authApi.updateAppSettings({
+ admin_ntfy_server: smtpValues.admin_ntfy_server || '',
+ admin_ntfy_topic: smtpValues.admin_ntfy_topic || '',
+ ...(smtpValues.admin_ntfy_token && smtpValues.admin_ntfy_token !== '••••••••'
+ ? { admin_ntfy_token: smtpValues.admin_ntfy_token }
+ : {}),
+ })
+ toast.success(t('admin.notifications.adminNtfyPanel.saved'))
+ } catch {
+ toast.error(t('common.error'))
+ }
+ }
+
+ const testAdminNtfy = async () => {
+ const topic = smtpValues.admin_ntfy_topic?.trim()
+ if (!topic) return
+ try {
+ const token =
+ smtpValues.admin_ntfy_token && smtpValues.admin_ntfy_token !== '••••••••' ? smtpValues.admin_ntfy_token : null
+ const result = await notificationsApi.testNtfy({ topic, server: smtpValues.admin_ntfy_server || null, token })
+ if (result.success) toast.success(t('admin.notifications.adminNtfyPanel.testSuccess'))
+ else toast.error(result.error || t('admin.notifications.adminNtfyPanel.testFailed'))
+ } catch {
+ toast.error(t('admin.notifications.adminNtfyPanel.testFailed'))
+ }
+ }
+
+ const clearNtfyToken = async () => {
+ try {
+ await authApi.updateAppSettings({ admin_ntfy_token: '' })
+ setSmtpValues((prev) => ({ ...prev, admin_ntfy_token: '' }))
+ toast.success(t('admin.notifications.adminNtfyPanel.tokenCleared'))
+ } catch {
+ toast.error(t('common.error'))
+ }
+ }
+
+ return (
+
+ {/* Email (SMTP) */}
+
+ setChannels(!emailActive, webhookActive, ntfyActive)}
+ />
+ }
+ />
+
+ {smtpLoaded &&
+ SMTP_FIELDS.map((field) => (
+
+ setSmtpValues((prev) => ({ ...prev, [field.key]: e.target.value }))}
+ placeholder={field.placeholder}
+ />
+
+ ))}
+ setSmtpValues((prev) => ({ ...prev, smtp_skip_tls_verify: v ? 'true' : 'false' }))}
+ />
+ }
+ />
+
+
+ {t('common.save')}
+
+ {t('admin.smtp.testButton')}
+
+
+
+
+ {/* Webhook */}
+
+ setChannels(emailActive, !webhookActive, ntfyActive)}
+ />
+ }
+ />
+
+
+ {/* Ntfy */}
+
+ setChannels(emailActive, webhookActive, !ntfyActive)}
+ />
+ }
+ />
+
+
+ {/* In-App (always on) */}
+
+ {}} />}
+ />
+
+
+ {/* Trip reminders */}
+
+
+ }
+ />
+
+
+ {/* Admin webhook */}
+
+
+ {smtpLoaded && (
+
+ setSmtpValues((prev) => ({ ...prev, admin_webhook_url: e.target.value }))}
+ placeholder={smtpValues.admin_webhook_url === '••••••••' ? '••••••••' : 'https://discord.com/api/webhooks/...'}
+ />
+
+ )}
+
+ {t('common.save')}
+
+ {t('admin.notifications.testWebhook')}
+
+
+
+
+ {/* Admin ntfy */}
+
+
+ {smtpLoaded && (
+
+
+ setSmtpValues((prev) => ({ ...prev, admin_ntfy_server: e.target.value }))}
+ placeholder={t('admin.notifications.adminNtfyPanel.serverPlaceholder')}
+ />
+
+
+ setSmtpValues((prev) => ({ ...prev, admin_ntfy_topic: e.target.value }))}
+ placeholder={t('admin.notifications.adminNtfyPanel.topicPlaceholder')}
+ />
+
+
+
+
+ setSmtpValues((prev) => ({ ...prev, admin_ntfy_token: e.target.value }))}
+ placeholder={smtpValues.admin_ntfy_token === '••••••••' ? '••••••••' : ''}
+ />
+
+ {smtpValues.admin_ntfy_token === '••••••••' && (
+
+ {t('common.clear')}
+
+ )}
+
+
+
+ )}
+
+ {t('common.save')}
+
+ {t('admin.notifications.adminNtfyPanel.test')}
+
+
+
+
+
+
+ )
+}
diff --git a/client/src/mobile/screens/admin/MAdminNotifyMatrix.tsx b/client/src/mobile/screens/admin/MAdminNotifyMatrix.tsx
new file mode 100644
index 000000000..88cd93df5
--- /dev/null
+++ b/client/src/mobile/screens/admin/MAdminNotifyMatrix.tsx
@@ -0,0 +1,109 @@
+import { useEffect, useState } from 'react'
+import { adminApi } from '../../../api/client'
+import type { TranslationFn } from '../../../types'
+import type { useToast } from '../../../components/shared/Toast'
+import { ADMIN_EVENT_LABEL_KEYS, ADMIN_CHANNEL_LABEL_KEYS } from '../../../pages/admin/AdminPage.constants'
+import MToggle from '../../components/MToggle'
+import { MAdminCard, MAdminCardHead } from './MAdminUi'
+
+interface ChannelInfo {
+ id: string
+ active: boolean
+}
+
+interface MatrixData {
+ event_types: string[]
+ channels?: ChannelInfo[]
+ implemented_combos: Record
+ preferences: Record>
+}
+
+const BUILTIN_CHANNELS = ['inapp', 'email', 'webhook', 'ntfy'] as const
+
+// Per-event × per-channel admin notification matrix — the mobile layout of
+// AdminNotificationsPanel. Loads its own data and auto-saves each toggle.
+export default function MAdminNotifyMatrix({ t, toast }: { t: TranslationFn; toast: ReturnType }) {
+ const [matrix, setMatrix] = useState(null)
+
+ useEffect(() => {
+ adminApi.getNotificationPreferences().then((data: MatrixData) => setMatrix(data)).catch(() => {})
+ }, [])
+
+ if (!matrix) {
+ return (
+
+ {t('common.loading')}
+
+ )
+ }
+
+ if (matrix.event_types.length === 0) {
+ return (
+
+ {t('settings.notificationPreferences.noChannels')}
+
+ )
+ }
+
+ // Admin-scoped events only go out over the built-in channels (plugin
+ // channels are user-scoped), same rule as the desktop panel.
+ const isActive = (id: string) => matrix.channels?.some((c) => c.id === id && c.active) ?? false
+ const visibleChannels = BUILTIN_CHANNELS.filter(
+ (ch) => isActive(ch) && matrix.event_types.some((evt) => matrix.implemented_combos[evt]?.includes(ch)),
+ )
+
+ const toggle = async (eventType: string, channel: string) => {
+ const current = matrix.preferences[eventType]?.[channel] ?? true
+ const updated = {
+ ...matrix.preferences,
+ [eventType]: { ...matrix.preferences[eventType], [channel]: !current },
+ }
+ setMatrix((m) => (m ? { ...m, preferences: updated } : m))
+ try {
+ await adminApi.updateNotificationPreferences(updated)
+ } catch {
+ setMatrix((m) => (m ? { ...m, preferences: matrix.preferences } : m))
+ toast.error(t('common.error'))
+ }
+ }
+
+ return (
+
+
+
+
+ {visibleChannels.map((ch) => (
+
+ {t(ADMIN_CHANNEL_LABEL_KEYS[ch]) || ch}
+
+ ))}
+
+ {matrix.event_types.map((eventType) => {
+ const implemented = matrix.implemented_combos[eventType] ?? []
+ return (
+
+
+ {t(ADMIN_EVENT_LABEL_KEYS[eventType]) || eventType}
+
+ {visibleChannels.map((ch) => (
+
+ {implemented.includes(ch) ? (
+ toggle(eventType, ch)}
+ />
+ ) : (
+ —
+ )}
+
+ ))}
+
+ )
+ })}
+
+ )
+}
diff --git a/client/src/mobile/screens/admin/MAdminPackingTemplateManager.tsx b/client/src/mobile/screens/admin/MAdminPackingTemplateManager.tsx
new file mode 100644
index 000000000..e33c59374
--- /dev/null
+++ b/client/src/mobile/screens/admin/MAdminPackingTemplateManager.tsx
@@ -0,0 +1,410 @@
+import { useState, useEffect, useRef, ReactNode } from 'react'
+import { adminApi } from '../../../api/client'
+import { useToast } from '../../../components/shared/Toast'
+import { useTranslation } from '../../../i18n'
+import { Plus, Trash2, Edit2, Package, X, Check, ChevronDown, ChevronRight, FolderPlus } from 'lucide-react'
+import { MAdminButton, MAdminCard, MAdminCardHead, MAdminInput } from './MAdminUi'
+
+interface TemplateCategory { id: number; template_id: number; name: string; sort_order: number }
+interface TemplateItem { id: number; category_id: number; name: string; sort_order: number }
+interface Template { id: number; name: string; item_count: number; category_count: number; created_by_name: string }
+
+// Small round icon action button in the mobile admin idiom (flat --m-ic circle).
+function PkIconBtn({
+ onClick,
+ ariaLabel,
+ variant = 'neutral',
+ size = 32,
+ disabled = false,
+ children,
+}: {
+ onClick: () => void
+ ariaLabel: string
+ variant?: 'neutral' | 'danger' | 'accent'
+ size?: number
+ disabled?: boolean
+ children: ReactNode
+}) {
+ const look =
+ variant === 'accent'
+ ? 'bg-m-act text-m-actfg'
+ : variant === 'danger'
+ ? 'bg-[color:var(--m-ic)] text-[color:var(--m-st-danger)]'
+ : 'bg-[color:var(--m-ic)] text-m-muted'
+ return (
+
+ {children}
+
+ )
+}
+
+// Compact inline field matching MAdminInput, used where a native ref is needed
+// (the add-item input focuses itself after each add).
+const inlineFieldCls =
+ 'h-[38px] w-full rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 text-[0.84375rem] text-m-ink outline-none placeholder:text-m-faint focus:border-[color:var(--m-faint)]'
+
+/**
+ * Mobile-native re-skin of the admin Packing Template Manager: create/rename/
+ * delete templates, expand a template to manage its categories and items. All
+ * state, effects and adminApi mutations are preserved from the desktop version.
+ */
+export default function MAdminPackingTemplateManager() {
+ const [templates, setTemplates] = useState([])
+ const [isLoading, setIsLoading] = useState(true)
+ const [showCreate, setShowCreate] = useState(false)
+ const [createName, setCreateName] = useState('')
+
+ // Expanded template state
+ const [expandedId, setExpandedId] = useState(null)
+ const [categories, setCategories] = useState([])
+ const [items, setItems] = useState([])
+
+ // Editing states
+ const [editingTemplate, setEditingTemplate] = useState(null)
+ const [editTemplateName, setEditTemplateName] = useState('')
+ const [editingCatId, setEditingCatId] = useState(null)
+ const [editCatName, setEditCatName] = useState('')
+ const [editingItemId, setEditingItemId] = useState(null)
+ const [editItemName, setEditItemName] = useState('')
+
+ // Adding states
+ const [addingCategory, setAddingCategory] = useState(false)
+ const [newCatName, setNewCatName] = useState('')
+ const [addingItemToCatId, setAddingItemToCatId] = useState(null)
+ const [newItemName, setNewItemName] = useState('')
+ const addItemRef = useRef(null)
+
+ const toast = useToast()
+ const { t } = useTranslation()
+
+ useEffect(() => { loadTemplates() }, [])
+
+ const loadTemplates = async () => {
+ setIsLoading(true)
+ try {
+ const data = await adminApi.packingTemplates()
+ setTemplates(data.templates || [])
+ } catch { toast.error(t('admin.packingTemplates.loadError')) }
+ finally { setIsLoading(false) }
+ }
+
+ const toggleExpand = async (id: number) => {
+ if (expandedId === id) { setExpandedId(null); return }
+ setExpandedId(id)
+ setAddingCategory(false)
+ setAddingItemToCatId(null)
+ try {
+ const data = await adminApi.getPackingTemplate(id)
+ setCategories(data.categories || [])
+ setItems(data.items || [])
+ } catch { toast.error(t('admin.packingTemplates.loadError')) }
+ }
+
+ // Template CRUD
+ const handleCreateTemplate = async () => {
+ if (!createName.trim()) return
+ try {
+ const data = await adminApi.createPackingTemplate({ name: createName.trim() })
+ setTemplates(prev => [{ ...data.template, item_count: 0, category_count: 0 }, ...prev])
+ setCreateName(''); setShowCreate(false)
+ setExpandedId(data.template.id); setCategories([]); setItems([])
+ toast.success(t('admin.packingTemplates.created'))
+ } catch { toast.error(t('admin.packingTemplates.createError')) }
+ }
+
+ const handleDeleteTemplate = async (id: number) => {
+ try {
+ await adminApi.deletePackingTemplate(id)
+ setTemplates(prev => prev.filter(t => t.id !== id))
+ if (expandedId === id) setExpandedId(null)
+ toast.success(t('admin.packingTemplates.deleted'))
+ } catch { toast.error(t('admin.packingTemplates.deleteError')) }
+ }
+
+ const handleRenameTemplate = async (id: number) => {
+ if (!editTemplateName.trim()) { setEditingTemplate(null); return }
+ try {
+ await adminApi.updatePackingTemplate(id, { name: editTemplateName.trim() })
+ setTemplates(prev => prev.map(t => t.id === id ? { ...t, name: editTemplateName.trim() } : t))
+ setEditingTemplate(null)
+ } catch { toast.error(t('admin.packingTemplates.saveError')) }
+ }
+
+ // Category CRUD
+ const handleAddCategory = async () => {
+ if (!newCatName.trim() || !expandedId) return
+ try {
+ const data = await adminApi.addTemplateCategory(expandedId, { name: newCatName.trim() })
+ setCategories(prev => [...prev, data.category])
+ setNewCatName(''); setAddingCategory(false)
+ } catch { toast.error(t('admin.packingTemplates.saveError')) }
+ }
+
+ const handleRenameCategory = async (catId: number) => {
+ if (!editCatName.trim() || !expandedId) { setEditingCatId(null); return }
+ try {
+ await adminApi.updateTemplateCategory(expandedId, catId, { name: editCatName.trim() })
+ setCategories(prev => prev.map(c => c.id === catId ? { ...c, name: editCatName.trim() } : c))
+ setEditingCatId(null)
+ } catch { toast.error(t('admin.packingTemplates.saveError')) }
+ }
+
+ const handleDeleteCategory = async (catId: number) => {
+ if (!expandedId) return
+ try {
+ await adminApi.deleteTemplateCategory(expandedId, catId)
+ setCategories(prev => prev.filter(c => c.id !== catId))
+ setItems(prev => prev.filter(i => i.category_id !== catId))
+ } catch { toast.error(t('admin.packingTemplates.deleteError')) }
+ }
+
+ // Item CRUD
+ const handleAddItem = async (catId: number) => {
+ if (!newItemName.trim() || !expandedId) return
+ try {
+ const data = await adminApi.addTemplateItem(expandedId, catId, { name: newItemName.trim() })
+ setItems(prev => [...prev, data.item])
+ setNewItemName('')
+ setTimeout(() => addItemRef.current?.focus(), 30)
+ } catch { toast.error(t('admin.packingTemplates.saveError')) }
+ }
+
+ const handleRenameItem = async (itemId: number) => {
+ if (!editItemName.trim() || !expandedId) { setEditingItemId(null); return }
+ try {
+ await adminApi.updateTemplateItem(expandedId, itemId, { name: editItemName.trim() })
+ setItems(prev => prev.map(i => i.id === itemId ? { ...i, name: editItemName.trim() } : i))
+ setEditingItemId(null)
+ } catch { toast.error(t('admin.packingTemplates.saveError')) }
+ }
+
+ const handleDeleteItem = async (itemId: number) => {
+ if (!expandedId) return
+ try {
+ await adminApi.deleteTemplateItem(expandedId, itemId)
+ setItems(prev => prev.filter(i => i.id !== itemId))
+ } catch { toast.error(t('admin.packingTemplates.deleteError')) }
+ }
+
+ return (
+
+ {/* Header */}
+ setShowCreate(true)}>
+
+ {t('admin.packingTemplates.create')}
+
+ }
+ />
+
+ {/* Create template */}
+ {showCreate && (
+
+
+
+ setCreateName(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter') handleCreateTemplate(); if (e.key === 'Escape') setShowCreate(false) }}
+ placeholder={t('admin.packingTemplates.namePlaceholder')}
+ />
+
+
+
setShowCreate(false)}>
+
+ )}
+
+ {/* Template list */}
+ {isLoading ? (
+
+
+
+ ) : templates.length === 0 ? (
+ {t('admin.packingTemplates.empty')}
+ ) : (
+
+ {templates.map(tmpl => (
+
+ {/* Template row */}
+
+
toggleExpand(tmpl.id)}
+ className="flex-none text-m-faint"
+ >
+ {expandedId === tmpl.id ? : }
+
+
+ {editingTemplate === tmpl.id ? (
+
+ setEditTemplateName(e.target.value)}
+ onBlur={() => handleRenameTemplate(tmpl.id)}
+ onKeyDown={e => { if (e.key === 'Enter') handleRenameTemplate(tmpl.id); if (e.key === 'Escape') setEditingTemplate(null) }}
+ />
+
+ ) : (
+
toggleExpand(tmpl.id)}>
+
{tmpl.name}
+
+ {tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count} {t('admin.packingTemplates.items')}
+
+
+ )}
+
{ setEditingTemplate(tmpl.id); setEditTemplateName(tmpl.name) }}
+ >
+
handleDeleteTemplate(tmpl.id)}
+ >
+
+
+ {/* Expanded content */}
+ {expandedId === tmpl.id && (
+
+ {categories.map(cat => {
+ const catItems = items.filter(i => i.category_id === cat.id)
+ return (
+
+ {/* Category header */}
+
+ {editingCatId === cat.id ? (
+
+ setEditCatName(e.target.value)}
+ onBlur={() => handleRenameCategory(cat.id)}
+ onKeyDown={e => { if (e.key === 'Enter') handleRenameCategory(cat.id); if (e.key === 'Escape') setEditingCatId(null) }}
+ />
+
+ ) : (
+
{cat.name}
+ )}
+
{catItems.length}
+
{ setAddingItemToCatId(addingItemToCatId === cat.id ? null : cat.id); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) }}
+ >
+
{ setEditingCatId(cat.id); setEditCatName(cat.name) }}
+ >
+
handleDeleteCategory(cat.id)}
+ >
+
+
+ {/* Items */}
+ {(catItems.length > 0 || addingItemToCatId === cat.id) && (
+
+ {catItems.map(item => (
+
+ {editingItemId === item.id ? (
+ <>
+
+ setEditItemName(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter') handleRenameItem(item.id); if (e.key === 'Escape') setEditingItemId(null) }}
+ />
+
+
handleRenameItem(item.id)}>
+
setEditingItemId(null)}>
+ >
+ ) : (
+ <>
+
{item.name}
+
{ setEditingItemId(item.id); setEditItemName(item.name) }}
+ >
+
handleDeleteItem(item.id)}
+ >
+ >
+ )}
+
+ ))}
+
+ {/* Add item inline */}
+ {addingItemToCatId === cat.id && (
+
+
setNewItemName(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter' && newItemName.trim()) handleAddItem(cat.id); if (e.key === 'Escape') { setAddingItemToCatId(null); setNewItemName('') } }}
+ placeholder={t('admin.packingTemplates.itemName')}
+ className={inlineFieldCls}
+ />
+
handleAddItem(cat.id)}>
+
{ setAddingItemToCatId(null); setNewItemName('') }}>
+
+ )}
+
+ )}
+
+ )
+ })}
+
+ {/* Add category */}
+ {addingCategory ? (
+
+
+ setNewCatName(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter') handleAddCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }}
+ placeholder={t('admin.packingTemplates.categoryName')}
+ />
+
+
+
{ setAddingCategory(false); setNewCatName('') }}>
+
+ ) : (
+
setAddingCategory(true)}
+ className="flex w-full items-center justify-center gap-2 rounded-xl border border-dashed border-[color:var(--m-rowbr)] px-3 py-[10px] font-geist text-[0.75rem] font-semibold text-m-muted"
+ >
+ {t('admin.packingTemplates.addCategory')}
+
+ )}
+
+ )}
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/client/src/mobile/screens/admin/MAdminPermissionsPanel.tsx b/client/src/mobile/screens/admin/MAdminPermissionsPanel.tsx
new file mode 100644
index 000000000..fff021a89
--- /dev/null
+++ b/client/src/mobile/screens/admin/MAdminPermissionsPanel.tsx
@@ -0,0 +1,169 @@
+import React, { useEffect, useState, useMemo } from 'react'
+import { adminApi } from '../../../api/client'
+import { useTranslation } from '../../../i18n'
+import { usePermissionsStore, PermissionLevel } from '../../../store/permissionsStore'
+import { useToast } from '../../../components/shared/Toast'
+import { Save, RotateCcw } from 'lucide-react'
+import { MAdminButton, MAdminCard } from './MAdminUi'
+
+interface PermissionEntry {
+ key: string
+ level: PermissionLevel
+ defaultLevel: PermissionLevel
+ allowedLevels: PermissionLevel[]
+}
+
+const LEVEL_LABELS: Record = {
+ admin: 'perm.level.admin',
+ trip_owner: 'perm.level.tripOwner',
+ trip_member: 'perm.level.tripMember',
+ everybody: 'perm.level.everybody',
+}
+
+const CATEGORIES = [
+ { id: 'trip', keys: ['trip_create', 'trip_edit', 'trip_delete', 'trip_archive', 'trip_cover_upload'] },
+ { id: 'members', keys: ['member_manage'] },
+ { id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] },
+ { id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] },
+ { id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage'] },
+]
+
+export default function MAdminPermissionsPanel(): React.ReactElement {
+ const { t } = useTranslation()
+ const toast = useToast()
+ const [entries, setEntries] = useState([])
+ const [values, setValues] = useState>({})
+ const [loading, setLoading] = useState(true)
+ const [saving, setSaving] = useState(false)
+ const [dirty, setDirty] = useState(false)
+
+ useEffect(() => {
+ loadPermissions()
+ }, [])
+
+ const loadPermissions = async () => {
+ setLoading(true)
+ try {
+ const data = await adminApi.getPermissions()
+ setEntries(data.permissions)
+ const vals: Record = {}
+ for (const p of data.permissions) vals[p.key] = p.level
+ setValues(vals)
+ setDirty(false)
+ } catch {
+ toast.error(t('common.error'))
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleChange = (key: string, level: PermissionLevel) => {
+ setValues(prev => ({ ...prev, [key]: level }))
+ setDirty(true)
+ }
+
+ const handleSave = async () => {
+ setSaving(true)
+ try {
+ const data = await adminApi.updatePermissions(values)
+ if (data.permissions) {
+ usePermissionsStore.getState().setPermissions(data.permissions)
+ }
+ setDirty(false)
+ toast.success(t('perm.saved'))
+ } catch {
+ toast.error(t('common.error'))
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleReset = () => {
+ const defaults: Record = {}
+ for (const p of entries) defaults[p.key] = p.defaultLevel
+ setValues(defaults)
+ setDirty(true)
+ }
+
+ const entryMap = useMemo(() => new Map(entries.map(e => [e.key, e])), [entries])
+
+ if (loading) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
{t('perm.title')}
+
{t('perm.subtitle')}
+
+
+
+
+
+ {!saving && }
+ {t('common.save')}
+
+
+
+
+ {CATEGORIES.map(cat => (
+
+
+ {t(`perm.cat.${cat.id}`)}
+
+
+ {cat.keys.map(key => {
+ const entry = entryMap.get(key)
+ if (!entry) return null
+ const currentLevel = values[key] || entry.defaultLevel
+ const isDefault = currentLevel === entry.defaultLevel
+ return (
+
+
+
+
{t(`perm.action.${key}`)}
+
+ {t(`perm.actionHint.${key}`)}
+
+
+ {!isDefault && (
+
+ {t('perm.customized')}
+
+ )}
+
+
handleChange(key, e.target.value as PermissionLevel)}
+ aria-label={t(`perm.action.${key}`)}
+ className="h-[42px] w-full rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 text-[0.84375rem] text-m-ink outline-none"
+ >
+ {entry.allowedLevels.map(l => (
+
+ {t(LEVEL_LABELS[l] || l)}
+
+ ))}
+
+
+ )
+ })}
+
+
+ ))}
+
+
+ )
+}
diff --git a/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx b/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx
new file mode 100644
index 000000000..cac92beb1
--- /dev/null
+++ b/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx
@@ -0,0 +1,1777 @@
+import { useEffect, useMemo, useRef, useState, type DragEvent } from 'react'
+import {
+ Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud,
+ ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert,
+ SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard,
+ Radio, Luggage, Globe, Image, CalendarDays, Bell,
+ Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, Route, Navigation, Clock, LocateFixed,
+} from 'lucide-react'
+import PluginIcon from '../../../components/shared/PluginIcon'
+import { adminApi } from '../../../api/client'
+import { usePluginStore } from '../../../store/pluginStore'
+import { useTranslation } from '../../../i18n'
+import { useToast } from '../../../components/shared/Toast'
+import MToggle from '../../components/MToggle'
+import MSheet from '../../components/MSheet'
+import MSegmented from '../../components/MSegmented'
+import MConfirmSheet from '../settings/MConfirmSheet'
+import { MAdminButton, MAdminSheetFrame } from './MAdminUi'
+
+/**
+ * Admin → Plugins, mobile skin. A drop-in replacement for the desktop
+ * AdminPluginsPanel: identical logic (segmented Installed/Discover switch,
+ * search + type/status/sort filters, updates bar, installed rows with capability
+ * chips, a registry list, an enriched detail sheet and the update re-consent
+ * gate) with the mobile design system as the presentation layer. Every dialog is
+ * an MSheet; every boolean is an MToggle.
+ */
+
+interface PluginDep { id: string; version: string }
+interface VersionMismatch { id: string; wanted: string; installed: string }
+type DependencyStatus = 'ok' | 'addonDisabled' | 'missingPlugin' | 'hostIncompatible'
+interface PluginDependencies { requiredAddons: string[]; pluginDependencies: PluginDep[] }
+interface DependencyIssues { disabledAddons: string[]; missing: PluginDep[]; versionMismatch: VersionMismatch[] }
+
+interface PluginRow {
+ id: string
+ name: string
+ description: string | null
+ type: string
+ icon: string | null
+ version: string | null
+ status: string
+ enabled: number
+ last_error: string | null
+ reviewed_at: string | null
+ source_repo: string | null
+ permissions: string
+ capabilities: string
+ /** The plugin needs OPERATOR-supplied egress hosts (a self-hosted target). */
+ operatorEgress?: boolean
+ /** How many hosts the admin has added — 0 means the plugin can't reach anything yet. */
+ egressHostCount?: number
+ dependencies?: PluginDependencies
+ dependencyStatus?: DependencyStatus
+ dependencyIssues?: DependencyIssues
+ /** The TREK versions the plugin says it supports; null if it never declared any. */
+ trekRange?: string | null
+ /** The TREK this server is running — the server does the semver, the client just shows it. */
+ hostVersion?: string
+ /** The author's signature was verified and their key pinned at install. False means the
+ * bytes matched the registry's sha256 and nothing more — one fewer guarantee. */
+ signed?: boolean
+ /** Short form of the pinned key, for eyeballing against what the author reads out. */
+ keyFingerprint?: string | null
+ /** Why an update was refused, if one was. `version` is the version that was refused. */
+ updateBlock?: { code: string; detail: string | null; version: string | null } | null
+}
+interface RegistryItem {
+ id: string
+ name: string
+ author: string
+ description: string
+ repo: string
+ homepage?: string | null
+ type: string
+ /** Lucide icon name from the registry entry; absent → Blocks. */
+ icon?: string | null
+ latest: string | null
+ minTrekVersion: string | null
+ /** The latest version's declared TREK range; null on a legacy registry entry. */
+ trek?: string | null
+ hostVersion?: string
+ /** Whether the LATEST version can be installed on this TREK. Computed server-side. */
+ compatible?: boolean
+ /** Newest installable version — the latest, an older fallback, or null if none fits. */
+ latestCompatible?: string | null
+ reviewedAt: string | null
+ downloadCount?: number | null
+ screenshotUrl: string | null
+ requiredAddons?: string[]
+ pluginDependencies?: PluginDep[]
+ /** The latest version ships an author signature and the entry declares a key. */
+ signed?: boolean
+ /** The full key — public, and carried in full because re-trust compares it exactly. */
+ authorPublicKey?: string | null
+}
+interface RegistryDetail extends RegistryItem {
+ size: number | null
+ publishedAt: string | null
+ manifest: {
+ permissions: string[]
+ egress: string[]
+ /** The plugin needs OPERATOR-supplied hosts — its egress list is not the whole story. */
+ operatorEgress?: boolean
+ settings: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }>
+ license: string | null
+ icon: string | null
+ requiredAddons?: string[]
+ pluginDependencies?: PluginDep[]
+ } | null
+}
+
+/** 409 error-body shape from POST /activate when a dependency blocks activation. */
+interface ActivateErr {
+ response?: {
+ status?: number
+ data?: { code?: string; error?: string; newPermissions?: string[]; newEgress?: string[]; addons?: string[]; missing?: PluginDep[]; versionMismatch?: VersionMismatch[] }
+ }
+}
+
+/** The server's error envelope. Reading `code` — not the message text — is what lets the
+ * UI tell a rotated key (overridable) from a signature that doesn't verify (never). */
+function errBody(e: unknown): { error?: string; code?: string } {
+ return (e as { response?: { data?: { error?: string; code?: string } } })?.response?.data ?? {}
+}
+
+/** The ONE signature refusal an admin may override, because a rotation has a benign
+ * explanation. SIGNATURE_INVALID / _MISSING / _INCOMPLETE mean the bytes are not what the
+ * author signed — those get an explanation and no override button at all. */
+const RETRUSTABLE = 'SIGNATURE_KEY_CHANGED'
+const SIGNATURE_CODES = [RETRUSTABLE, 'SIGNATURE_INVALID', 'SIGNATURE_MISSING', 'SIGNATURE_INCOMPLETE']
+
+/**
+ * What the signature dialog needs to talk about a plugin — deliberately NOT a PluginRow.
+ *
+ * A refusal can land on a plugin that is not installed (a fresh install from Discover, or a
+ * dependency being downloaded), and those have no row. Keying the dialog off PluginRow meant
+ * the lookup missed and the refusal silently degraded to a toast — on the very path where an
+ * admin most often meets these codes for the first time. Name + pinned fingerprint is all the
+ * dialog ever reads, and both a row and a registry entry can supply that.
+ */
+type SigSubject = { id: string; name: string; keyFingerprint: string | null }
+
+/**
+ * Is a recorded update block still describing the version on offer?
+ *
+ * Once the registry offers something NEWER than the version that was refused, the block
+ * describes an artifact nobody is being offered anymore — so it reads as stale and the
+ * admin can simply re-attempt (the next install re-verifies and either succeeds or
+ * re-blocks with fresh values). When the registry is unreachable we can't prove staleness,
+ * so the block stands: silently dropping the last thing we knew would be the worse failure.
+ */
+function blockIsCurrent(p: PluginRow, latestVer?: string): boolean {
+ if (!p.updateBlock) return false
+ // A block describes a refused REGISTRY update. Once a plugin is sideloaded or dev-linked
+ // it has left the registry trust model — the running code is whatever the admin supplied,
+ // and a block about an author signing key says nothing about it. The server clears the
+ // block on both paths; this makes it impossible to render a stale one regardless.
+ if (!isRegistrySourced(p.source_repo)) return false
+ if (!latestVer || !p.updateBlock.version) return true
+ return latestVer === p.updateBlock.version
+}
+
+type T = (k: string, p?: Record) => string
+type TypeFilter = 'all' | 'widget' | 'page' | 'integration' | 'trip-page'
+type StatusFilter = 'all' | 'on' | 'off' | 'update' | 'err'
+type SortKey = 'name' | 'recent' | 'updates' | 'downloads'
+
+// Runtime health → dot colour on the icon tile (mobile status tokens).
+const HEALTH: Record = {
+ active: 'bg-[color:var(--m-st-confirmed)]',
+ starting: 'bg-[color:var(--m-st-info)] animate-pulse',
+ error: 'bg-[color:var(--m-st-danger)]',
+ inactive: 'bg-[color:var(--m-faint)]',
+ disabled: 'bg-[color:var(--m-st-pending)]',
+ incompatible: 'bg-[color:var(--m-st-pending)]',
+}
+
+// Known permissions → human-readable i18n key; unknown ones render as raw code.
+const PERM_KEYS = [
+ 'db:own', 'db:read:trips', 'db:read:users', 'db:read:costs', 'db:read:packing', 'db:read:files', 'db:read:files:content',
+ 'db:read:collab', 'db:read:journal', 'db:read:atlas', 'db:read:vacay', 'db:read:daynotes', 'db:read:collections',
+ 'db:read:categories', 'db:read:tags', 'db:read:todos', 'weather:read', 'rates:read', 'db:write:costs',
+ 'db:write:places', 'db:write:days', 'db:write:itinerary', 'db:write:trips', 'db:write:reservations', 'db:write:accommodations', 'db:write:daynotes', 'db:write:packing',
+ 'db:write:tags', 'db:write:todos', 'db:write:atlas', 'db:write:vacay', 'db:write:journal', 'db:write:collections',
+ 'db:write:files', 'db:write:collab', 'db:write:members',
+ 'db:create:trips',
+ 'db:meta',
+ 'notify:send', 'ai:invoke', 'oauth:client',
+ 'events:subscribe', 'jobs:run',
+ 'ws:broadcast:trip', 'ws:broadcast:user',
+ 'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider', 'hook:trip-warning-provider', 'hook:table-contributor', 'hook:map-marker-provider',
+ 'hook:map-layer-provider', 'hook:route-provider', 'hook:day-schedule-provider', 'geolocation:read',
+ 'hook:pdf-section-provider', 'hook:atlas-layer-provider', 'hook:journal-entry-provider', 'hook:trip-card-provider', 'hook:notification-channel', 'hook:user-data', 'http:outbound',
+]
+
+const KNOWN_TYPES = ['widget', 'page', 'integration', 'trip-page']
+
+function isNewer(a: string, b: string): boolean {
+ const nums = (v: string) => v.split('-')[0].split('.').map(n => parseInt(n, 10) || 0)
+ const pa = nums(a), pb = nums(b)
+ for (let i = 0; i < 3; i++) {
+ const x = pa[i] || 0, y = pb[i] || 0
+ if (x !== y) return x > y
+ }
+ return !a.includes('-') && b.includes('-')
+}
+
+function parseJson(raw: string | null | undefined, fallback: T): T {
+ try { const v = JSON.parse(raw || '') as T; return v ?? fallback } catch { return fallback }
+}
+
+interface Cap { icon: React.ComponentType<{ size?: number; className?: string }>; label: string; net?: boolean }
+
+// Turn a plugin's declared permissions + capabilities into the at-a-glance chips
+// that make its real reach legible without opening the detail dialog.
+function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPage?: { replaces?: string[] } }, t: T): Cap[] {
+ const out: Cap[] = []
+ if (perms.includes('db:read:trips')) out.push({ icon: Database, label: t('admin.plugins.cap.readsTrips') })
+ if (perms.includes('db:read:users')) out.push({ icon: Users, label: t('admin.plugins.cap.readsUsers') })
+ if (perms.includes('db:write:costs')) out.push({ icon: Wallet, label: t('admin.plugins.cap.writesCosts') })
+ else if (perms.includes('db:read:costs')) out.push({ icon: Wallet, label: t('admin.plugins.cap.readsCosts') })
+ if (perms.includes('db:read:packing')) out.push({ icon: Luggage, label: t('admin.plugins.cap.readsPacking') })
+ if (perms.includes('db:read:files')) out.push({ icon: FileText, label: t('admin.plugins.cap.readsFiles') })
+ if (perms.includes('db:write:places')) out.push({ icon: MapPin, label: t('admin.plugins.cap.writesPlaces') })
+ if (perms.includes('db:write:days')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.writesDays') })
+ if (perms.includes('db:write:itinerary')) out.push({ icon: ListChecks, label: t('admin.plugins.cap.writesItinerary') })
+ if (perms.includes('db:write:trips')) out.push({ icon: Pencil, label: t('admin.plugins.cap.writesTrips') })
+ if (perms.includes('db:meta')) out.push({ icon: Tag, label: t('admin.plugins.cap.metadata') })
+ if (caps.widget) {
+ const slotKey = caps.widget.slot === 'hero' ? 'admin.plugins.cap.heroWidget'
+ : caps.widget.slot === 'place-detail' ? 'admin.plugins.cap.placeSlot'
+ : caps.widget.slot === 'day-detail' ? 'admin.plugins.cap.daySlot'
+ : caps.widget.slot === 'reservation-detail' ? 'admin.plugins.cap.reservationSlot'
+ : 'admin.plugins.cap.widget'
+ out.push({ icon: LayoutDashboard, label: t(slotKey as never) })
+ }
+ // Replacing planner tabs is the one capability that HIDES core UI — always chip it.
+ if (caps.tripPage?.replaces?.length) out.push({ icon: LayoutDashboard, label: t('admin.plugins.cap.replacesTabs') })
+ if (perms.some(p => p.startsWith('ws:broadcast'))) out.push({ icon: Radio, label: t('admin.plugins.cap.realtime') })
+ if (perms.includes('hook:photo-provider')) out.push({ icon: Image, label: t('admin.plugins.cap.photos') })
+ if (perms.includes('hook:calendar-source')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.calendar') })
+ if (perms.includes('hook:place-detail-provider')) out.push({ icon: MapPin, label: t('admin.plugins.cap.placeDetails') })
+ if (perms.includes('hook:trip-warning-provider')) out.push({ icon: AlertTriangle, label: t('admin.plugins.cap.warnings') })
+ if (perms.includes('hook:map-layer-provider')) out.push({ icon: Route, label: t('admin.plugins.cap.mapLayers') })
+ if (perms.includes('hook:route-provider')) out.push({ icon: Navigation, label: t('admin.plugins.cap.routing') })
+ if (perms.includes('hook:day-schedule-provider')) out.push({ icon: Clock, label: t('admin.plugins.cap.daySchedule') })
+ if (perms.includes('geolocation:read')) out.push({ icon: LocateFixed, label: t('admin.plugins.cap.geolocation') })
+ if (perms.includes('hook:notification-channel')) out.push({ icon: Bell, label: t('admin.plugins.cap.notificationChannel') })
+ if (perms.includes('events:subscribe')) out.push({ icon: Radio, label: t('admin.plugins.cap.events') })
+ for (const h of perms.filter(p => p.startsWith('http:outbound:')).map(p => p.slice('http:outbound:'.length)).filter(Boolean)) {
+ out.push({ icon: ArrowRight, label: h, net: true })
+ }
+ return out
+}
+
+interface DepChip { icon: React.ComponentType<{ size?: number; className?: string }>; label: string; blocked: boolean }
+
+// A plugin's declared dependencies as chips — a required addon (amber when that
+// addon is disabled), a plugin dependency (amber when missing / version-mismatched),
+// or the TREK version itself (amber when this server has outgrown the plugin's range,
+// which is the one blocker the admin cannot fix by flipping something else on).
+function deriveDeps(p: PluginRow, t: T): DepChip[] {
+ const out: DepChip[] = []
+ const issues = p.dependencyIssues
+ if (p.dependencyStatus === 'hostIncompatible') {
+ out.push({
+ icon: AlertTriangle,
+ label: p.trekRange
+ ? t('admin.plugins.dep.trekIncompatible', { range: p.trekRange, host: p.hostVersion ?? '?' })
+ : t('admin.plugins.dep.trekUnknown'),
+ blocked: true,
+ })
+ }
+ for (const a of p.dependencies?.requiredAddons ?? []) {
+ out.push({ icon: Blocks, label: t('admin.plugins.cap.requiresAddon', { addon: a }), blocked: !!issues?.disabledAddons.includes(a) })
+ }
+ for (const d of p.dependencies?.pluginDependencies ?? []) {
+ const blocked = !!(issues?.missing.some(m => m.id === d.id) || issues?.versionMismatch.some(m => m.id === d.id))
+ out.push({ icon: Puzzle, label: t('admin.plugins.cap.dependsOn', { id: d.id, version: d.version }), blocked })
+ }
+ return out
+}
+
+/**
+ * What the Install button may do for a registry item.
+ *
+ * The server already decided compatibility — it owns the semver, and a second
+ * implementation here would eventually disagree with the install gate and offer a button
+ * that 400s. This only picks the wording. Note the middle case: when the newest release
+ * has outrun this TREK but an older one still fits, offer THAT version rather than a dead
+ * grey button. The plugin is perfectly usable, just not at its newest.
+ */
+function installOffer(item: RegistryItem, t: T): { blocked: boolean; version?: string; label: string; title?: string } {
+ if (item.compatible !== false) return { blocked: false, label: t('admin.plugins.install') }
+ const title = item.trek
+ ? t('admin.plugins.dep.trekIncompatible', { range: item.trek, host: item.hostVersion ?? '?' })
+ : t('admin.plugins.dep.trekUnknown')
+ if (item.latestCompatible) {
+ return { blocked: false, version: item.latestCompatible, label: t('admin.plugins.installCompatible', { version: item.latestCompatible }), title }
+ }
+ return { blocked: true, label: t('admin.plugins.incompatible'), title }
+}
+
+// ── Chip / badge tone helpers (mobile status tokens) ────────────────────────
+const CHIP_NEUTRAL = 'text-m-muted border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)]'
+const CHIP_INFO = 'text-[color:var(--m-st-info)] border-[color:color-mix(in_srgb,var(--m-st-info)_28%,transparent)] bg-[color:color-mix(in_srgb,var(--m-st-info)_10%,transparent)]'
+const CHIP_PENDING = 'text-[color:var(--m-st-pending)] border-[color:color-mix(in_srgb,var(--m-st-pending)_28%,transparent)] bg-[color:color-mix(in_srgb,var(--m-st-pending)_10%,transparent)]'
+const PENDING_CARD = 'border-[color:color-mix(in_srgb,var(--m-st-pending)_28%,transparent)] bg-[color:color-mix(in_srgb,var(--m-st-pending)_10%,transparent)]'
+const ACT_PILL = 'inline-flex flex-none items-center justify-center gap-[5px] whitespace-nowrap rounded-full px-3 py-[7px] text-[0.6875rem] font-bold bg-m-act text-m-actfg disabled:opacity-50'
+
+function ReviewedBadge({ t, compact }: { t: T; compact?: boolean }) {
+ if (compact) return
+ return (
+
+ {t('admin.plugins.reviewed')}
+
+ )
+}
+
+/** Marks a manually-uploaded (sideloaded) plugin: no registry, unsigned, not reviewed. */
+function SideloadedBadge({ t }: { t: T }) {
+ return (
+
+ {t('admin.plugins.sideloaded')}
+
+ )
+}
+
+/**
+ * Whether a plugin came from the REGISTRY (as opposed to a manual upload or a dev-link).
+ *
+ * This is the precedence rule for the trust badges, and it is load-bearing: `signed`
+ * derives from the pinned author key while sideloaded/dev-linked derive from source_repo,
+ * so the states are NOT mutually exclusive in the data. A sideloaded plugin genuinely has
+ * no pinned key, and a naive render would put "Unsigned" *and* "Sideloaded" side by side.
+ * The source badge wins — it already says something strictly stronger, and doubling up
+ * dilutes the amber into wallpaper, which is exactly what makes a warning worthless.
+ */
+function isRegistrySourced(sourceRepo: string | null | undefined): boolean {
+ return !!sourceRepo && sourceRepo !== 'local:upload' && sourceRepo !== 'local:link'
+}
+
+/**
+ * Signed / Unsigned, for registry plugins only (see isRegistrySourced).
+ *
+ * Signed is a quiet neutral tick, NOT a green celebration; unsigned is an amber note, NOT
+ * a red alarm. Roughly two thirds of the live registry is unsigned, so an alarming
+ * treatment would fire on most of the catalog and teach admins to ignore it — and the
+ * honest delta is small: sha256 proves the bytes are what the REGISTRY vouches for, a
+ * signature proves they came from the AUTHOR. Unsigned is one fewer guarantee, not "unsafe".
+ */
+function TrustBadge({ signed, t }: { signed: boolean; t: T }) {
+ if (signed) {
+ return (
+
+ {t('admin.plugins.signed')}
+
+ )
+ }
+ return (
+
+ {t('admin.plugins.unsigned')}
+
+ )
+}
+
+/** Marks a dev-linked plugin: loaded from a local build dir + hot-reloaded (dev only). */
+function DevLinkBadge({ t }: { t: T }) {
+ return (
+
+ {t('admin.plugins.devLinkBadge')}
+
+ )
+}
+
+function TypeBadge({ type, t }: { type: string; t: T }) {
+ return (
+
+ {KNOWN_TYPES.includes(type) ? t(`admin.plugins.type.${type}` as never) : type}
+
+ )
+}
+
+export default function MAdminPluginsPanel() {
+ const { t, locale } = useTranslation()
+ const toast = useToast()
+ const [runtimeOn, setRuntimeOn] = useState(false)
+ const [devLink, setDevLink] = useState(false) // dev-link enabled server-side (TREK_PLUGINS_DEV_LINK)
+ const [linkPath, setLinkPath] = useState('')
+ const [plugins, setPlugins] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(false)
+ const [busy, setBusy] = useState(null)
+ const [view, setView] = useState<'installed' | 'discover'>('installed')
+ const [registry, setRegistry] = useState(null)
+ const [latest, setLatest] = useState>({})
+ // The registry entry per id — the re-trust dialog needs the NEW author key from it (in
+ // full: the server's equality check is exact), and the Discover/consent copy needs `signed`.
+ const [regById, setRegById] = useState>({})
+ // Open when a signature check refused an install/update. Carries the code, so the dialog
+ // knows whether an override may even be offered.
+ const [signatureBlock, setSignatureBlock] = useState<{ subject: SigSubject; code: string; detail: string | null } | null>(null)
+ const [retrusting, setRetrusting] = useState(false)
+ const [detailFor, setDetailFor] = useState(null)
+ const [errorsFor, setErrorsFor] = useState<{ id: string; rows: Array<{ ts: string; level: string; message: string }> } | null>(null)
+ const [egressFor, setEgressFor] = useState<{ id: string; supported: boolean; hosts: string[] } | null>(null)
+ const [egressDraft, setEgressDraft] = useState('')
+ const [egressSaving, setEgressSaving] = useState(false)
+ const [egressError, setEgressError] = useState('')
+ const [confirmUninstall, setConfirmUninstall] = useState(null)
+ // A QUEUE, not one slot: "Update All" can produce several re-consent prompts —
+ // each must be shown, not silently overwritten by the last one.
+ const [consentQueue, setConsentQueue] = useState>([])
+ // Open when enabling a plugin is blocked by missing/outdated plugin dependencies.
+ const [depResolve, setDepResolve] = useState<{ plugin: PluginRow; missing: PluginDep[]; versionMismatch: VersionMismatch[] } | null>(null)
+ const [menu, setMenu] = useState(null)
+
+ // Toolbar state.
+ const [q, setQ] = useState('')
+ const [typeFilter, setTypeFilter] = useState('all')
+ const [statusFilter, setStatusFilter] = useState('all')
+ const [sort, setSort] = useState('name')
+
+ // 'updates' only ranks installed plugins, 'downloads' only the registry — snap
+ // the key back to name when switching tabs so the dropdown never carries a label
+ // for an option the active tab can't offer.
+ useEffect(() => {
+ if (view === 'discover' && sort === 'updates') setSort('name')
+ else if (view === 'installed' && sort === 'downloads') setSort('name')
+ }, [view, sort])
+
+ // Sideload upload: drag a plugin .zip onto the panel or use the toolbar button.
+ const [dragActive, setDragActive] = useState(false)
+ const uploadInputRef = useRef(null)
+ const dragDepth = useRef(0)
+
+ // Index the registry once per fetch: the version map the update badges read, plus the
+ // whole entry (author key + signed flag) the trust badges and re-trust dialog need.
+ const indexRegistry = (items: RegistryItem[]) => {
+ const vers: Record = {}
+ const byId: Record = {}
+ items.forEach((i) => { byId[i.id] = i; if (i.latest) vers[i.id] = i.latest })
+ setLatest(vers)
+ setRegById(byId)
+ }
+
+ const refresh = () => {
+ // Keep the app-wide active-plugin store in sync so widget/hero/tab consumers
+ // (e.g. the dashboard) reflect an activate/deactivate without a full reload (F5).
+ void usePluginStore.getState().loadPlugins()
+ adminApi.plugins()
+ .then((d: { enabled: boolean; devLink?: boolean; plugins: PluginRow[] }) => {
+ setRuntimeOn(!!d.enabled)
+ setDevLink(!!d.devLink)
+ setPlugins(d.plugins || [])
+ if ((d.plugins || []).length) {
+ adminApi.pluginBrowse().then(indexRegistry).catch(() => {})
+ }
+ })
+ .catch(() => setError(true))
+ .finally(() => setLoading(false))
+ }
+ useEffect(refresh, [])
+
+ // A signature refusal is not an ordinary "action failed": it is the one class of error
+ // where WHICH failure it was decides what the admin may do about it. Route it to the
+ // dialog off the CODE, never off the message text. Returns true when handled.
+ //
+ // The subject is resolved from wherever it can be — the installed row, else the registry
+ // entry (a plugin being INSTALLED has no row yet), else the bare id. A signature code
+ // ALWAYS opens the dialog: falling back to a toast is what used to hide an invalid
+ // signature on a fresh install behind the same treatment as a network blip.
+ const routeSignatureError = (id: string, code?: string, detail?: string): boolean => {
+ if (!code || !SIGNATURE_CODES.includes(code)) return false
+ const row = plugins.find(p => p.id === id)
+ const subject: SigSubject = row
+ ? { id, name: row.name, keyFingerprint: row.keyFingerprint ?? null }
+ // Not installed: no pinned key exists, so SIGNATURE_KEY_CHANGED cannot arise and the
+ // dialog shows the no-override explanation — which is exactly right.
+ : { id, name: regById[id]?.name ?? id, keyFingerprint: null }
+ setSignatureBlock({ subject, code, detail: detail ?? null })
+ return true
+ }
+
+ const act = async (id: string, fn: () => Promise, ok: string) => {
+ setBusy(id); setMenu(null)
+ try { await fn(); toast.success(ok) }
+ catch (e) {
+ const { error, code } = errBody(e)
+ if (!routeSignatureError(id, code, error)) toast.error(error || t('admin.plugins.actionError'))
+ }
+ finally { setBusy(null); refresh() }
+ }
+
+ const openDiscover = () => {
+ setView('discover')
+ if (!registry) {
+ adminApi.pluginBrowse()
+ .then((items: RegistryItem[]) => { setRegistry(items); indexRegistry(items) })
+ .catch(() => setRegistry([]))
+ }
+ }
+ // The rescan/reload button rediscovers locally-installed plugins AND force-pulls
+ // the remote registry (bypassing the 30-min server cache + GitHub's CDN), so a
+ // just-published plugin shows up right away instead of up to ~35 min later.
+ const rescan = () => act('__rescan', async () => {
+ await adminApi.pluginRescan()
+ const items: RegistryItem[] = await adminApi.pluginBrowse(true)
+ setRegistry(items)
+ indexRegistry(items)
+ }, t('admin.plugins.rescanned'))
+
+ // Sideload a plugin archive (installs INACTIVE — the admin still consents on activation).
+ const uploadPlugin = async (file: File) => {
+ setBusy('__upload'); setMenu(null)
+ try {
+ const res = await adminApi.pluginUpload(file)
+ setView('installed')
+ toast.success(t('admin.plugins.uploaded', { name: res.id }))
+ } catch (e) {
+ toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError'))
+ } finally {
+ setBusy(null); refresh()
+ }
+ }
+ const pickUpload = () => uploadInputRef.current?.click()
+ const onDragEnter = (e: DragEvent) => {
+ if (!runtimeOn || !Array.from(e.dataTransfer.types).includes('Files')) return
+ e.preventDefault(); dragDepth.current++; setDragActive(true)
+ }
+ const onDragLeave = () => { if (--dragDepth.current <= 0) { dragDepth.current = 0; setDragActive(false) } }
+ const onDrop = (e: DragEvent) => {
+ e.preventDefault(); dragDepth.current = 0; setDragActive(false)
+ if (!runtimeOn) return
+ const f = e.dataTransfer.files?.[0]
+ if (f) void uploadPlugin(f)
+ }
+ const openEgress = (id: string) => {
+ setMenu(null)
+ setEgressDraft(''); setEgressError('')
+ adminApi.pluginEgressHosts(id)
+ .then(d => setEgressFor({ id, supported: d.supported, hosts: d.hosts }))
+ .catch(() => setEgressFor({ id, supported: false, hosts: [] }))
+ }
+
+ // Saving RE-SPAWNS the plugin: the child's egress guard is installed once at init and
+ // a second init is refused, so a live child's allow-list can never be widened in place.
+ const saveEgress = async (hosts: string[]) => {
+ if (!egressFor) return
+ setEgressSaving(true); setEgressError('')
+ try {
+ const d = await adminApi.pluginSetEgressHosts(egressFor.id, hosts)
+ setEgressFor({ ...egressFor, hosts: d.hosts })
+ setEgressDraft('')
+ } catch (e) {
+ const err = e as { response?: { data?: { error?: string } } }
+ setEgressError(err.response?.data?.error || t('common.error'))
+ } finally {
+ setEgressSaving(false)
+ }
+ }
+
+ const openErrors = (id: string) => {
+ setMenu(null)
+ adminApi.pluginErrors(id)
+ .then((d: { errors: Array<{ ts: string; level: string; message: string }> }) => setErrorsFor({ id, rows: d.errors }))
+ .catch(() => setErrorsFor({ id, rows: [] }))
+ }
+
+ const updateAvailable = (p: PluginRow) => !!(p.version && latest[p.id] && isNewer(latest[p.id], p.version))
+ const install = (id: string, version?: string) => act(id, () => adminApi.pluginInstall(id, version ? { version } : undefined), t('admin.plugins.installed'))
+ const restart = (id: string) => act(id, async () => { await adminApi.pluginDeactivate(id); await adminApi.pluginActivate(id) }, t('admin.plugins.restarted'))
+ // Dev-link: register a plugin from a local built directory (dev only). Reuses the
+ // same busy/toast/refresh loop as uploadPlugin; the server gates it.
+ const linkLocal = async () => {
+ const p = linkPath.trim()
+ if (!p) return
+ setBusy('__link'); setMenu(null)
+ try {
+ const res = await adminApi.pluginLink(p)
+ setView('installed')
+ setLinkPath('')
+ toast.success(t('admin.plugins.devLinkLinked', { id: res.id }))
+ } catch (e) {
+ toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError'))
+ } finally {
+ setBusy(null); refresh()
+ }
+ }
+ const installedIds = new Set(plugins.map(p => p.id))
+
+ // Installed-but-disabled direct deps that enabling `p` will auto-enable first.
+ const autoEnabledDeps = (p: PluginRow) =>
+ (p.dependencies?.pluginDependencies ?? [])
+ .map(d => plugins.find(x => x.id === d.id))
+ .filter((x): x is PluginRow => !!x && x.enabled === 0)
+ .map(x => x.name)
+
+ // Shared handling for a failed activation: route each 409 code to the right fix
+ // (consent dialog, download-dependency dialog, or a clear toast).
+ const onActivateError = (p: PluginRow, e: ActivateErr) => {
+ const d = e?.response?.data
+ if (e?.response?.status === 409 && d?.code === 'CONSENT_REQUIRED') {
+ setConsentQueue(qq => [...qq, { plugin: p, version: latest[p.id] ?? p.version ?? '', newPermissions: d.newPermissions ?? [], newEgress: d.newEgress ?? [] }])
+ } else if (e?.response?.status === 409 && d?.code === 'ADDON_DISABLED') {
+ toast.error(t('admin.plugins.dep.addonDisabledToast', { addons: (d.addons ?? []).join(', ') }))
+ } else if (e?.response?.status === 409 && d?.code === 'DEPENDENCY_MISSING') {
+ setDepResolve({ plugin: p, missing: d.missing ?? [], versionMismatch: d.versionMismatch ?? [] })
+ } else {
+ // DEPENDENCY_CYCLE and everything else surface their server message.
+ toast.error(d?.error || t('admin.plugins.actionError'))
+ }
+ }
+
+ const attemptActivate = (p: PluginRow) => {
+ const cascaded = autoEnabledDeps(p)
+ return adminApi.pluginActivate(p.id)
+ .then(() => {
+ toast.success(t('admin.plugins.activated'))
+ if (cascaded.length) toast.success(t('admin.plugins.dep.autoEnabled', { plugins: cascaded.join(', ') }))
+ setDepResolve(null)
+ })
+ .catch((e: ActivateErr) => onActivateError(p, e))
+ }
+
+ // Enable/disable a plugin. Re-enabling one whose update widened its permissions
+ // must NOT grant them silently (409 CONSENT_REQUIRED → consent dialog); a disabled
+ // required addon or a missing plugin dependency (409 ADDON_DISABLED /
+ // DEPENDENCY_MISSING) routes to the right remedy.
+ const toggle = (p: PluginRow) => {
+ if (busy === p.id) return
+ if (p.enabled === 1) { void act(p.id, () => adminApi.pluginDeactivate(p.id), t('admin.plugins.deactivated')); return }
+ setBusy(p.id); setMenu(null)
+ attemptActivate(p).finally(() => { setBusy(null); refresh() })
+ }
+
+ // Download a missing/outdated plugin dependency (latest compatible for its range,
+ // transitively), then retry enabling the plugin that needed it.
+ const resolveDependency = (parent: PluginRow, depId: string, constraint?: string) => {
+ if (busy === parent.id) return
+ setBusy(parent.id)
+ adminApi.pluginInstall(depId, { constraint, withDependencies: true })
+ .then((r: { installed?: string[]; requiredAddons?: string[] }) => {
+ toast.success(t('admin.plugins.dep.downloaded', { id: depId }))
+ if (r?.requiredAddons?.length) toast.error(t('admin.plugins.dep.addonDisabledToast', { addons: r.requiredAddons.join(', ') }))
+ return attemptActivate(parent)
+ })
+ // The DEPENDENCY is what's being downloaded, so a signature refusal here is about the
+ // dependency's author, not the parent's — route it under depId before falling through
+ // to the activation-error handling, which knows nothing about signature codes.
+ .catch((e: ActivateErr) => {
+ const { error, code } = errBody(e)
+ if (!routeSignatureError(depId, code, error)) onActivateError(parent, e)
+ })
+ .finally(() => { setBusy(null); refresh() })
+ }
+
+ const runUpdate = (p: PluginRow) => {
+ setBusy(p.id); setMenu(null)
+ adminApi.pluginUpdate(p.id)
+ .then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => {
+ if (r.activated || (r.newPermissions.length === 0 && r.newEgress.length === 0)) toast.success(t('admin.plugins.updated'))
+ else setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }])
+ })
+ .catch(e => {
+ const { error, code } = errBody(e)
+ if (!routeSignatureError(p.id, code, error)) toast.error(error || t('admin.plugins.actionError'))
+ })
+ .finally(() => { setBusy(null); refresh() })
+ }
+
+ // Confirm a key rotation: re-pin the new key AND update, in one server call. There is no
+ // follow-up /update — a re-pin that waited for a second call would leave the plugin
+ // pinned to a key no install had ever been verified against if that call never came.
+ const confirmRetrust = (id: string, version: string, publicKey: string) => {
+ setRetrusting(true)
+ adminApi.pluginRetrust(id, version, publicKey)
+ .then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => {
+ setSignatureBlock(null)
+ // A re-trusted update widening permissions still needs consent — re-trusting a
+ // signing key says nothing about what the new code is allowed to do. Re-trust only
+ // ever fires for an INSTALLED plugin, so the row is there to consent against.
+ const p = plugins.find(x => x.id === id)
+ if (p && !r.activated && (r.newPermissions.length > 0 || r.newEgress.length > 0)) {
+ setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }])
+ } else toast.success(t('admin.plugins.retrusted'))
+ })
+ .catch(e => toast.error(errBody(e).error || t('admin.plugins.actionError')))
+ .finally(() => { setRetrusting(false); refresh() })
+ }
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ const updatable = useMemo(() => plugins.filter(updateAvailable), [plugins, latest])
+
+ // Installed list after search / type / status filters + sort.
+ const shownInstalled = useMemo(() => {
+ const term = q.trim().toLowerCase()
+ let rows = plugins.filter(p => {
+ const matchesText = !term || `${p.name} ${p.description ?? ''}`.toLowerCase().includes(term)
+ const matchesType = typeFilter === 'all' || p.type === typeFilter
+ const st = statusFilter === 'all' ? true
+ : statusFilter === 'on' ? p.enabled === 1 && p.status !== 'error'
+ : statusFilter === 'off' ? p.enabled === 0
+ : statusFilter === 'update' ? updateAvailable(p)
+ : p.status === 'error'
+ return matchesText && matchesType && st
+ })
+ rows = [...rows].sort((a, b) => {
+ if (sort === 'updates') {
+ const ua = updateAvailable(a) ? 0 : 1, ub = updateAvailable(b) ? 0 : 1
+ if (ua !== ub) return ua - ub
+ }
+ return a.name.localeCompare(b.name)
+ })
+ return rows
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [plugins, q, typeFilter, statusFilter, sort, latest])
+
+ // Registry list after search / type filter.
+ const shownRegistry = useMemo(() => {
+ if (!registry) return null
+ const term = q.trim().toLowerCase()
+ let items = registry.filter(r => {
+ const matchesText = !term || `${r.name} ${r.author} ${r.description}`.toLowerCase().includes(term)
+ const matchesType = typeFilter === 'all' || r.type === typeFilter
+ return matchesText && matchesType
+ })
+ items = [...items].sort((a, b) => {
+ if (sort === 'downloads') return (b.downloadCount ?? 0) - (a.downloadCount ?? 0) || a.name.localeCompare(b.name)
+ if (sort === 'recent') return (Date.parse(b.reviewedAt ?? '') || 0) - (Date.parse(a.reviewedAt ?? '') || 0) || a.name.localeCompare(b.name)
+ return a.name.localeCompare(b.name)
+ })
+ return items
+ }, [registry, q, typeFilter, sort])
+
+ const anyFilter = q.trim() !== '' || typeFilter !== 'all' || statusFilter !== 'all'
+
+ // The plugin whose ⋯ action sheet is open (menu === `row:${id}`).
+ const rowMenuPlugin = menu?.startsWith('row:') ? plugins.find(p => `row:${p.id}` === menu) ?? null : null
+
+ return (
+ { if (dragActive) e.preventDefault() }} onDragLeave={onDragLeave} onDrop={onDrop}>
+ {/* Hidden input for the "Upload plugin" button (drag-drop uses the same handler). */}
+
{ const f = e.target.files?.[0]; if (f) void uploadPlugin(f); e.target.value = '' }} />
+ {/* Drag-to-install overlay */}
+ {dragActive && (
+
+
+ {t('admin.plugins.dropToUpload')}
+
+ )}
+
+ {/* Header */}
+
+
+
+
+
+ {t('admin.plugins.title')}
+
+
{t('admin.plugins.subtitle')}
+
+ {runtimeOn && (
+
+ {t('admin.plugins.runtimeOn')}
+
+ )}
+
+
+
+ {/* Runtime-disabled notice */}
+ {!runtimeOn && !loading && !error && (
+
+
+
+
{t('admin.plugins.disabledTitle')}
+
{t('admin.plugins.disabledBody')}
+
+
+ )}
+
+ {/* Dev-link: register + hot-reload a plugin from a local build dir (dev only). */}
+ {devLink && runtimeOn && !loading && !error && (
+
+ )}
+
+ {/* Toolbar */}
+ {runtimeOn && !loading && !error && (
+
+
+
+ { if (v === 'discover') openDiscover(); else setView('installed') }}
+ options={[
+ { value: 'installed', label: },
+ { value: 'discover', label: },
+ ]}
+ />
+
+
+
+
+
+
+
+ setQ(e.target.value)} type="search"
+ placeholder={t('admin.plugins.searchPlaceholder')}
+ className="h-[42px] w-full rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] pl-9 pr-3 text-[0.84375rem] text-m-ink outline-none placeholder:text-m-faint focus:border-[color:var(--m-faint)]"
+ />
+
+
+
+ } onClick={() => setMenu('type')} />
+ {view === 'installed' && (
+ } onClick={() => setMenu('status')} />
+ )}
+ } onClick={() => setMenu('sort')} />
+
+
+ )}
+
+ {/* Body */}
+ {loading ? (
+
{t('common.loading')}
+ ) : error ? (
+
{t('admin.plugins.loadError')}
+ ) : !runtimeOn ? null : view === 'discover' ? (
+
+ ) : plugins.length === 0 ? (
+
+ ) : (
+
+ {updatable.length > 0 && statusFilter !== 'err' && (
+
+
+
{t('admin.plugins.updatesAvailable', { count: updatable.length })}
+
updatable.forEach(runUpdate)}
+ className="ml-auto rounded-full bg-[color:var(--m-st-pending)] px-3 py-1.5 text-[0.6875rem] font-bold text-white">
+ {t('admin.plugins.updateAll')}
+
+
+ )}
+ {shownInstalled.length === 0 ? (
+
+
+
{t('admin.plugins.noMatchInstalled')}
+
+ ) : shownInstalled.map(p => (
+
toggle(p)}
+ onUpdate={() => runUpdate(p)}
+ onReviewBlock={() => setSignatureBlock({
+ subject: { id: p.id, name: p.name, keyFingerprint: p.keyFingerprint ?? null },
+ code: p.updateBlock!.code, detail: p.updateBlock!.detail,
+ })}
+ onEgress={() => openEgress(p.id)}
+ onMenu={() => setMenu(`row:${p.id}`)} />
+ ))}
+
+ )}
+
+
+
+ {/* Registry detail sheet */}
+ {detailFor && (
+
setDetailFor(null)} />
+ )}
+
+ {/* Row ⋯ action sheet */}
+ {rowMenuPlugin && (
+ setMenu(null)}
+ onRestart={() => restart(rowMenuPlugin.id)}
+ onErrors={() => openErrors(rowMenuPlugin.id)}
+ onEgress={() => openEgress(rowMenuPlugin.id)}
+ onUninstall={() => { setMenu(null); setConfirmUninstall(rowMenuPlugin) }} />
+ )}
+
+ {/* Filter picker sheets */}
+ setMenu(null)} title={t('admin.plugins.filterType')} value={typeFilter}
+ options={[
+ ['all', t('admin.plugins.allTypes')], ['widget', t('admin.plugins.type.widget')],
+ ['integration', t('admin.plugins.type.integration')], ['page', t('admin.plugins.type.page')],
+ ['trip-page', t('admin.plugins.type.trip-page')],
+ ]}
+ onPick={v => setTypeFilter(v as TypeFilter)} />
+ setMenu(null)} title={t('admin.plugins.filterStatus')} value={statusFilter}
+ options={[
+ ['all', t('admin.plugins.allStatuses')], ['on', t('admin.plugins.status.active')], ['off', t('admin.plugins.stateOff')],
+ ['update', t('admin.plugins.filterUpdate')], ['err', t('admin.plugins.status.error')],
+ ]}
+ onPick={v => setStatusFilter(v as StatusFilter)} />
+ setMenu(null)} title={t('admin.plugins.sortBy')} value={sort}
+ options={view === 'discover'
+ ? [['name', t('admin.plugins.sortName')], ['recent', t('admin.plugins.sortRecent')], ['downloads', t('admin.plugins.sortDownloads')]]
+ : [['name', t('admin.plugins.sortName')], ['recent', t('admin.plugins.sortRecent')], ['updates', t('admin.plugins.sortUpdates')]]}
+ onPick={v => setSort(v as SortKey)} />
+
+ {/* Error-log sheet */}
+ {errorsFor && (
+ setErrorsFor(null)} variant="bottom" material="opaque" ariaLabel={t('admin.plugins.errorLog')}>
+ {errorsFor.id} — {t('admin.plugins.errorLog')}}
+ onClose={() => setErrorsFor(null)}
+ >
+
+ {errorsFor.rows.length === 0 ?
{t('admin.plugins.noErrors')}
:
+ errorsFor.rows.map((r, i) => (
+
+ {r.level}
+ {r.ts}
+ {r.message}
+
+ ))}
+
+
+
+ )}
+
+ {/* Operator-supplied egress hosts */}
+ {egressFor && (
+ setEgressFor(null)} variant="bottom" material="opaque" ariaLabel={t('admin.plugins.allowedHosts')}>
+ {egressFor.id} — {t('admin.plugins.allowedHosts')}}
+ onClose={() => setEgressFor(null)}
+ >
+ {!egressFor.supported ? (
+ {t('admin.plugins.allowedHosts.unsupported')}
+ ) : (
+
+
{t('admin.plugins.allowedHosts.hint')}
+ {egressFor.hosts.length === 0 && (
+
{t('admin.plugins.allowedHosts.none')}
+ )}
+ {egressFor.hosts.map(h => (
+
+ {h}
+ saveEgress(egressFor.hosts.filter(x => x !== h))}
+ className="text-m-faint disabled:opacity-50"
+ aria-label={t('common.delete')}
+ >
+
+ ))}
+
+ setEgressDraft(e.target.value)}
+ placeholder="gotify.example.com"
+ className="h-[42px] min-w-0 flex-1 rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 text-[0.84375rem] text-m-ink outline-none placeholder:text-m-faint focus:border-[color:var(--m-faint)]"
+ />
+ saveEgress([...egressFor.hosts, egressDraft.trim()])}
+ className="h-[42px]"
+ >{t('common.add')}
+
+ {egressError &&
{egressError}
}
+
{t('admin.plugins.allowedHosts.restartNote')}
+
+ )}
+
+
+ )}
+
+ {/* Uninstall confirm */}
+ setConfirmUninstall(null)}
+ onConfirm={async () => {
+ const p = confirmUninstall!; setConfirmUninstall(null)
+ await act(p.id, () => adminApi.pluginUninstall(p.id, true), t('admin.plugins.uninstalled'))
+ }}
+ title={t('admin.plugins.uninstallTitle')}
+ message={t('admin.plugins.uninstallBody')}
+ confirmLabel={t('common.delete')}
+ cancelLabel={t('common.cancel')}
+ danger
+ />
+
+ {signatureBlock && (
+ confirmRetrust(signatureBlock.subject.id, version, publicKey)}
+ onClose={() => setSignatureBlock(null)}
+ />
+ )}
+
+ {consentQueue[0] && (
+ {
+ const c = consentQueue[0]; setConsentQueue(qq => qq.slice(1))
+ // consent:true — the ONLY path that may widen a plugin's granted rights.
+ await act(c.plugin.id, () => adminApi.pluginActivate(c.plugin.id, true), t('admin.plugins.updated'))
+ }}
+ onLater={() => { setConsentQueue(qq => qq.slice(1)); toast.success(t('admin.plugins.updateKeptOff')) }}
+ />
+ )}
+
+ {depResolve && (
+ resolveDependency(depResolve.plugin, depId, constraint)}
+ onClose={() => setDepResolve(null)}
+ />
+ )}
+
+ )
+}
+
+// ── Toolbar bits ────────────────────────────────────────────────────────────
+
+function TabLabel({ text, count, active }: { text: string; count?: number; active: boolean }) {
+ return (
+
+ {text}
+ {count != null && (
+ {count}
+ )}
+
+ )
+}
+
+function ToolIcon({ label, onClick, children }: { label: string; onClick: () => void; children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function FilterPill({ label, value, active, icon, onClick }: { label: string; value: string; active: boolean; icon: React.ReactNode; onClick: () => void }) {
+ return (
+
+ {icon}
+ {value}
+
+
+ )
+}
+
+function typeLabel(f: TypeFilter, t: T): string {
+ return f === 'all' ? t('admin.plugins.allTypes') : t(`admin.plugins.type.${f}` as never)
+}
+function statusLabel(s: StatusFilter, t: T): string {
+ return s === 'all' ? t('admin.plugins.allStatuses') : s === 'on' ? t('admin.plugins.status.active')
+ : s === 'off' ? t('admin.plugins.stateOff') : s === 'update' ? t('admin.plugins.filterUpdate') : t('admin.plugins.status.error')
+}
+function sortLabel(s: SortKey, t: T): string {
+ return s === 'name' ? t('admin.plugins.sortName')
+ : s === 'recent' ? t('admin.plugins.sortRecent')
+ : s === 'downloads' ? t('admin.plugins.sortDownloads')
+ : t('admin.plugins.sortUpdates')
+}
+
+function PickerSheet({ open, onClose, title, options, value, onPick }: {
+ open: boolean; onClose: () => void; title: string
+ options: Array<[string, string]>; value: string; onPick: (v: string) => void
+}) {
+ return (
+
+
+
+ {options.map(([v, lbl]) => (
+ { onPick(v); onClose() }}
+ className={`flex w-full items-center gap-2.5 rounded-xl px-3 py-3 text-left text-[0.8125rem] ${
+ value === v ? 'bg-[color:var(--m-ic)] font-bold text-m-ink' : 'font-medium text-m-muted'}`}>
+ {lbl}
+
+
+ ))}
+
+
+
+ )
+}
+
+// ── Installed row ──────────────────────────────────────────────────────────
+
+function InstalledRow({ p, t, busy, hasUpdate, latestVer, blocked, onToggle, onUpdate, onReviewBlock, onEgress, onMenu }: {
+ p: PluginRow; t: T; busy: string | null
+ hasUpdate: boolean; latestVer?: string; blocked: boolean
+ onToggle: () => void; onUpdate: () => void; onReviewBlock: () => void
+ onEgress: () => void; onMenu: () => void
+}) {
+ const caps = deriveCaps(parseJson(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t)
+ const deps = deriveDeps(p, t)
+
+ return (
+
+
+
+
+
+
+ {p.name}
+ {p.version && v{p.version} }
+ {p.reviewed_at && }
+ {p.source_repo === 'local:upload' && }
+ {p.source_repo === 'local:link' && }
+ {/* Registry plugins only — a sideloaded/dev-linked plugin already says something
+ strictly stronger, and stacking "Unsigned" on top of it just dilutes the amber. */}
+ {isRegistrySourced(p.source_repo) && }
+
+ {p.description &&
{p.description}
}
+
+
+
+
+
+
+
+
+
+
+ {/* A refused update leaves a WORKING plugin pinned at its old version — so this is
+ its own row state, not an error state, and it persists until it is resolved
+ rather than dying with the toast that first reported it. */}
+ {blocked && p.updateBlock && (
+
+
+ {t('admin.plugins.updateBlocked', { reason: p.updateBlock.detail ?? p.updateBlock.code })}
+
+ {t('admin.plugins.reviewBlock')}
+
+
+ )}
+
+ {p.status === 'error' && p.last_error ? (
+
+ ) : (caps.length > 0 || p.operatorEgress) && (
+
+ {caps.map((c, i) => (
+
+ {c.label}
+
+ ))}
+ {/* This plugin talks to a service only the OPERATOR can name (a self-hosted
+ Gotify/ntfy), so its manifest can't list the host — the admin adds it.
+ Actionable, and warning-toned until at least one host exists, because
+ until then the plugin cannot reach anything and looks silently broken. */}
+ {p.operatorEgress && (
+ 0 ? CHIP_INFO : CHIP_PENDING}`}
+ >
+ 0 ? 'text-[color:var(--m-st-info)]' : 'text-[color:var(--m-st-pending)]'} />
+ {p.egressHostCount > 0
+ ? t('admin.plugins.allowedHosts.count').replace('{n}', String(p.egressHostCount))
+ : t('admin.plugins.allowedHosts.add')}
+
+ )}
+
+ )}
+
+ {deps.length > 0 && (
+
+ {deps.map((d, i) => (
+
+ {d.label}
+
+ ))}
+
+ )}
+
+ {hasUpdate && (
+
+
+ {t('admin.plugins.updateTo', { version: latestVer })}
+
+
+ )}
+
+ )
+}
+
+// Row ⋯ action sheet — the desktop portal menu re-expressed as a bottom sheet.
+function RowActionsSheet({ p, t, onClose, onRestart, onErrors, onEgress, onUninstall }: {
+ p: PluginRow; t: T; onClose: () => void
+ onRestart: () => void; onErrors: () => void; onEgress: () => void; onUninstall: () => void
+}) {
+ const linkable = p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link'
+ const rowClass = 'flex w-full items-center gap-2.5 rounded-xl px-3 py-3 text-left text-[0.8125rem] font-semibold text-m-ink'
+ return (
+
+
+
+
+
+ )
+}
+
+// ── Registry (Discover) ────────────────────────────────────────────────────
+
+function EmptyState({ t, onDiscover }: { t: T; onDiscover: () => void }) {
+ return (
+
+
+
{t('admin.plugins.empty')}
+
+ {t('admin.plugins.tabDiscover')}
+
+
+ )
+}
+
+function Screenshot({ url, className, iconSize = 28 }: { url: string | null; className: string; iconSize?: number }) {
+ const [failed, setFailed] = useState(false)
+ return (
+
+ {url && !failed ? (
+
setFailed(true)} />
+ ) : (
+
+
+
+ )}
+
+ )
+}
+
+function RegistryList({ items, onInstall, onOpenDetail, busy, t, installedIds, filtered }: {
+ items: RegistryItem[] | null
+ onInstall: (id: string, version?: string) => void
+ onOpenDetail: (item: RegistryItem) => void
+ busy: string | null
+ t: T
+ installedIds: Set
+ filtered: boolean
+}) {
+ if (!items) return {t('common.loading')}
+ if (items.length === 0) return (
+
+
+
{filtered ? t('admin.plugins.noMatchRegistry') : t('admin.plugins.registryEmpty')}
+
+ )
+ return (
+
+ {items.map(item => {
+ const installed = installedIds.has(item.id)
+ const offer = installOffer(item, t)
+ return (
+
onOpenDetail(item)}
+ onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpenDetail(item) } }}
+ className="overflow-hidden rounded-[18px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheetop)]">
+
+
+
+ {item.reviewedAt && (
+
+ {t('admin.plugins.reviewed')}
+
+ )}
+
+
+
+
{item.name}
+
{item.author}
+
{item.description}
+
+
+ {/* Everything in Discover is registry-sourced, so the badge always applies. */}
+
+ {item.latest && v{item.latest} }
+ {typeof item.downloadCount === 'number' && item.downloadCount > 0 && (
+
+ {formatCompactCount(item.downloadCount)}
+
+ )}
+ { e.stopPropagation(); onInstall(item.id, offer.version) }}
+ disabled={busy === item.id || installed || offer.blocked}
+ title={installed ? undefined : offer.title}
+ className={`ml-auto ${ACT_PILL}`}>
+ {installed ? t('admin.plugins.installed') : offer.label}
+
+
+
+
+ )
+ })}
+
+ )
+}
+
+// 1234 -> "1.2k" — GitHub-style compact download counts for the browse cards.
+// The M threshold sits at the k-rounding boundary so 999 950 is "1M", not "1000k".
+function formatCompactCount(n: number): string {
+ if (n >= 999_500) return `${Math.round(n / 100_000) / 10}M`
+ if (n >= 1000) return `${Math.round(n / 100) / 10}k`
+ return String(n)
+}
+
+// A permission rendered human-readable when known, else as its raw code.
+function PermLabel({ perm, t }: { perm: string; t: T }) {
+ return PERM_KEYS.includes(perm)
+ ? {t(`admin.plugins.perm.${perm}` as never)}
+ : {perm}
+}
+
+function PluginDetailSheet({ item, installed, busy, onInstall, onClose, t, locale }: {
+ item: RegistryItem; installed: boolean; busy: string | null
+ onInstall: (id: string, version?: string) => void; onClose: () => void; t: T; locale: string
+}) {
+ const [detail, setDetail] = useState(null)
+ const [failed, setFailed] = useState(false)
+
+ useEffect(() => {
+ let alive = true
+ adminApi.pluginDetail(item.id)
+ .then((d: RegistryDetail) => { if (alive) setDetail(d) })
+ .catch(() => { if (alive) setFailed(true) })
+ return () => { alive = false }
+ }, [item.id])
+
+ const manifest = detail?.manifest ?? null
+ const caps = manifest ? deriveCaps(manifest.permissions, {}, t) : []
+ const repoUrl = `https://github.com/${item.repo}`
+ const homepage = item.homepage && /^https?:\/\//i.test(item.homepage) && item.homepage !== repoUrl ? item.homepage : null
+ const sizeKb = detail?.size ? Math.max(1, Math.round(detail.size / 1024)) : null
+ // The detail fetch carries the same compat verdict as the browse list; prefer it once
+ // it lands (it is keyed to the same entry) and fall back to the grid item until then.
+ const offer = installOffer(detail ?? item, t)
+ const linkClass = 'inline-flex items-center gap-1.5 rounded-lg border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 py-1.5 text-xs font-medium text-m-muted'
+ const sectionH = 'text-[11px] font-semibold uppercase tracking-wider text-m-muted'
+
+ return (
+
+
+
+
+
+
+
+
+
{item.name}
+ {item.reviewedAt && }
+
+
{item.author}{item.latest ? ` · v${item.latest}` : ''}
+
+
onInstall(item.id, offer.version)}
+ disabled={busy === item.id || installed || offer.blocked}
+ title={installed ? undefined : offer.title}
+ className={`${ACT_PILL} self-end`}>
+ {installed ? t('admin.plugins.installed') : offer.label}
+
+
+
+
+
{item.description}
+ {failed &&
{t('admin.plugins.detailError')}
}
+ {/* The reason the button is blocked (or offering an older version) — a tooltip
+ alone would leave a touch user with a dead button and no explanation. */}
+ {offer.title && !installed && (
+
+ {offer.title}
+
+ )}
+
+ {manifest && (
+
+
{t('admin.plugins.accessTitle')}
+ {caps.filter(c => !c.net).length === 0 && !manifest.permissions.includes('db:own') ? (
+
{t('admin.plugins.noAccess')}
+ ) : (
+
+ {caps.filter(c => !c.net).map((c, i) => (
+
+ {c.label}
+
+ ))}
+ {manifest.permissions.includes('db:own') && (
+
+ {t('admin.plugins.perm.db:own')}
+
+ )}
+
+ )}
+
+ )}
+
+ {manifest && (manifest.egress.length > 0 || manifest.operatorEgress) && (
+
+
{t('admin.plugins.connectsTitle')}
+
+ {manifest.egress.map(h => (
+ {h}
+ ))}
+ {/* The hosts above are NOT the whole story for this plugin: it talks to a
+ service only the operator can name, so its reach is whatever an admin
+ adds after install. Say so HERE — this is the pre-install review, and a
+ reviewer who reads only the host list would otherwise be misled. */}
+ {manifest.operatorEgress && (
+
+ {t('admin.plugins.operatorEgressPill')}
+
+ )}
+
+ {manifest.operatorEgress && (
+
{t('admin.plugins.operatorEgressHint')}
+ )}
+
+ )}
+
+ {manifest && manifest.settings.length > 0 && (
+
+
{t('admin.plugins.setupTitle')}
+
+ {manifest.settings.map(s => (
+
+ {s.label}
+ {t(`admin.plugins.scope.${s.scope}` as never)}
+ {s.required && {t('admin.plugins.fieldRequired')} }
+
+ ))}
+
+
+ )}
+
+
+
{t('admin.plugins.detailsTitle')}
+
+ {item.latest && }
+ {sizeKb && }
+ {/* The range, not just its lower bound: "TREK 3.2.0+" reads as "and anything
+ newer", which is exactly the claim a `<4.0.0` upper bound denies. */}
+ {(item.trek || item.minTrekVersion) && (
+
+ )}
+ {item.reviewedAt && }
+ {typeof item.downloadCount === 'number' && item.downloadCount > 0 && (
+
+ )}
+
+
+
+
+
+
+
+ )
+}
+
+function Meta({ k, v }: { k: string; v: string }) {
+ return
+}
+
+/**
+ * A signature check refused an install/update.
+ *
+ * The override lives here, and it is scoped to exactly one code. A rotated key has a
+ * benign explanation — the author rotated it, or lost it and made a new one. A signature
+ * that does NOT verify does not: it means the bytes are not what the author signed, which
+ * is corruption or an attack, and there is no story where the right answer is "let the
+ * admin wave it through". So SIGNATURE_INVALID / _MISSING / _INCOMPLETE get a clear
+ * explanation and no override button AT ALL — not a disabled one, not one behind a
+ * confirm. The absence of an escape hatch is the feature.
+ *
+ * (The server enforces this too — it re-derives the condition and refuses anything but a
+ * changed key. This dialog is the convenience, not the control.)
+ */
+function SignatureBlockSheet({ data, entry, busy, t, onRetrust, onClose }: {
+ data: { subject: SigSubject; code: string; detail: string | null }
+ entry: RegistryItem | undefined
+ busy: boolean; t: T
+ onRetrust: (version: string, publicKey: string) => void
+ onClose: () => void
+}) {
+ const canRetrust = data.code === RETRUSTABLE
+ const newKey = entry?.authorPublicKey ?? null
+ const version = entry?.latest ?? null
+ // Only offer the override when we actually hold the new key + version to send: the
+ // request carries both, and the server compares the key exactly.
+ const offerRetrust = canRetrust && !!newKey && !!version
+
+ const bodyKey = data.code === RETRUSTABLE ? 'admin.plugins.sig.keyChangedBody'
+ : data.code === 'SIGNATURE_MISSING' ? 'admin.plugins.sig.missingBody'
+ : data.code === 'SIGNATURE_INCOMPLETE' ? 'admin.plugins.sig.incompleteBody'
+ : 'admin.plugins.sig.invalidBody'
+
+ return (
+
+
+
+
+
+
{t('admin.plugins.sig.title', { name: data.subject.name })}
+
{t(bodyKey as never)}
+
+
+
+ {/* Fingerprints, not full keys: these exist to be COMPARED by a human — read the
+ new one back to the author over the phone. The full key travels in the request. */}
+ {canRetrust && (
+
+
+
+
{t('admin.plugins.sig.confirmOutOfBand')}
+
+ )}
+ {!canRetrust && data.detail && (
+
{data.detail}
+ )}
+
+
+
+ {offerRetrust ? t('admin.plugins.sig.cancel') : t('common.close')}
+
+ {offerRetrust && (
+ onRetrust(version, newKey)}>
+ {t('admin.plugins.sig.retrustConfirm')}
+
+ )}
+
+
+
+ )
+}
+
+function KeyRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) {
+ return (
+
+ {label}
+ {value}
+
+ )
+}
+
+/** Client-side twin of the server's fingerprint: head…tail of the base64 payload. Display
+ * only — every equality check that matters happens server-side against the full key. */
+function fingerprint(key: string | null): string | null {
+ if (!key) return null
+ const payload = key.trim().split(/\r?\n/).map(l => l.trim()).filter(l => l && !l.startsWith('untrusted comment')).pop()
+ if (!payload) return null
+ return payload.length <= 20 ? payload : `${payload.slice(0, 8)}…${payload.slice(-8)}`
+}
+
+function UpdateConsentSheet({ data, unsigned, t, onApprove, onLater }: {
+ data: { plugin: PluginRow; version: string; newPermissions: string[]; newEgress: string[] }
+ unsigned: boolean
+ t: T; onApprove: () => void; onLater: () => void
+}) {
+ return (
+
+
+
+
+
+
{t('admin.plugins.updateConsentTitle')}
+
{t('admin.plugins.updateConsentBody', { name: data.plugin.name, version: data.version })}
+
+
+
+ {/* The admin is about to widen what this code may do — so say, right here, that
+ nothing ties this code to its author. One line, no checkbox, no extra click:
+ this informs, it does not block. */}
+ {unsigned && (
+
+
+ {t('admin.plugins.sig.consentUnsigned')}
+
+ )}
+ {data.newPermissions.length > 0 && (
+
+
{t('admin.plugins.updateNewPermissions')}
+
+ {data.newPermissions.map(perm => (
+
+ ))}
+
+
+ )}
+ {data.newEgress.length > 0 && (
+
+
{t('admin.plugins.updateNewEgress')}
+
+ {data.newEgress.map(host => {host})}
+
+
+ )}
+
+
+ {t('admin.plugins.updateLater')}
+ {t('admin.plugins.updateApprove')}
+
+
+
+ )
+}
+
+// Shown when enabling a plugin is blocked by missing/outdated plugin dependencies.
+// Each dependency gets a one-click download (latest version satisfying its range,
+// transitively) that then retries enabling the plugin.
+function DependencyResolveSheet({ data, t, busy, installedIds, onDownload, onClose }: {
+ data: { plugin: PluginRow; missing: PluginDep[]; versionMismatch: VersionMismatch[] }
+ t: T; busy: boolean; installedIds: Set
+ onDownload: (depId: string, constraint?: string) => void; onClose: () => void
+}) {
+ const rows: Array<{ id: string; constraint: string; installed?: string }> = [
+ ...data.missing.map(d => ({ id: d.id, constraint: d.version })),
+ ...data.versionMismatch.map(d => ({ id: d.id, constraint: d.wanted, installed: d.installed })),
+ ]
+ return (
+
+
+
+
+
+
{t('admin.plugins.dep.resolveTitle')}
+
{t('admin.plugins.dep.resolveBody', { name: data.plugin.name })}
+
+
+
+ {rows.map(r => (
+
+
+
{r.id}
+
+ {r.installed
+ ? t('admin.plugins.dep.mismatch', { wanted: r.constraint, installed: r.installed })
+ : t('admin.plugins.dep.requires', { version: r.constraint })}
+
+
+
onDownload(r.id, r.constraint)} disabled={busy}
+ className={`shrink-0 ${ACT_PILL}`}>
+ {r.installed ? t('admin.plugins.dep.update') : t('admin.plugins.dep.download')}
+
+
+ ))}
+ {rows.some(r => !installedIds.has(r.id)) && (
+
{t('admin.plugins.dep.resolveHint')}
+ )}
+
+
+ {t('common.cancel')}
+
+
+
+ )
+}
+
+// Footer: a plain-language note on what "Reviewed" means, plus a collapsible
+// panel that lays out how plugins are contained, the limits, and the worst case.
+function SecurityInfo({ t }: { t: T }) {
+ const [open, setOpen] = useState(false)
+ const sections: Array<[string, string]> = [
+ ['admin.plugins.security.isolationTitle', 'admin.plugins.security.isolationBody'],
+ ['admin.plugins.security.permsTitle', 'admin.plugins.security.permsBody'],
+ ['admin.plugins.security.limitsTitle', 'admin.plugins.security.limitsBody'],
+ ['admin.plugins.security.worstTitle', 'admin.plugins.security.worstBody'],
+ ['admin.plugins.security.reviewedTitle', 'admin.plugins.security.reviewedBody'],
+ ['admin.plugins.security.signedTitle', 'admin.plugins.security.signedBody'],
+ ['admin.plugins.security.trustTitle', 'admin.plugins.security.trustBody'],
+ ]
+ return (
+
+
+
+
{t('admin.plugins.reviewedMeaning')}
+
+
setOpen(o => !o)}
+ className="flex w-full items-center justify-between gap-2 border-t border-[color:var(--m-rowbr)] px-4 py-3 text-xs font-medium text-m-muted">
+ {t('admin.plugins.security.title')}
+
+
+ {open && (
+
+ {sections.map(([h, b]) => (
+
+
{t(h as never)}
+
{t(b as never)}
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/client/src/mobile/screens/admin/MAdminSettingsSection.tsx b/client/src/mobile/screens/admin/MAdminSettingsSection.tsx
new file mode 100644
index 000000000..0e61a2aa0
--- /dev/null
+++ b/client/src/mobile/screens/admin/MAdminSettingsSection.tsx
@@ -0,0 +1,436 @@
+import { AlertTriangle, RefreshCw, Sun } from 'lucide-react'
+import { adminApi, authApi } from '../../../api/client'
+import { getApiErrorMessage } from '../../../types'
+import type { TranslationFn } from '../../../types'
+import type { useAdmin } from '../../../pages/admin/useAdmin'
+import MToggle from '../../components/MToggle'
+import {
+ MAdminButton,
+ MAdminCard,
+ MAdminCardHead,
+ MAdminField,
+ MAdminInput,
+ MAdminRow,
+ MAdminSecretInput,
+} from './MAdminUi'
+
+interface MAdminSettingsSectionProps {
+ admin: ReturnType
+ t: TranslationFn
+}
+
+// Settings section: auth methods, passkey login, require-MFA, allowed file
+// types, API keys, OIDC and the danger zone — the full desktop settings tab
+// relaid as mobile cards. All state and mutations come from useAdmin.
+export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectionProps) {
+ const {
+ toast,
+ setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled,
+ placesPhotosEnabled, setPlacesPhotosEnabledState,
+ placesAutocompleteEnabled, setPlacesAutocompleteEnabledState,
+ placesDetailsEnabled, setPlacesDetailsEnabledState,
+ oidcConfig, setOidcConfig, savingOidc, setSavingOidc,
+ passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration,
+ oidcLogin, setOidcLogin, oidcRegistration, setOidcRegistration,
+ envOverrideOidcOnly, oidcConfigured, requireMfa,
+ passkeyLogin, setPasskeyLogin, passkeyConfigured,
+ webauthnRpId, setWebauthnRpId, webauthnOrigins, setWebauthnOrigins, savingWebauthn, handleSaveWebauthn,
+ allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
+ mapsKey, setMapsKey, unsplashKey, setUnsplashKey, savingKeys, validating, validation,
+ setShowRotateJwtModal,
+ handleToggleAuthSetting, handleToggleRequireMfa,
+ handleSaveApiKeys, handleValidateKey,
+ } = admin
+
+ const saveFileTypes = async () => {
+ setSavingFileTypes(true)
+ try {
+ await authApi.updateAppSettings({ allowed_file_types: allowedFileTypes })
+ toast.success(t('admin.fileTypesSaved'))
+ } catch {
+ toast.error(t('common.error'))
+ } finally {
+ setSavingFileTypes(false)
+ }
+ }
+
+ const saveOidc = async () => {
+ setSavingOidc(true)
+ try {
+ const payload: Record = {
+ issuer: oidcConfig.issuer,
+ client_id: oidcConfig.client_id,
+ display_name: oidcConfig.display_name,
+ discovery_url: oidcConfig.discovery_url,
+ }
+ if (oidcConfig.client_secret) payload.client_secret = oidcConfig.client_secret
+ await adminApi.updateOidc(payload)
+ toast.success(t('admin.oidcSaved'))
+ } catch (err: unknown) {
+ toast.error(getApiErrorMessage(err, t('common.error')))
+ } finally {
+ setSavingOidc(false)
+ }
+ }
+
+ return (
+
+ {/* Authentication methods */}
+
+
+ {envOverrideOidcOnly && (
+
+ {t('admin.envOverrideHint')}
+
+ )}
+ handleToggleAuthSetting('password_login', v, setPasswordLogin)}
+ />
+ }
+ />
+ handleToggleAuthSetting('password_registration', v, setPasswordRegistration)}
+ />
+ }
+ />
+ {oidcConfigured && (
+ handleToggleAuthSetting('oidc_login', v, setOidcLogin)}
+ />
+ }
+ />
+ )}
+ {oidcConfigured && (
+ handleToggleAuthSetting('oidc_registration', v, setOidcRegistration)}
+ />
+ }
+ />
+ )}
+
+
+ {/* Passkey login */}
+
+
+ handleToggleAuthSetting('passkey_login', v, setPasskeyLogin)}
+ />
+ }
+ />
+ {passkeyLogin && !passkeyConfigured && (
+
+
+ {t('admin.passkey.notConfigured')}
+
+ )}
+
+
+ setWebauthnRpId(e.target.value)}
+ placeholder="trek.example.org"
+ />
+
+
+ setWebauthnOrigins(e.target.value)}
+ placeholder="https://trek.example.org"
+ />
+
+
+ {t('common.save')}
+
+
+
+
+ {/* Require 2FA */}
+
+
+ }
+ />
+
+
+ {/* Allowed file types */}
+
+
+
+
+ setAllowedFileTypes(e.target.value)}
+ placeholder="jpg,png,pdf,doc,docx,xls,xlsx,txt,csv"
+ />
+
+
+ {t('common.save')}
+
+
+
+
+ {/* API keys */}
+
+
+
+
+ {t('admin.mapsKey')}
+
+ {t('admin.recommended')}
+
+
+ }
+ hint={t('admin.mapsKeyHintLong')}
+ >
+
+
+ setMapsKey(e.target.value)}
+ placeholder={t('settings.keyPlaceholder')}
+ />
+
+
handleValidateKey('maps')}
+ className="h-[42px]"
+ >
+ {t('admin.validateKey')}
+
+
+ {validation.maps === true && (
+
+ {t('admin.keyValid')}
+
+ )}
+ {validation.maps === false && (
+
+ {t('admin.keyInvalid')}
+
+ )}
+
+
+
+ setUnsplashKey(e.target.value)}
+ placeholder={t('settings.keyPlaceholder')}
+ />
+
+
+
+ {
+ setPlacesPhotosEnabledState(next)
+ setPlacesPhotosEnabled(next)
+ try {
+ await adminApi.updatePlacesPhotos(next)
+ } catch {
+ setPlacesPhotosEnabledState(!next)
+ setPlacesPhotosEnabled(!next)
+ }
+ }}
+ />
+ }
+ />
+ {
+ setPlacesAutocompleteEnabledState(next)
+ setPlacesAutocompleteEnabled(next)
+ try {
+ await adminApi.updatePlacesAutocomplete(next)
+ } catch {
+ setPlacesAutocompleteEnabledState(!next)
+ setPlacesAutocompleteEnabled(!next)
+ }
+ }}
+ />
+ }
+ />
+ {
+ setPlacesDetailsEnabledState(next)
+ setPlacesDetailsEnabled(next)
+ try {
+ await adminApi.updatePlacesDetails(next)
+ } catch {
+ setPlacesDetailsEnabledState(!next)
+ setPlacesDetailsEnabled(!next)
+ }
+ }}
+ />
+ }
+ />
+
+
+ {/* Open-Meteo weather info */}
+
+
+
+
+
+
+ {t('admin.weather.title')}
+
+
+ {t('admin.weather.badge')}
+
+
+
+ {t('admin.weather.description')}
+
+
+ {t('admin.weather.locationHint')}
+
+
+ {[
+ { title: t('admin.weather.forecast'), desc: t('admin.weather.forecastDesc') },
+ { title: t('admin.weather.climate'), desc: t('admin.weather.climateDesc') },
+ { title: t('admin.weather.requests'), desc: t('admin.weather.requestsDesc') },
+ ].map((row) => (
+
+
{row.title}
+
{row.desc}
+
+ ))}
+
+
+
+
+ {t('common.save')}
+
+
+
+
+ {/* OIDC / SSO */}
+
+
+
+
+ setOidcConfig((c) => ({ ...c, display_name: e.target.value }))}
+ placeholder="z.B. Google, Authentik, Keycloak"
+ />
+
+
+ setOidcConfig((c) => ({ ...c, issuer: e.target.value }))}
+ placeholder="https://accounts.google.com"
+ />
+
+
+ setOidcConfig((c) => ({ ...c, discovery_url: e.target.value }))}
+ placeholder="https://auth.example.com/application/o/trek/.well-known/openid-configuration"
+ />
+
+
+ setOidcConfig((c) => ({ ...c, client_id: e.target.value }))}
+ />
+
+
+ setOidcConfig((c) => ({ ...c, client_secret: e.target.value }))}
+ placeholder={oidcConfig.client_secret_set ? '••••••••' : ''}
+ />
+
+
+ {t('common.save')}
+
+
+
+
+ {/* Danger zone */}
+
+
+ setShowRotateJwtModal(true)}>
+
+ Rotate
+
+ }
+ />
+
+
+ )
+}
diff --git a/client/src/mobile/screens/admin/MAdminSheets.tsx b/client/src/mobile/screens/admin/MAdminSheets.tsx
new file mode 100644
index 000000000..8bb1bfbd4
--- /dev/null
+++ b/client/src/mobile/screens/admin/MAdminSheets.tsx
@@ -0,0 +1,311 @@
+import { useState } from 'react'
+import { AlertTriangle, CheckCircle, ExternalLink, Fingerprint, Trash2 } from 'lucide-react'
+import { adminApi } from '../../../api/client'
+import type { TranslationFn } from '../../../types'
+import type { useAdmin } from '../../../pages/admin/useAdmin'
+import MSheet from '../../components/MSheet'
+import MSegmented from '../../components/MSegmented'
+import { MAdminButton, MAdminField, MAdminInput, MAdminSecretInput, MAdminSheetFrame } from './MAdminUi'
+
+interface MAdminSheetsProps {
+ admin: ReturnType
+ t: TranslationFn
+}
+
+const DOCKER_UPDATE_COMMANDS = `docker pull mauriceboe/trek:latest
+docker stop trek && docker rm trek
+docker run -d --name trek \\
+ -p 3000:3000 \\
+ -v /opt/trek/data:/app/data \\
+ -v /opt/trek/uploads:/app/uploads \\
+ --restart unless-stopped \\
+ mauriceboe/trek:latest`
+
+// The admin screen's sheet layer: create user, edit user (incl. passkey reset
+// and delete), the "how to update" instructions and the rotate-JWT confirm.
+export default function MAdminSheets({ admin, t }: MAdminSheetsProps) {
+ const {
+ logout, navigate, toast, currentUser,
+ editingUser, setEditingUser, editForm, setEditForm,
+ showCreateUser, setShowCreateUser, createForm, setCreateForm,
+ updateInfo, showUpdateModal, setShowUpdateModal,
+ showRotateJwtModal, setShowRotateJwtModal, rotatingJwt, setRotatingJwt,
+ handleCreateUser, handleSaveUser, handleDeleteUser,
+ } = admin
+
+ const roleOptions = [
+ { value: 'user', label: t('settings.roleUser') },
+ { value: 'admin', label: t('settings.roleAdmin') },
+ ]
+
+ const [showResetPasskeys, setShowResetPasskeys] = useState(false)
+ const [resettingPk, setResettingPk] = useState(false)
+
+ const resetPasskeys = async () => {
+ if (!editingUser) return
+ setResettingPk(true)
+ try {
+ const r = await adminApi.resetUserPasskeys(editingUser.id)
+ toast.success(t('admin.passkey.resetDone', { count: r.deleted ?? 0 }))
+ setShowResetPasskeys(false)
+ } catch {
+ toast.error(t('common.error'))
+ } finally {
+ setResettingPk(false)
+ }
+ }
+
+ const rotateJwt = async () => {
+ setRotatingJwt(true)
+ try {
+ await adminApi.rotateJwtSecret()
+ setShowRotateJwtModal(false)
+ logout()
+ navigate('/login', { state: { noRedirect: true } })
+ } catch {
+ toast.error(t('common.error'))
+ setRotatingJwt(false)
+ }
+ }
+
+ return (
+ <>
+ {/* Create user */}
+ setShowCreateUser(false)} ariaLabel={t('admin.createUser')}>
+ setShowCreateUser(false)}
+ footer={
+ <>
+ setShowCreateUser(false)}>
+ {t('common.cancel')}
+
+ {t('admin.createUser')}
+ >
+ }
+ >
+
+
+ setCreateForm((f) => ({ ...f, username: e.target.value }))}
+ placeholder={t('settings.username')}
+ />
+
+
+ setCreateForm((f) => ({ ...f, email: e.target.value }))}
+ placeholder={t('common.email')}
+ />
+
+
+ setCreateForm((f) => ({ ...f, password: e.target.value }))}
+ placeholder={t('common.password')}
+ />
+
+
+ setCreateForm((f) => ({ ...f, role }))}
+ />
+
+
+
+
+
+ {/* Edit user */}
+ setEditingUser(null)} ariaLabel={t('admin.editUser')}>
+ setEditingUser(null)}
+ footer={
+ <>
+ setEditingUser(null)}>
+ {t('common.cancel')}
+
+ {t('common.save')}
+ >
+ }
+ >
+ {editingUser && (
+
+
+ setEditForm((f) => ({ ...f, username: e.target.value }))}
+ />
+
+
+ setEditForm((f) => ({ ...f, email: e.target.value }))}
+ />
+
+
+ setEditForm((f) => ({ ...f, password: e.target.value }))}
+ placeholder={t('admin.newPasswordPlaceholder')}
+ />
+
+
+ setEditForm((f) => ({ ...f, role }))}
+ />
+
+
+
+ {t('admin.passkey.resetHint')}
+
+
+ setShowResetPasskeys(true)}>
+
+ {t('admin.passkey.reset')}
+
+ {
+ const user = editingUser
+ setEditingUser(null)
+ handleDeleteUser(user)
+ }}
+ >
+
+ {t('admin.deleteUserTitle')}
+
+
+
+
+ )}
+
+
+
+ {/* How to update */}
+ setShowUpdateModal(false)} ariaLabel={t('admin.update.howTo')}>
+ setShowUpdateModal(false)}
+ footer={ setShowUpdateModal(false)}>{t('common.close')} }
+ >
+
+
+ v{updateInfo?.current} → v{updateInfo?.latest}
+
+
+ {(updateInfo?.is_docker === false ? t('admin.update.nonDockerText') : t('admin.update.dockerText')).replace(
+ '{version}',
+ `v${updateInfo?.latest ?? ''}`,
+ )}
+
+ {updateInfo?.is_docker === false ? (
+
+
+ {t('admin.update.wikiLink')}
+
+ ) : (
+
+ {DOCKER_UPDATE_COMMANDS}
+
+ )}
+
+
+ {t('admin.update.dataInfo')}
+
+ {updateInfo?.release_url && (
+
+
+ {t('admin.update.button')}
+
+ )}
+
+
+
+
+ {/* Reset passkeys confirm */}
+ setShowResetPasskeys(false)} ariaLabel={t('admin.passkey.reset')}>
+ setShowResetPasskeys(false)}
+ footer={
+ <>
+ setShowResetPasskeys(false)}>
+ {t('common.cancel')}
+
+
+ {t('admin.passkey.reset')}
+
+ >
+ }
+ >
+
+
+
+
+
+
+ {t('admin.passkey.resetConfirm', { name: editingUser?.username ?? '' })}
+
+
+ {t('admin.passkey.resetHint')}
+
+
+
+
+
+
+ {/* Rotate JWT confirm */}
+ setShowRotateJwtModal(false)} ariaLabel="Rotate JWT Secret">
+ setShowRotateJwtModal(false)}
+ footer={
+ <>
+ setShowRotateJwtModal(false)}>
+ {t('common.cancel')}
+
+
+ Rotate & Log out
+
+ >
+ }
+ >
+
+
+
+
+
+
+ Warning, this will invalidate all sessions and log you out.
+
+
+ A new JWT secret will be generated immediately. Every logged-in user — including you — will be signed
+ out and will need to log in again.
+
+
+
+
+
+ >
+ )
+}
diff --git a/client/src/mobile/screens/admin/MAdminUi.tsx b/client/src/mobile/screens/admin/MAdminUi.tsx
new file mode 100644
index 000000000..7226adfb5
--- /dev/null
+++ b/client/src/mobile/screens/admin/MAdminUi.tsx
@@ -0,0 +1,171 @@
+import { InputHTMLAttributes, ReactNode, useState } from 'react'
+import { Eye, EyeOff, Loader2, X } from 'lucide-react'
+import { useTranslation } from '../../../i18n'
+import MIconBtn from '../../components/MIconBtn'
+
+/** Opaque content card of the admin screen (r18, --m-sheetop on --m-rowbr). */
+export function MAdminCard({ className = '', children }: { className?: string; children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+/** Card header: 14px/800 title, small Geist hint, optional trailing control. */
+export function MAdminCardHead({
+ title,
+ hint,
+ trailing,
+}: {
+ title: ReactNode
+ hint?: ReactNode
+ trailing?: ReactNode
+}) {
+ return (
+
+
+
{title}
+ {hint &&
{hint}
}
+
+ {trailing}
+
+ )
+}
+
+/** Settings row: bold 13px title + Geist hint on the left, control on the right. */
+export function MAdminRow({
+ title,
+ hint,
+ trailing,
+ first = false,
+}: {
+ title: ReactNode
+ hint?: ReactNode
+ trailing?: ReactNode
+ first?: boolean
+}) {
+ return (
+
+
+
{title}
+ {hint &&
{hint}
}
+
+ {trailing}
+
+ )
+}
+
+/** Labelled form field with an optional hint line under the control. */
+export function MAdminField({ label, hint, children }: { label: ReactNode; hint?: ReactNode; children: ReactNode }) {
+ return (
+
+
{label}
+ {children}
+ {hint &&
{hint}
}
+
+ )
+}
+
+export function MAdminInput({ className = '', ...props }: InputHTMLAttributes) {
+ return (
+
+ )
+}
+
+/** Password-style input with the show/hide eye of the desktop admin forms. */
+export function MAdminSecretInput({ className = '', ...props }: InputHTMLAttributes) {
+ const [show, setShow] = useState(false)
+ return (
+
+
+ setShow((v) => !v)}
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-m-faint"
+ >
+ {show ? : }
+
+
+ )
+}
+
+interface MAdminButtonProps {
+ variant?: 'primary' | 'ghost' | 'danger'
+ busy?: boolean
+ disabled?: boolean
+ onClick?: () => void
+ title?: string
+ className?: string
+ children: ReactNode
+}
+
+/** Pill action button (design: r999, --m-act surface, 11px/700). */
+export function MAdminButton({
+ variant = 'primary',
+ busy = false,
+ disabled = false,
+ onClick,
+ title,
+ className = '',
+ children,
+}: MAdminButtonProps) {
+ const look =
+ variant === 'primary'
+ ? 'bg-m-act text-m-actfg'
+ : variant === 'danger'
+ ? 'bg-[color:var(--m-st-danger)] text-white'
+ : 'border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-ink'
+ return (
+
+ {busy && }
+ {children}
+
+ )
+}
+
+/** Sheet body frame: title row + close, scrollable content, optional footer. */
+export function MAdminSheetFrame({
+ title,
+ onClose,
+ footer,
+ children,
+}: {
+ title: ReactNode
+ onClose: () => void
+ footer?: ReactNode
+ children: ReactNode
+}) {
+ const { t } = useTranslation()
+ return (
+ <>
+
+ {children}
+ {footer && (
+
+ {footer}
+
+ )}
+ >
+ )
+}
diff --git a/client/src/mobile/screens/admin/MAdminUsersSection.tsx b/client/src/mobile/screens/admin/MAdminUsersSection.tsx
new file mode 100644
index 000000000..2edaeffcc
--- /dev/null
+++ b/client/src/mobile/screens/admin/MAdminUsersSection.tsx
@@ -0,0 +1,222 @@
+import { Copy, Link2, Trash2, UserPlus } from 'lucide-react'
+import type { TranslationFn } from '../../../types'
+import type { useAdmin } from '../../../pages/admin/useAdmin'
+import MAdminPermissionsPanel from './MAdminPermissionsPanel'
+import MSheet from '../../components/MSheet'
+import MChip from '../../components/MChip'
+import { MAdminButton, MAdminCard, MAdminField, MAdminSheetFrame } from './MAdminUi'
+
+interface MAdminUsersSectionProps {
+ admin: ReturnType
+ t: TranslationFn
+ locale: string
+}
+
+const INVITE_USES = [1, 2, 3, 4, 5, 0]
+const INVITE_EXPIRY: { value: number | ''; label: string }[] = [
+ { value: 1, label: '1d' },
+ { value: 3, label: '3d' },
+ { value: 7, label: '7d' },
+ { value: 14, label: '14d' },
+ { value: '', label: '∞' },
+]
+
+// Users section: user list with role badges (design §6.4), invite links and the
+// permissions matrix. Rows open the edit sheet; Create opens the create sheet.
+export default function MAdminUsersSection({ admin, t, locale }: MAdminUsersSectionProps) {
+ const {
+ currentUser, users, isLoading,
+ setShowCreateUser, handleEditUser,
+ invites, inviteTrips, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm,
+ copyInviteLink, handleCreateInvite, handleDeleteInvite,
+ } = admin
+
+ return (
+
+
+
+ {t('admin.tabs.users')}
+
+ {users.length} {t('admin.stats.users').toLowerCase()}
+
+ setShowCreateUser(true)}>
+
+ {t('mobileAdmin.create')}
+
+
+
+ {isLoading ? (
+
+ ) : (
+ users.map((u) => (
+ handleEditUser(u)}
+ className="flex w-full items-center gap-[11px] border-t border-[color:var(--m-rowbr)] py-[11px] text-left"
+ >
+ {u.avatar_url ? (
+
+ ) : (
+
+ {u.username.charAt(0).toUpperCase()}
+
+ )}
+
+
+ {u.username}
+ {u.id === currentUser?.id && (
+ {t('admin.you')}
+ )}
+
+ {u.email}
+
+
+ {u.role === 'admin' ? t('settings.roleAdmin') : t('settings.roleUser')}
+
+
+ ))
+ )}
+
+
+
+
+
+
{t('admin.invite.title')}
+
{t('admin.invite.subtitle')}
+
+
setShowCreateInvite(true)}>{t('admin.invite.create')}
+
+
+ {invites.length === 0 ? (
+ {t('admin.invite.empty')}
+ ) : (
+ invites.map((inv) => {
+ const isExpired = inv.expires_at && new Date(inv.expires_at) < new Date()
+ const isUsedUp = inv.max_uses > 0 && inv.used_count >= inv.max_uses
+ const isActive = !isExpired && !isUsedUp
+ return (
+
+
+
+
+ {inv.token.slice(0, 12)}…
+
+ {isUsedUp ? t('admin.invite.usedUp') : isExpired ? t('admin.invite.expired') : t('admin.invite.active')}
+
+
+
+ {inv.used_count}/{inv.max_uses === 0 ? '∞' : inv.max_uses} {t('admin.invite.uses')}
+ {inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale)}`}
+ {inv.trip_title && ` · ${t('admin.invite.boundTo', { trip: inv.trip_title })}`}
+ {` · ${t('admin.invite.createdBy')} ${inv.created_by_name}`}
+
+
+ {isActive && (
+
copyInviteLink(inv.token)}
+ className="flex h-8 w-8 flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-muted"
+ >
+
+
+ )}
+
handleDeleteInvite(inv.id)}
+ className="flex h-8 w-8 flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-[color:var(--m-st-danger)]"
+ >
+
+
+
+ )
+ })
+ )}
+
+
+
+
+ {/* Create invite sheet */}
+
setShowCreateInvite(false)} ariaLabel={t('admin.invite.create')}>
+ setShowCreateInvite(false)}
+ footer={
+ <>
+ setShowCreateInvite(false)}>
+ {t('common.cancel')}
+
+ {t('admin.invite.createAndCopy')}
+ >
+ }
+ >
+
+
+
+ {INVITE_USES.map((n) => (
+ setInviteForm((f) => ({ ...f, max_uses: n }))}
+ >
+ {n === 0 ? '∞' : `${n}×`}
+
+ ))}
+
+
+
+
+ {INVITE_EXPIRY.map((opt) => (
+ setInviteForm((f) => ({ ...f, expires_in_days: opt.value }))}
+ >
+ {opt.label}
+
+ ))}
+
+
+ {inviteTrips.length > 0 && (
+
+
+ setInviteForm((f) => ({ ...f, trip_id: e.target.value === '' ? '' : Number(e.target.value) }))
+ }
+ className="h-[42px] w-full rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 text-[0.84375rem] text-m-ink outline-none"
+ >
+ {t('admin.invite.tripNone')}
+ {inviteTrips.map((tr) => (
+
+ {tr.title}
+
+ ))}
+
+
+ )}
+
+
+
+
+ )
+}
diff --git a/client/src/mobile/screens/atlas/MAtlas.tsx b/client/src/mobile/screens/atlas/MAtlas.tsx
new file mode 100644
index 000000000..e6a22aaee
--- /dev/null
+++ b/client/src/mobile/screens/atlas/MAtlas.tsx
@@ -0,0 +1,180 @@
+import { useEffect, useMemo, useState } from 'react'
+import { useSearchParams } from 'react-router-dom'
+import { X } from 'lucide-react'
+import { useAtlas } from '../../../pages/atlas/useAtlas'
+import MIconBtn from '../../components/MIconBtn'
+import MSheet from '../../components/MSheet'
+import MChip from '../../components/MChip'
+import MAtlasStatsCard from './MAtlasStatsCard'
+import MAtlasSearch from './MAtlasSearch'
+import MAtlasCountryPopup from './MAtlasCountryPopup'
+import MAtlasBucketSheet from './MAtlasBucketSheet'
+
+const removeBtnCls = 'mt-4 w-full rounded-full bg-[rgba(214,39,59,.12)] py-[11px] text-center text-[0.8125rem] font-bold text-[color:var(--m-st-danger)]' // theme-lint-disable — fixed status-danger tint
+
+/**
+ * Mobile atlas screen: the interactive world map fills the viewport, the
+ * five-column stats card floats above the bottom nav and the search overlay
+ * flies the map to a country. All map/data logic comes from the shared
+ * useAtlas hook — this file is presentation and wiring only.
+ */
+export default function MAtlas() {
+ const atlas = useAtlas()
+ const {
+ t,
+ navigate,
+ resolveName,
+ loading,
+ mapRef,
+ regionTooltipRef,
+ stats,
+ countries,
+ bucketList,
+ selectedCountry,
+ countryDetail,
+ handleUnmarkCountry,
+ select_country_from_search,
+ atlas_country_options,
+ } = atlas
+ const [searchOpen, setSearchOpen] = useState(false)
+ const [bucketOpen, setBucketOpen] = useState(false)
+ const [detailOpen, setDetailOpen] = useState(false)
+
+ // The dock FAB is the search button on this screen (demo Z. 1099) and hands
+ // off via ?search=1, mirroring the ?create= contract of the other pages.
+ const [searchParams, setSearchParams] = useSearchParams()
+ useEffect(() => {
+ if (searchParams.get('search') == null) return
+ const next = new URLSearchParams(searchParams)
+ next.delete('search')
+ setSearchParams(next, { replace: true })
+ setSearchOpen(true)
+ }, [searchParams, setSearchParams])
+
+ // A search fly-to on a visited country loads its detail — surface it as a sheet.
+ useEffect(() => {
+ if (selectedCountry && countryDetail) setDetailOpen(true)
+ }, [selectedCountry, countryDetail])
+
+ // Suggestions for the empty search field: recently visited countries first,
+ // then countries already on the bucket list.
+ const suggestions = useMemo(() => {
+ const seen = new Set()
+ const list: { code: string; label: string }[] = []
+ const visited = [...countries].sort((a, b) => (b.lastVisit || '').localeCompare(a.lastVisit || ''))
+ for (const c of visited) {
+ if (seen.has(c.code)) continue
+ seen.add(c.code)
+ list.push({ code: c.code, label: resolveName(c.code) })
+ }
+ for (const item of bucketList) {
+ const code = item.country_code
+ if (!code || code.length !== 2 || seen.has(code)) continue
+ seen.add(code)
+ list.push({ code, label: resolveName(code) })
+ }
+ return list.slice(0, 5)
+ }, [countries, bucketList, resolveName])
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+ {/* Region hover tooltip — positioned imperatively by useAtlas. */}
+
+
+ {/* Full-width bucket-list button. The bottom nav makes a back button
+ redundant on this main-nav screen, so the header spans the width. */}
+
+ setBucketOpen(true)}
+ className="flex h-[38px] w-full items-center justify-center rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] px-4 text-[0.78125rem] font-bold text-m-ink shadow-[0_5px_12px_-8px_rgba(0,0,0,.18)]"
+ >
+ {t('atlas.bucketTab')}
+
+
+
+
+
+
setSearchOpen(false)}
+ options={atlas_country_options}
+ suggestions={suggestions}
+ isVisited={(code) => countries.some((c) => c.code === code)}
+ isOnBucketList={(code) => bucketList.some((b) => b.country_code === code)}
+ onSelect={(code) => {
+ setSearchOpen(false)
+ select_country_from_search(code)
+ }}
+ />
+
+ {/* Country detail sheet (visited country with trips/places). */}
+ setDetailOpen(false)}
+ variant="card"
+ ariaLabel={selectedCountry ? resolveName(selectedCountry) : undefined}
+ >
+ {selectedCountry && countryDetail && (
+
+
+
+
+
{resolveName(selectedCountry)}
+
+ {countryDetail.places.length} {t('atlas.places')} · {countryDetail.trips.length} {t('atlas.trips')}
+
+
+
setDetailOpen(false)} ariaLabel={t('common.close')}>
+
+
+
+ {countryDetail.trips.length > 0 && (
+
+ {countryDetail.trips.slice(0, 6).map((trip) => (
+ navigate(`/trips/${trip.id}`)}>
+ {trip.title}
+
+ ))}
+
+ )}
+ {countryDetail.manually_marked && (
+
{
+ setDetailOpen(false)
+ handleUnmarkCountry(selectedCountry)
+ }}
+ className={removeBtnCls}
+ >
+ {t('atlas.unmark')}
+
+ )}
+
+ )}
+
+
+ setBucketOpen(false)} />
+
+
+ )
+}
diff --git a/client/src/mobile/screens/atlas/MAtlasBucketSheet.tsx b/client/src/mobile/screens/atlas/MAtlasBucketSheet.tsx
new file mode 100644
index 000000000..c8a5f3c18
--- /dev/null
+++ b/client/src/mobile/screens/atlas/MAtlasBucketSheet.tsx
@@ -0,0 +1,304 @@
+import { useState } from 'react'
+import { Calendar, ChevronDown, ChevronLeft, ChevronRight, MapPin, Plus, Search, Star, Trash2, X } from 'lucide-react'
+import MSheet from '../../components/MSheet'
+import MIconBtn from '../../components/MIconBtn'
+import { A2_TO_A3 } from '../../../pages/atlas/atlasModel'
+import type { BucketItem } from '../../../pages/atlas/atlasModel'
+import type { AtlasController } from './atlasController'
+
+interface MAtlasBucketSheetProps {
+ atlas: AtlasController
+ open: boolean
+ onClose: () => void
+}
+
+const inputCls =
+ 'w-full rounded-[14px] border border-[color:var(--m-inbr)] bg-[color:var(--m-inner)] px-[14px] py-[11px] text-[0.84375rem] font-medium text-m-ink outline-none placeholder:text-m-faint'
+
+function itemCountryA2(item: BucketItem): string | null {
+ const code = item.country_code
+ if (!code) return null
+ if (code.length === 2) return code
+ return Object.entries(A2_TO_A3).find(([, v]) => v === code)?.[0] ?? null
+}
+
+/**
+ * Bucket-list sheet, entered via the chip on the stats card. Lists every
+ * bucket item with country, target month, coordinates and notes, and carries
+ * the full add flow (POI search or free name, notes, target month).
+ */
+export default function MAtlasBucketSheet({ atlas, open, onClose }: MAtlasBucketSheetProps) {
+ const {
+ t,
+ language,
+ resolveName,
+ bucketList,
+ showBucketAdd,
+ setShowBucketAdd,
+ bucketForm,
+ setBucketForm,
+ bucketSearch,
+ setBucketSearch,
+ bucketSearchResults,
+ setBucketSearchResults,
+ bucketSearching,
+ handleBucketPoiSearch,
+ handleAddBucketItem,
+ handleDeleteBucketItem,
+ } = atlas
+
+ const resetForm = (): void => {
+ setShowBucketAdd(false)
+ setBucketForm({ name: '', notes: '', lat: '', lng: '', target_date: '' })
+ setBucketSearch('')
+ setBucketSearchResults([])
+ }
+
+ const close = (): void => {
+ resetForm()
+ onClose()
+ }
+
+ // Own POI pick instead of the hook's handleSelectBucketPoi: keeps the notes
+ // and target month the user may already have typed into the form.
+ const pickPoi = (result: { name?: string; lat?: number; lng?: number }): void => {
+ setBucketForm({ ...bucketForm, name: result.name || bucketSearch, lat: String(result.lat ?? ''), lng: String(result.lng ?? '') })
+ setBucketSearchResults([])
+ setBucketSearch('')
+ }
+
+ const fmtTarget = (targetDate: string): string => {
+ const [y, m] = targetDate.split('-')
+ return m ? new Date(Number(y), Number(m) - 1).toLocaleDateString(language, { month: 'short', year: 'numeric' }) : y
+ }
+
+ const itemSub = (item: BucketItem): string => {
+ const a2 = itemCountryA2(item)
+ return [
+ a2 ? resolveName(a2) : null,
+ item.target_date ? fmtTarget(item.target_date) : null,
+ item.lat != null && item.lng != null ? `${item.lat.toFixed(2)}, ${item.lng.toFixed(2)}` : null,
+ item.notes || null,
+ ]
+ .filter(Boolean)
+ .join(' · ')
+ }
+
+ return (
+
+
+
+
+
+ {t('atlas.bucketTab')}
+ {bucketList.length > 0 && (
+ {bucketList.length}
+ )}
+
+
+
+
+
+
+
+ {bucketList.length === 0 && !showBucketAdd && (
+
+
{t('atlas.bucketEmpty')}
+
{t('atlas.bucketEmptyHint')}
+
+ )}
+ {bucketList.map((item) => {
+ const a2 = itemCountryA2(item)
+ const sub = itemSub(item)
+ return (
+
+ {a2 ? (
+
+ ) : (
+
+ )}
+
+
{item.name}
+ {sub &&
{sub}
}
+
+
handleDeleteBucketItem(item.id)}
+ aria-label={t('atlas.unmark')}
+ className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full text-m-faint active:bg-[color:var(--m-ic)]"
+ >
+
+
+
+ )
+ })}
+
+
+ {showBucketAdd ? (
+
+
+
+ {
+ const v = e.target.value
+ if (bucketForm.name) setBucketForm({ ...bucketForm, name: v })
+ else setBucketSearch(v)
+ }}
+ onKeyDown={(e) => {
+ if (e.key !== 'Enter') return
+ if (bucketForm.name) handleAddBucketItem()
+ else handleBucketPoiSearch()
+ }}
+ placeholder={t('atlas.bucketNamePlaceholder')}
+ className={`${inputCls} min-w-0 flex-1`}
+ />
+ {bucketForm.name ? (
+ {
+ setBucketForm({ ...bucketForm, name: '', lat: '', lng: '' })
+ setBucketSearch('')
+ }}
+ ariaLabel={t('common.cancel')}
+ >
+
+
+ ) : (
+
+
+
+ )}
+
+ {bucketSearchResults.length > 0 && (
+
+ {bucketSearchResults.slice(0, 6).map((result, i) => (
+ pickPoi(result)} className="flex w-full flex-col px-[14px] py-[9px] text-left active:bg-[color:var(--m-ic)]">
+ {result.name}
+ {result.address && {result.address} }
+
+ ))}
+
+ )}
+
+ {bucketForm.lat && bucketForm.lng && (
+
+
+ {Number(bucketForm.lat).toFixed(4)}, {Number(bucketForm.lng).toFixed(4)}
+
+ )}
+
setBucketForm({ ...bucketForm, notes: e.target.value })}
+ placeholder={t('atlas.bucketNotesPlaceholder')}
+ className={inputCls}
+ />
+
setBucketForm({ ...bucketForm, target_date: v })}
+ placeholder={t('atlas.bucketWhen')}
+ language={language}
+ t={t}
+ />
+
+
+ {t('common.cancel')}
+
+
+ {t('common.add')}
+
+
+
+ ) : (
+
setShowBucketAdd(true)}
+ className="mt-3 flex w-full flex-none items-center justify-center gap-[6px] rounded-full border border-dashed border-[color:var(--m-rowbr)] py-[10px] font-geist text-[0.75rem] font-semibold text-m-muted"
+ >
+
+ {t('atlas.addPoi')}
+
+ )}
+
+
+ )
+}
+
+/** Custom month/year picker replacing the native : a year
+ * stepper over a 12-month grid, opening upward so it clears the docked sheet. */
+function MMonthYearField({ value, onChange, placeholder, language, t }: {
+ value: string
+ onChange: (v: string) => void
+ placeholder: string
+ language: string
+ t: AtlasController['t']
+}) {
+ const [open, setOpen] = useState(false)
+ const selYear = value ? Number(value.split('-')[0]) : null
+ const selMonth = value ? Number(value.split('-')[1]) : null
+ const [year, setYear] = useState(selYear ?? new Date().getFullYear())
+
+ const display = selYear
+ ? new Date(selYear, (selMonth ?? 1) - 1).toLocaleDateString(language, { month: 'long', year: 'numeric' })
+ : ''
+
+ return (
+
+
setOpen(o => !o)} aria-expanded={open} className={`${inputCls} flex items-center gap-2 text-left`}>
+
+ {display || placeholder}
+
+
+ {open && (
+
+
+ setYear(y => y - 1)} aria-label={t('mobileVacay.prevYear')} className="flex h-8 w-8 flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-muted">
+
+
+ {year}
+ setYear(y => y + 1)} aria-label={t('mobileVacay.nextYear')} className="flex h-8 w-8 flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-muted">
+
+
+
+
+ {Array.from({ length: 12 }, (_, m) => {
+ const on = selYear === year && selMonth === m + 1
+ return (
+ { onChange(`${year}-${String(m + 1).padStart(2, '0')}`); setOpen(false) }}
+ className={`rounded-[10px] py-2 text-[0.71875rem] font-bold capitalize ${on ? 'bg-m-act text-m-actfg' : 'bg-[color:var(--m-ic)] text-m-ink'}`}
+ >
+ {new Date(2000, m, 1).toLocaleDateString(language, { month: 'short' })}
+
+ )
+ })}
+
+ {value && (
+
{ onChange(''); setOpen(false) }} className="mt-[10px] w-full rounded-[10px] py-[7px] text-[0.6875rem] font-bold text-m-muted">
+ {t('common.reset')}
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/client/src/mobile/screens/atlas/MAtlasCountryPopup.tsx b/client/src/mobile/screens/atlas/MAtlasCountryPopup.tsx
new file mode 100644
index 000000000..fb7940dc1
--- /dev/null
+++ b/client/src/mobile/screens/atlas/MAtlasCountryPopup.tsx
@@ -0,0 +1,287 @@
+import { useEffect, useState } from 'react'
+import { ChevronRight, MapPin, Star } from 'lucide-react'
+import type { LucideIcon } from 'lucide-react'
+import apiClient from '../../../api/client'
+import { continentForCountry } from '@trek/shared'
+import { getApiErrorMessage } from '../../../types'
+import { useToast } from '../../../components/shared/Toast'
+import { countryCodeToFlag } from '../../../pages/atlas/atlasModel'
+import MSheet from '../../components/MSheet'
+import type { AtlasController } from './atlasController'
+
+const markTint = 'bg-[rgba(47,163,122,.16)] text-[color:var(--m-st-confirmed)]' // theme-lint-disable — fixed tint from the design's option rows
+const bucketTint = 'bg-[rgba(232,161,58,.16)] text-[color:var(--m-st-pending)]' // theme-lint-disable — fixed tint from the design's option rows
+
+const btnBase = 'flex-1 rounded-full py-[11px] text-[0.8125rem] font-bold'
+const cancelBtn = `${btnBase} bg-[color:var(--m-ic)] text-m-ink`
+const dangerBtn = `${btnBase} bg-[color:var(--m-st-danger)] text-white`
+const actBtn = `${btnBase} bg-m-act text-m-actfg`
+
+const inputCls =
+ 'mt-2 w-full rounded-[14px] border border-[color:var(--m-inbr)] bg-[color:var(--m-inner)] px-[14px] py-[11px] text-[0.84375rem] font-medium text-m-ink outline-none'
+
+interface OptionRowProps {
+ icon: LucideIcon
+ tint: string
+ title: string
+ hint: string
+ onClick: () => void
+}
+
+function OptionRow({ icon: Icon, tint, title, hint, onClick }: OptionRowProps) {
+ return (
+
+
+
+
+
+ {title}
+ {hint}
+
+
+
+ )
+}
+
+interface MAtlasCountryPopupProps {
+ atlas: AtlasController
+}
+
+/**
+ * Country/region tap popup: mark visited, add to bucket list (with target
+ * month) or remove again. Same flows and API calls as the desktop popup,
+ * rendered as the mobile card sheet.
+ */
+export default function MAtlasCountryPopup({ atlas }: MAtlasCountryPopupProps) {
+ const {
+ t,
+ confirmAction,
+ setConfirmAction,
+ executeConfirmAction,
+ setData,
+ setVisitedRegions,
+ setBucketList,
+ visitedRegions,
+ } = atlas
+ const toast = useToast()
+ const [bucketDate, setBucketDate] = useState('')
+
+ useEffect(() => {
+ if (!confirmAction) setBucketDate('')
+ }, [confirmAction])
+
+ const markCountry = async (): Promise => {
+ if (!confirmAction) return
+ const { code } = confirmAction
+ try {
+ await apiClient.post(`/addons/atlas/country/${code}/mark`)
+ setData((prev) => {
+ if (!prev || prev.countries.find((c) => c.code === code)) return prev
+ const cont = continentForCountry(code)
+ return {
+ ...prev,
+ countries: [...prev.countries, { code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }],
+ stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 },
+ continents: { ...prev.continents, [cont]: (prev.continents?.[cont] || 0) + 1 },
+ }
+ })
+ } catch (err) {
+ toast.error(getApiErrorMessage(err, t('common.error')))
+ }
+ setConfirmAction(null)
+ }
+
+ const markRegion = async (): Promise => {
+ if (!confirmAction) return
+ const { code: countryCode, name: regionName, regionCode } = confirmAction
+ if (!regionCode) return
+ try {
+ await apiClient.post(`/addons/atlas/region/${regionCode}/mark`, { name: regionName, country_code: countryCode })
+ setVisitedRegions((prev) => {
+ const existing = prev[countryCode] || []
+ if (existing.find((r) => r.code === regionCode)) return prev
+ return { ...prev, [countryCode]: [...existing, { code: regionCode, name: regionName, placeCount: 0, manuallyMarked: true }] }
+ })
+ setData((prev) => {
+ if (!prev || prev.countries.find((c) => c.code === countryCode)) return prev
+ const cont = continentForCountry(countryCode)
+ return {
+ ...prev,
+ countries: [...prev.countries, { code: countryCode, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }],
+ stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 },
+ continents: { ...prev.continents, [cont]: (prev.continents?.[cont] || 0) + 1 },
+ }
+ })
+ } catch (err) {
+ toast.error(getApiErrorMessage(err, t('common.error')))
+ }
+ setConfirmAction(null)
+ }
+
+ const unmarkRegion = async (): Promise => {
+ if (!confirmAction) return
+ const { code: countryCode, regionCode } = confirmAction
+ if (!regionCode) return
+ try {
+ await apiClient.delete(`/addons/atlas/region/${regionCode}/mark`)
+ setVisitedRegions((prev) => {
+ const remaining = (prev[countryCode] || []).filter((r) => r.code !== regionCode)
+ const next = { ...prev, [countryCode]: remaining }
+ if (remaining.length === 0) delete next[countryCode]
+ return next
+ })
+ // Drop the country too once no manually-marked region and no trip/place keeps it.
+ setData((prev) => {
+ if (!prev) return prev
+ const c = prev.countries.find((c) => c.code === countryCode)
+ if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
+ const remainingRegions = (visitedRegions[countryCode] || []).filter((r) => r.code !== regionCode && r.manuallyMarked)
+ if (remainingRegions.length > 0) return prev
+ const cont = continentForCountry(countryCode)
+ return {
+ ...prev,
+ countries: prev.countries.filter((c) => c.code !== countryCode),
+ stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
+ continents: { ...prev.continents, [cont]: Math.max(0, (prev.continents?.[cont] || 0) - 1) },
+ }
+ })
+ } catch (err) {
+ toast.error(getApiErrorMessage(err, t('common.error')))
+ }
+ setConfirmAction(null)
+ }
+
+ const addBucket = async (): Promise => {
+ if (!confirmAction) return
+ try {
+ const r = await apiClient.post('/addons/atlas/bucket-list', {
+ name: confirmAction.name,
+ country_code: confirmAction.code,
+ target_date: bucketDate || null,
+ })
+ setBucketList((prev) => [r.data.item, ...prev])
+ } catch (err) {
+ toast.error(getApiErrorMessage(err, t('common.error')))
+ }
+ setConfirmAction(null)
+ }
+
+ const a = confirmAction
+
+ return (
+ setConfirmAction(null)} variant="card" ariaLabel={a?.name}>
+ {a && (
+
+
+ {a.code.length === 2 ? (
+
+ ) : (
+
{countryCodeToFlag(a.code)}
+ )}
+
{a.name}
+ {a.countryName && (a.type === 'choose-region' || a.type === 'unmark-region') && (
+
{a.countryName}
+ )}
+
+
+ {a.type === 'choose' && (
+ <>
+
+
setConfirmAction({ ...a, type: 'bucket' })}
+ />
+ >
+ )}
+
+ {a.type === 'choose-region' && (
+ <>
+
+ setConfirmAction({ ...a, type: 'bucket' })}
+ />
+ >
+ )}
+
+ {a.type === 'mark' && (
+ <>
+ {t('atlas.confirmMark')}
+
+ setConfirmAction(null)} className={cancelBtn}>
+ {t('common.cancel')}
+
+
+ {t('atlas.markVisited')}
+
+
+ >
+ )}
+
+ {a.type === 'unmark' && (
+ <>
+ {t('atlas.confirmUnmark')}
+
+ setConfirmAction(null)} className={cancelBtn}>
+ {t('common.cancel')}
+
+
+ {t('atlas.unmark')}
+
+
+ >
+ )}
+
+ {a.type === 'unmark-region' && (
+ <>
+ {t('atlas.confirmUnmarkRegion')}
+
+ setConfirmAction(null)} className={cancelBtn}>
+ {t('common.cancel')}
+
+
+ {t('atlas.unmark')}
+
+
+ >
+ )}
+
+ {a.type === 'bucket' && (
+ <>
+
+ {t('atlas.bucketWhen')}
+ setBucketDate(e.target.value)} className={inputCls} />
+
+
+ setConfirmAction({ ...a, type: a.regionCode ? 'choose-region' : 'choose' })}
+ className={cancelBtn}
+ >
+ {t('common.back')}
+
+
+ {t('atlas.addToBucket')}
+
+
+ >
+ )}
+
+ )}
+
+ )
+}
diff --git a/client/src/mobile/screens/atlas/MAtlasSearch.tsx b/client/src/mobile/screens/atlas/MAtlasSearch.tsx
new file mode 100644
index 000000000..8c676e81e
--- /dev/null
+++ b/client/src/mobile/screens/atlas/MAtlasSearch.tsx
@@ -0,0 +1,104 @@
+import { useEffect, useState } from 'react'
+import { ChevronRight, Search } from 'lucide-react'
+import { useTranslation } from '../../../i18n'
+
+interface CountryOption {
+ code: string
+ label: string
+}
+
+interface MAtlasSearchProps {
+ open: boolean
+ onClose: () => void
+ /** All searchable countries (code + localized label). */
+ options: CountryOption[]
+ /** Shown while the query is empty: recently visited + bucket countries. */
+ suggestions: CountryOption[]
+ isVisited: (code: string) => boolean
+ isOnBucketList: (code: string) => boolean
+ onSelect: (code: string) => void
+}
+
+const statusCls = 'flex-none font-geist text-[0.625rem] font-bold uppercase tracking-[.04em] text-m-faint'
+
+/**
+ * Full-screen country search on a blurred scrim. Typing filters the country
+ * list; while empty the suggestion list (visited + bucket countries) fills
+ * the result card. Selecting a row flies the map to the country.
+ */
+export default function MAtlasSearch({
+ open,
+ onClose,
+ options,
+ suggestions,
+ isVisited,
+ isOnBucketList,
+ onSelect,
+}: MAtlasSearchProps) {
+ const { t } = useTranslation()
+ const [query, setQuery] = useState('')
+
+ useEffect(() => {
+ if (!open) setQuery('')
+ }, [open])
+
+ if (!open) return null
+
+ const q = query.trim().toLowerCase()
+ const rows = q ? options.filter((o) => o.label.toLowerCase().includes(q)).slice(0, 6) : suggestions
+
+ const scrimCls = 'm-fade-in fixed inset-0 z-[70] flex flex-col bg-[rgba(16,16,19,.28)] px-[18px] pt-[calc(var(--m-safe-top,12px)+12px)] backdrop-blur-[22px] backdrop-saturate-[1.6]' // theme-lint-disable — fixed scrim value from the design
+
+ return (
+
+
e.stopPropagation()}>
+
+
+ setQuery(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Escape') onClose()
+ if (e.key === 'Enter' && rows.length > 0) onSelect(rows[0].code)
+ }}
+ placeholder={t('atlas.searchCountry')}
+ className="min-w-0 flex-1 bg-transparent font-geist text-[0.9375rem] font-semibold text-m-ink outline-none placeholder:font-normal placeholder:text-m-faint"
+ />
+
+
+ {t('common.cancel')}
+
+
+
+ {rows.length > 0 && (
+
e.stopPropagation()}
+ >
+ {rows.map((r) => (
+
onSelect(r.code)}
+ className="flex w-full items-center gap-3 px-4 py-[14px] text-left active:bg-[color:var(--m-ic)]"
+ >
+
+ {r.label}
+ {isVisited(r.code) ? (
+ {t('mobileAtlas.visited')}
+ ) : isOnBucketList(r.code) ? (
+ {t('atlas.bucketTab')}
+ ) : null}
+
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/client/src/mobile/screens/atlas/MAtlasStatsCard.tsx b/client/src/mobile/screens/atlas/MAtlasStatsCard.tsx
new file mode 100644
index 000000000..eb0000c08
--- /dev/null
+++ b/client/src/mobile/screens/atlas/MAtlasStatsCard.tsx
@@ -0,0 +1,35 @@
+import { useTranslation } from '../../../i18n'
+import type { AtlasStats } from '../../../pages/atlas/atlasModel'
+
+interface MAtlasStatsCardProps {
+ stats: AtlasStats
+}
+
+/**
+ * Floating stats card above the bottom nav: five stat columns straight from the
+ * design. The bucket-list entry now lives in the full-width top header, so this
+ * card is stats only.
+ */
+export default function MAtlasStatsCard({ stats }: MAtlasStatsCardProps) {
+ const { t } = useTranslation()
+ const cols: [number, string][] = [
+ [stats.totalCountries, t('atlas.countries')],
+ [stats.totalTrips, t('atlas.trips')],
+ [stats.totalPlaces, t('atlas.places')],
+ [stats.totalCities || 0, t('atlas.cities')],
+ [stats.totalDays, t('atlas.days')],
+ ]
+
+ return (
+
+
+ {cols.map(([n, l]) => (
+
+ ))}
+
+
+ )
+}
diff --git a/client/src/mobile/screens/atlas/atlasController.ts b/client/src/mobile/screens/atlas/atlasController.ts
new file mode 100644
index 000000000..9c5edd731
--- /dev/null
+++ b/client/src/mobile/screens/atlas/atlasController.ts
@@ -0,0 +1,8 @@
+import { useAtlas } from '../../../pages/atlas/useAtlas'
+
+/**
+ * Full surface of the shared atlas hook. The mobile screen calls useAtlas()
+ * once and hands the controller down to its sheets, so map state, bucket list
+ * and the mark/unmark flows stay in a single instance.
+ */
+export type AtlasController = ReturnType
diff --git a/client/src/mobile/screens/collections/MCollAddSheet.tsx b/client/src/mobile/screens/collections/MCollAddSheet.tsx
new file mode 100644
index 000000000..88168383d
--- /dev/null
+++ b/client/src/mobile/screens/collections/MCollAddSheet.tsx
@@ -0,0 +1,239 @@
+import { useEffect, useState } from 'react'
+import { Check, Loader2, MapPin, Search, X } from 'lucide-react'
+import type { CollectionStatus } from '@trek/shared'
+import type { Category, TranslationFn } from '../../../types'
+import { mapsApi } from '../../../api/client'
+import { collectionsApi } from '../../../api/collections'
+import { useTranslation } from '../../../i18n'
+import { useToast } from '../../../components/shared/Toast'
+import { getApiErrorMessage } from '../../../types'
+import { STATUS_ORDER } from '../../../pages/collections/collectionsModel'
+import MSheet from '../../components/MSheet'
+import MCollCategoryPicker from './MCollCategoryPicker'
+import { STATUS_SPEC } from './collectionsMobileModel'
+import { CancelPill, Eyebrow, INPUT_CLS, PrimaryPill, SheetFooter, SheetHeader, TEXTAREA_CLS } from './MCollSheetKit'
+
+type MapsPlace = Record
+const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined)
+const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : typeof v === 'string' && v !== '' ? Number(v) : undefined)
+
+interface MCollAddSheetProps {
+ open: boolean
+ collectionId: number | null
+ collectionName: string
+ /** Pickable target lists — used when opened without a fixed collectionId. */
+ lists: { id: number; name: string; color?: string | null }[]
+ categories: Category[]
+ onClose: () => void
+ onAdded: () => void
+ t: TranslationFn
+}
+
+/**
+ * "Add a place": maps search that fills the location, then name / category /
+ * status / description before saving into the active list (duplicates are
+ * reported by the server and surfaced as a toast).
+ */
+export default function MCollAddSheet({ open, collectionId, collectionName, lists, categories, onClose, onAdded, t }: MCollAddSheetProps) {
+ const { language } = useTranslation()
+ const toast = useToast()
+ const [query, setQuery] = useState('')
+ const [results, setResults] = useState([])
+ const [searching, setSearching] = useState(false)
+ const [picked, setPicked] = useState(null)
+ const [name, setName] = useState('')
+ const [categoryId, setCategoryId] = useState(null)
+ const [status, setStatus] = useState('idea')
+ const [description, setDescription] = useState('')
+ const [saving, setSaving] = useState(false)
+ const [targetId, setTargetId] = useState(collectionId)
+
+ useEffect(() => {
+ if (open) return
+ setQuery(''); setResults([]); setPicked(null); setName(''); setCategoryId(null); setStatus('idea'); setDescription('')
+ }, [open])
+
+ // Opened from a specific list → target is fixed; from "All Saved" (no active
+ // list) → default to the sole list if there is one, else pick it in-sheet.
+ useEffect(() => {
+ if (open) setTargetId(collectionId ?? (lists.length === 1 ? lists[0].id : null))
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open, collectionId])
+
+ const search = async () => {
+ if (!query.trim() || searching) return
+ setSearching(true)
+ try {
+ const res = await mapsApi.search(query, language)
+ setResults((res.places as MapsPlace[]) || [])
+ } catch (err) {
+ toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
+ } finally {
+ setSearching(false)
+ }
+ }
+
+ const pick = (r: MapsPlace) => {
+ setPicked(r)
+ setName(str(r.name) ?? '')
+ setResults([])
+ setQuery(str(r.name) ?? query)
+ }
+
+ const save = async () => {
+ const cleanName = name.trim()
+ if (!cleanName || targetId == null || saving) return
+ setSaving(true)
+ try {
+ const res = await collectionsApi.savePlace({
+ collection_id: targetId,
+ name: cleanName,
+ address: (picked && str(picked.address)) ?? null,
+ lat: (picked && num(picked.lat)) ?? null,
+ lng: (picked && num(picked.lng)) ?? null,
+ google_place_id: (picked && str(picked.google_place_id)) ?? null,
+ google_ftid: (picked && str(picked.google_ftid)) ?? null,
+ osm_id: (picked && str(picked.osm_id)) ?? null,
+ website: (picked && str(picked.website)) ?? null,
+ phone: (picked && str(picked.phone)) ?? null,
+ category_id: categoryId,
+ description: description.trim() || null,
+ status,
+ force: true,
+ })
+ if (res.duplicate) toast.info(t('collections.duplicateWarning'))
+ else {
+ toast.success(t('collections.addedToList', { name: lists.find(l => l.id === targetId)?.name ?? collectionName }))
+ onAdded()
+ }
+ onClose()
+ } catch (err) {
+ toast.error(getApiErrorMessage(err, t('common.error')))
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const address = picked ? str(picked.address) : undefined
+
+ return (
+
+
+
+ {/* List picker — only when opened without a fixed target list */}
+ {collectionId == null && (
+
+
{t('collections.pickList').toUpperCase()}
+ {lists.length === 0 ? (
+
{t('collections.noListsYet')}
+ ) : (
+
+ {lists.map(l => {
+ const on = targetId === l.id
+ return (
+ setTargetId(l.id)}
+ aria-pressed={on}
+ className={`flex items-center gap-[6px] rounded-full px-3 py-2 text-[0.71875rem] font-bold ${
+ on ? 'bg-m-act text-m-actfg' : 'border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-ink'
+ }`}
+ >
+
+ {l.name}
+
+ )
+ })}
+
+ )}
+
+ )}
+ {/* Search — picking a result fills the location */}
+
+
+ setQuery(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); search() } }}
+ placeholder={t('collections.addPlaceSearch')}
+ className="min-w-0 flex-1 bg-transparent py-2 font-[inherit] text-[0.8125rem] text-m-ink outline-none placeholder:text-m-faint"
+ />
+
+ {searching ? : null}
+ {t('common.search')}
+
+
+ {results.length > 0 && (
+
+
+ {t('common.search').toUpperCase()}
+ setResults([])} aria-label={t('common.close')} className="text-m-faint">
+
+
+
+ {results.map((r, i) => (
+
pick(r)} className="flex w-full items-center gap-[10px] px-[13px] py-[10px] text-left">
+
+
+
+
+ {str(r.name)}
+ {str(r.address) && {str(r.address)} }
+
+
+ ))}
+
+ )}
+
+
{t('common.name').toUpperCase()}
+
setName(e.target.value)} placeholder={t('common.name')} className={INPUT_CLS} />
+ {address && (
+
+ {address}
+
+ )}
+
+
{t('collections.category').toUpperCase()}
+
+
+
{t('mobileCollections.status').toUpperCase()}
+
+ {STATUS_ORDER.map(s => {
+ const meta = STATUS_SPEC[s]
+ const Icon = meta.icon
+ const on = status === s
+ return (
+ setStatus(s)}
+ aria-pressed={on}
+ className={`flex flex-1 items-center justify-center gap-[5px] rounded-full py-2 text-[0.71875rem] font-bold ${
+ on ? 'bg-m-act text-m-actfg' : 'border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-ink'
+ }`}
+ >
+
+ {t(meta.labelKey)}
+
+ )
+ })}
+
+
+
{t('collections.description').toUpperCase()}
+
+
+ {t('common.cancel')}
+
+ {saving ? : } {t('common.add')}
+
+
+
+ )
+}
diff --git a/client/src/mobile/screens/collections/MCollCategoryPicker.tsx b/client/src/mobile/screens/collections/MCollCategoryPicker.tsx
new file mode 100644
index 000000000..ae0bc9437
--- /dev/null
+++ b/client/src/mobile/screens/collections/MCollCategoryPicker.tsx
@@ -0,0 +1,78 @@
+import { useState } from 'react'
+import { Check, ChevronDown, ChevronUp } from 'lucide-react'
+import type { Category, TranslationFn } from '../../../types'
+import { categoryMeta, NO_CATEGORY_META, tint } from './collectionsMobileModel'
+
+interface MCollCategoryPickerProps {
+ categories: Category[]
+ value: number | null
+ onChange: (id: number | null) => void
+ t: TranslationFn
+}
+
+/**
+ * Category dropdown of the add/edit-place sheets: a trigger row with the tinted
+ * 26px icon chip and an overlay list of "No category" + the admin categories.
+ */
+export default function MCollCategoryPicker({ categories, value, onChange, t }: MCollCategoryPickerProps) {
+ const [open, setOpen] = useState(false)
+
+ const selected = value != null ? categories.find(c => c.id === value) ?? null : null
+ const selectedMeta = (selected && categoryMeta(selected)) || NO_CATEGORY_META
+ const SelectedIcon = selectedMeta.icon
+ const Chevron = open ? ChevronUp : ChevronDown
+
+ const options: { id: number | null; name: string }[] = [
+ { id: null, name: t('collections.noCategory') },
+ ...categories.map(c => ({ id: c.id, name: c.name })),
+ ]
+
+ return (
+
+
setOpen(v => !v)}
+ className="flex w-full items-center gap-[9px] rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-[13px] py-[11px] text-left"
+ >
+
+
+
+
+ {selected?.name ?? t('collections.noCategory')}
+
+
+
+ {open && (
+
+ {options.map((opt, i) => {
+ const cat = opt.id != null ? categories.find(c => c.id === opt.id) ?? null : null
+ const meta = (cat && categoryMeta(cat)) || NO_CATEGORY_META
+ const Icon = meta.icon
+ const sel = value === opt.id
+ return (
+ { onChange(opt.id); setOpen(false) }}
+ className={`flex w-full items-center gap-[10px] px-[13px] py-[10px] text-left ${i > 0 ? 'border-t border-[color:var(--m-rowbr)]' : ''}`}
+ style={sel ? { background: tint(meta.color, '10') } : undefined}
+ >
+
+
+
+ {opt.name}
+ {sel && }
+
+ )
+ })}
+
+ )}
+
+ )
+}
diff --git a/client/src/mobile/screens/collections/MCollEditSheet.tsx b/client/src/mobile/screens/collections/MCollEditSheet.tsx
new file mode 100644
index 000000000..aac236e13
--- /dev/null
+++ b/client/src/mobile/screens/collections/MCollEditSheet.tsx
@@ -0,0 +1,282 @@
+import { useEffect, useRef, useState } from 'react'
+import { Image, Loader2, Search, Trash2 } from 'lucide-react'
+import type { Collection, CollectionLink } from '@trek/shared'
+import type { TranslationFn } from '../../../types'
+import { tripsApi } from '../../../api/client'
+import { useCollectionStore } from '../../../store/collectionStore'
+import { useToast } from '../../../components/shared/Toast'
+import { getApiErrorMessage } from '../../../types'
+import { normalizeLinkUrl } from '../../../pages/collections/collectionsModel'
+import MSheet from '../../components/MSheet'
+import MCollLinksEditor from './MCollLinksEditor'
+import { listCoverGradient, SWATCH_COLORS } from './collectionsMobileModel'
+import { CancelPill, PrimaryPill, SheetFooter, SheetHeader, TEXTAREA_CLS } from './MCollSheetKit'
+
+interface CoverSearchPhoto {
+ id: string
+ url: string
+ thumb: string
+ description?: string | null
+ photographer?: string | null
+}
+
+// Scrims over the cover photo / Unsplash thumbs — fixed dark overlays in both themes.
+const COVER_OVERLAY =
+ 'absolute inset-0 flex items-center justify-center gap-[6px] bg-[rgba(0,0,0,.28)] text-[0.78125rem] font-bold text-white' // theme-lint-disable
+const PHOTO_CREDIT =
+ 'absolute inset-x-0 bottom-0 truncate bg-[rgba(0,0,0,.55)] px-[6px] py-1 text-left text-[0.625rem] text-white' // theme-lint-disable
+
+interface MCollEditSheetProps {
+ /** null = closed, 'new' = create, a Collection = edit that list. */
+ target: Collection | 'new' | null
+ onClose: () => void
+ onCreated: (id: number) => void
+ /** Owner-only: hands the id to the delete-confirm flow (sheet closes first). */
+ onRequestDelete: (id: number) => void
+ t: TranslationFn
+}
+
+/**
+ * Create / edit a list: cover (upload or Unsplash search), name, colour,
+ * description and links. Deleting goes through the shared confirm flow.
+ */
+export default function MCollEditSheet({ target, onClose, onCreated, onRequestDelete, t }: MCollEditSheetProps) {
+ const createCollection = useCollectionStore(s => s.createCollection)
+ const updateCollection = useCollectionStore(s => s.updateCollection)
+ const uploadCover = useCollectionStore(s => s.uploadCover)
+ const toast = useToast()
+ const fileRef = useRef(null)
+
+ // Hold the last target through the exit animation.
+ const [held, setHeld] = useState(target)
+ if (target && target !== held) setHeld(target)
+ const editing = held && held !== 'new' ? held : null
+
+ const [name, setName] = useState('')
+ const [color, setColor] = useState(SWATCH_COLORS[0])
+ const [description, setDescription] = useState('')
+ const [links, setLinks] = useState([])
+ const [coverFile, setCoverFile] = useState(null)
+ const [coverPreview, setCoverPreview] = useState(null)
+ const [pendingUnsplashUrl, setPendingUnsplashUrl] = useState(null)
+ const [coverQuery, setCoverQuery] = useState('')
+ const [coverResults, setCoverResults] = useState([])
+ const [searchingCover, setSearchingCover] = useState(false)
+ const coverSeq = useRef(0)
+ const [saving, setSaving] = useState(false)
+ // A create that failed at the cover step keeps its id so a retry updates it.
+ const [createdId, setCreatedId] = useState(null)
+ const objectUrl = useRef(null)
+
+ const dropObjectUrl = () => {
+ if (objectUrl.current) { URL.revokeObjectURL(objectUrl.current); objectUrl.current = null }
+ }
+
+ // (Re)seed the form whenever the sheet opens on a target.
+ useEffect(() => {
+ if (!target) return
+ const edit = target !== 'new' ? target : null
+ setName(edit?.name ?? '')
+ setColor(edit?.color ?? SWATCH_COLORS[0])
+ setDescription(edit?.description ?? '')
+ setLinks(edit?.links ?? [])
+ setCoverFile(null)
+ dropObjectUrl()
+ setCoverPreview(edit?.cover_image ?? null)
+ setPendingUnsplashUrl(null)
+ setCoverQuery('')
+ setCoverResults([])
+ setCreatedId(null)
+ }, [target])
+
+ useEffect(() => () => dropObjectUrl(), [])
+
+ const pickCover = (file: File | undefined) => {
+ if (!file) return
+ dropObjectUrl()
+ const url = URL.createObjectURL(file)
+ objectUrl.current = url
+ setCoverFile(file)
+ setCoverPreview(url)
+ setPendingUnsplashUrl(null)
+ }
+
+ const searchCover = async () => {
+ const query = coverQuery.trim() || name.trim()
+ if (!query) return
+ const seq = ++coverSeq.current
+ setSearchingCover(true)
+ try {
+ const data = await tripsApi.searchCoverImages(query)
+ if (seq !== coverSeq.current) return
+ setCoverResults(data.photos || [])
+ } catch {
+ if (seq === coverSeq.current) setCoverResults([])
+ } finally {
+ if (seq === coverSeq.current) setSearchingCover(false)
+ }
+ }
+
+ const pickUnsplash = (photo: CoverSearchPhoto) => {
+ if (!photo.url) return
+ dropObjectUrl()
+ setCoverFile(null)
+ setPendingUnsplashUrl(photo.url)
+ setCoverPreview(photo.url)
+ }
+
+ const save = async () => {
+ const trimmed = name.trim()
+ if (!trimmed || saving) return
+ const cleanLinks = links
+ .map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) }))
+ .filter(l => l.url)
+ const payload = {
+ name: trimmed,
+ color,
+ description: description.trim() || null,
+ links: cleanLinks,
+ ...(pendingUnsplashUrl ? { cover_image: pendingUnsplashUrl } : {}),
+ }
+ setSaving(true)
+ try {
+ let id = editing?.id ?? createdId
+ if (id != null) {
+ await updateCollection(id, payload)
+ } else {
+ const created = await createCollection(payload)
+ id = created?.id ?? null
+ setCreatedId(id)
+ }
+ if (id != null && coverFile) await uploadCover(id, coverFile)
+ if (!editing && id != null) onCreated(id)
+ onClose()
+ } catch (err) {
+ toast.error(getApiErrorMessage(err, t('common.error')))
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const label = 'mb-[5px] font-geist text-[0.6875rem] font-bold text-m-muted'
+ const canDeleteList = editing != null && editing.is_owner !== false
+
+ return (
+
+
+
+
{t('collections.coverImage')}
+
fileRef.current?.click()}
+ className="relative block h-[120px] w-full overflow-hidden rounded-[14px]"
+ style={coverPreview ? undefined : { background: listCoverGradient(color) }}
+ >
+ {coverPreview && }
+
+ {coverPreview ? t('collections.changeCover') : t('collections.addCover')}
+
+
+
pickCover(e.target.files?.[0])}
+ />
+
+ setCoverQuery(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); searchCover() } }}
+ placeholder={t('dashboard.unsplashSearchPlaceholder')}
+ className="min-w-0 flex-1 rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-3 py-[10px] font-geist text-[0.71875rem] text-m-ink outline-none placeholder:text-m-faint"
+ />
+
+ {searchingCover ? : } Unsplash
+
+
+ {coverResults.length > 0 && (
+
+ {coverResults.map(photo => (
+
pickUnsplash(photo)}
+ aria-label={photo.photographer || 'Unsplash'}
+ className="relative h-20 overflow-hidden rounded-[10px] border border-[color:var(--m-rowbr)]"
+ style={coverPreview === photo.url ? { boxShadow: 'inset 0 0 0 2px var(--m-act)' } : undefined}
+ >
+
+ {photo.photographer && (
+
+ {photo.photographer}
+
+ )}
+
+ ))}
+
+ )}
+
+
{t('collections.listName')}
+
setName(e.target.value)}
+ placeholder={t('collections.listNamePlaceholder')}
+ className="w-full box-border rounded-[12px] border-[1.5px] border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-[13px] py-[11px] font-[inherit] text-[0.84375rem] font-semibold text-m-ink outline-none placeholder:text-m-faint"
+ />
+
+
{t('collections.listColor')}
+
+ {SWATCH_COLORS.map(col => (
+ setColor(col)}
+ aria-label={col}
+ aria-pressed={color === col}
+ className={`h-[26px] w-[26px] rounded-full ${color === col ? 'outline outline-2 outline-offset-2 outline-[color:var(--m-act)]' : ''}`}
+ style={{ background: col }}
+ />
+ ))}
+
+
+
{t('collections.description')}
+
+
+ {canDeleteList && (
+ { onClose(); onRequestDelete(editing!.id) }}
+ className="flex items-center gap-[5px] text-[0.75rem] font-bold text-[color:var(--m-st-danger)]"
+ >
+ {t('collections.deleteList')}
+
+ )}
+ {t('common.cancel')}
+
+ {saving && }
+ {editing ? t('common.save') : t('collections.create')}
+
+
+
+ )
+}
diff --git a/client/src/mobile/screens/collections/MCollLabelsSheet.tsx b/client/src/mobile/screens/collections/MCollLabelsSheet.tsx
new file mode 100644
index 000000000..8032237e5
--- /dev/null
+++ b/client/src/mobile/screens/collections/MCollLabelsSheet.tsx
@@ -0,0 +1,191 @@
+import { useEffect, useState } from 'react'
+import { Check, Pencil, Plus, Tag, Trash2 } from 'lucide-react'
+import type { CollectionLabelUpdateRequest } from '@trek/shared'
+import type { TranslationFn } from '../../../types'
+import type { LabelOption } from '../../../pages/collections/collectionsModel'
+import MSheet from '../../components/MSheet'
+import { SWATCH_COLORS } from './collectionsMobileModel'
+import { Eyebrow, INPUT_CLS, PrimaryPill, SheetFooter, SheetHeader } from './MCollSheetKit'
+
+export type MCollLabelsMode = 'manage' | 'assign'
+
+interface MCollLabelsSheetProps {
+ open: boolean
+ mode: MCollLabelsMode
+ labels: LabelOption[]
+ /** Selection size, for the assign title. */
+ selectedCount: number
+ onCreate: (name: string, color?: string) => Promise
+ onUpdate: (labelId: number, body: CollectionLabelUpdateRequest) => Promise
+ onDelete: (labelId: number) => Promise
+ onAssign: (labelIds: number[]) => Promise
+ onSwitchToManage: () => void
+ onClose: () => void
+ t: TranslationFn
+}
+
+/**
+ * The list's custom labels. Manage mode creates / renames / recolours /
+ * deletes labels; assign mode toggles labels onto the current selection.
+ */
+export default function MCollLabelsSheet({
+ open, mode, labels, selectedCount, onCreate, onUpdate, onDelete, onAssign, onSwitchToManage, onClose, t,
+}: MCollLabelsSheetProps) {
+ const [name, setName] = useState('')
+ const [color, setColor] = useState(SWATCH_COLORS[0])
+ const [editingId, setEditingId] = useState(null)
+ const [checked, setChecked] = useState([])
+ const [busy, setBusy] = useState(false)
+
+ useEffect(() => {
+ if (open) return
+ setName('')
+ setColor(SWATCH_COLORS[0])
+ setEditingId(null)
+ setChecked([])
+ }, [open])
+
+ const startEdit = (label: LabelOption) => {
+ setEditingId(label.id)
+ setName(label.name)
+ setColor(label.color || SWATCH_COLORS[0])
+ }
+
+ const submit = async () => {
+ const trimmed = name.trim()
+ if (!trimmed || busy) return
+ setBusy(true)
+ try {
+ if (editingId != null) await onUpdate(editingId, { name: trimmed, color })
+ else await onCreate(trimmed, color)
+ setName('')
+ setColor(SWATCH_COLORS[0])
+ setEditingId(null)
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const assign = async () => {
+ if (checked.length === 0 || busy) return
+ setBusy(true)
+ try {
+ await onAssign(checked)
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const toggleChecked = (id: number) =>
+ setChecked(checked.includes(id) ? checked.filter(x => x !== id) : [...checked, id])
+
+ const title = mode === 'assign' ? t('collections.labels.assignN', { count: selectedCount }) : t('collections.labels.manage')
+
+ return (
+
+
+
+ {mode === 'manage' && (
+ <>
+
{t('collections.labels.add').toUpperCase()}
+
setName(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter') submit() }}
+ placeholder={t('collections.labels.namePlaceholder')}
+ className={INPUT_CLS}
+ />
+
+ {SWATCH_COLORS.map(col => (
+ setColor(col)}
+ aria-label={col}
+ aria-pressed={color === col}
+ className={`h-7 w-7 rounded-full ${color === col ? 'outline outline-2 outline-offset-2 outline-[color:var(--m-act)]' : ''}`}
+ style={{ background: col }}
+ />
+ ))}
+
+
+ {editingId != null ? : }
+ {editingId != null ? t('common.save') : t('collections.labels.add')}
+
+
+ >
+ )}
+
+ {labels.length === 0 ? (
+
+
+
+
+
{t('collections.labels.empty')}
+ {mode === 'assign' && (
+
{t('collections.labels.emptyHint')}
+ )}
+
+ ) : (
+
+ {labels.map(label => {
+ const col = label.color || SWATCH_COLORS[0]
+ const on = mode === 'assign' && checked.includes(label.id)
+ return (
+
toggleChecked(label.id) : undefined}
+ role={mode === 'assign' ? 'checkbox' : undefined}
+ aria-checked={mode === 'assign' ? on : undefined}
+ >
+
+
{label.name}
+
{label.count}
+ {mode === 'manage' ? (
+ <>
+
startEdit(label)}
+ aria-label={t('common.edit')}
+ className="flex h-7 w-7 flex-none items-center justify-center rounded-full text-m-muted active:bg-[color:var(--m-ic)]"
+ >
+
+
+
onDelete(label.id)}
+ aria-label={t('common.delete')}
+ className="flex h-7 w-7 flex-none items-center justify-center rounded-full text-[color:var(--m-st-danger)] active:bg-[color:var(--m-ic)]"
+ >
+
+
+ >
+ ) : (
+ on &&
+ )}
+
+ )
+ })}
+
+ )}
+
+ {mode === 'assign' && (
+
+
+ {t('collections.labels.manage')}
+
+
+ {t('collections.labels.assign')}
+
+
+ )}
+
+ )
+}
diff --git a/client/src/mobile/screens/collections/MCollLinksEditor.tsx b/client/src/mobile/screens/collections/MCollLinksEditor.tsx
new file mode 100644
index 000000000..ccf74c4f3
--- /dev/null
+++ b/client/src/mobile/screens/collections/MCollLinksEditor.tsx
@@ -0,0 +1,54 @@
+import { Link2, Plus, Trash2 } from 'lucide-react'
+import type { CollectionLink } from '@trek/shared'
+import type { TranslationFn } from '../../../types'
+
+interface MCollLinksEditorProps {
+ links: CollectionLink[]
+ onChange: (links: CollectionLink[]) => void
+ t: TranslationFn
+}
+
+/** Label + URL rows with a dashed add-row, for list and place links. */
+export default function MCollLinksEditor({ links, onChange, t }: MCollLinksEditorProps) {
+ const setLink = (i: number, patch: Partial) =>
+ onChange(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
+
+ const rowInput =
+ 'box-border rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-[11px] py-[9px] font-[inherit] text-[0.78125rem] text-m-ink outline-none placeholder:text-m-faint'
+
+ return (
+
+ )
+}
diff --git a/client/src/mobile/screens/collections/MCollListPickerSheet.tsx b/client/src/mobile/screens/collections/MCollListPickerSheet.tsx
new file mode 100644
index 000000000..b006818fa
--- /dev/null
+++ b/client/src/mobile/screens/collections/MCollListPickerSheet.tsx
@@ -0,0 +1,66 @@
+import { useState } from 'react'
+import { Loader2 } from 'lucide-react'
+import type { Collection } from '@trek/shared'
+import type { TranslationFn } from '../../../types'
+import MSheet from '../../components/MSheet'
+import { SheetHeader } from './MCollSheetKit'
+
+export type MCollListPickerMode = 'move' | 'copy'
+
+interface MCollListPickerSheetProps {
+ /** null = closed; kept mounted for the exit animation. */
+ mode: MCollListPickerMode | null
+ lists: Collection[]
+ count: number
+ onPick: (targetId: number) => Promise
+ onClose: () => void
+ t: TranslationFn
+}
+
+/** Target-list picker for moving / duplicating the selection into another list. */
+export default function MCollListPickerSheet({ mode, lists, count, onPick, onClose, t }: MCollListPickerSheetProps) {
+ const [held, setHeld] = useState(mode)
+ if (mode && mode !== held) setHeld(mode)
+ const [busyId, setBusyId] = useState(null)
+
+ const title = held === 'move'
+ ? t('collections.moveToListTitle', { count })
+ : t('collections.duplicateToListTitle', { count })
+
+ const pick = async (id: number) => {
+ if (busyId != null) return
+ setBusyId(id)
+ try {
+ await onPick(id)
+ } finally {
+ setBusyId(null)
+ }
+ }
+
+ return (
+
+
+
+ {lists.length === 0 ? (
+
{t('collections.noOtherLists')}
+ ) : (
+ lists.map(list => (
+
pick(list.id)}
+ disabled={busyId != null}
+ className="mb-1 flex w-full items-center gap-[10px] rounded-[13px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-3 py-[11px] text-left disabled:opacity-60"
+ >
+
+ {list.name}
+ {busyId === list.id
+ ?
+ : {list.place_count ?? 0} }
+
+ ))
+ )}
+
+
+ )
+}
diff --git a/client/src/mobile/screens/collections/MCollListsDrawer.tsx b/client/src/mobile/screens/collections/MCollListsDrawer.tsx
new file mode 100644
index 000000000..23a10c383
--- /dev/null
+++ b/client/src/mobile/screens/collections/MCollListsDrawer.tsx
@@ -0,0 +1,125 @@
+import { Check, Layers, Plus, X } from 'lucide-react'
+import type { Collection } from '@trek/shared'
+import type { TranslationFn } from '../../../types'
+import type { ActiveCollectionId } from '../../../store/collectionStore'
+import type { IncomingCollectionInvite } from '../../../store/collectionStore'
+import { ALL_SAVED } from '../../../store/collectionStore'
+import MSheet from '../../components/MSheet'
+import { Eyebrow } from './MCollSheetKit'
+
+interface MCollListsDrawerProps {
+ open: boolean
+ onClose: () => void
+ ownedLists: Collection[]
+ sharedLists: Collection[]
+ activeId: ActiveCollectionId
+ incomingInvites: IncomingCollectionInvite[]
+ onSelect: (id: ActiveCollectionId) => void
+ onNewList: () => void
+ onAcceptInvite: (collectionId: number) => void
+ onDeclineInvite: (collectionId: number) => void
+ t: TranslationFn
+}
+
+function ListRow({ list, active, onClick }: { list: Collection; active: boolean; onClick: () => void }) {
+ return (
+
+
+ {list.name}
+ {list.place_count ?? 0}
+
+ )
+}
+
+/**
+ * The left lists drawer: new list, the "All saved" union, the owned and shared
+ * lists, and — when present — incoming share invites with accept/decline.
+ */
+export default function MCollListsDrawer({
+ open, onClose, ownedLists, sharedLists, activeId, incomingInvites,
+ onSelect, onNewList, onAcceptInvite, onDeclineInvite, t,
+}: MCollListsDrawerProps) {
+ return (
+
+
+
+
+
+
+
+
+ {t('collections.newList')}
+
+
onSelect(ALL_SAVED)}
+ className={`mt-[10px] flex flex-none items-center gap-[9px] rounded-[11px] px-[6px] py-[10px] text-[0.84375rem] font-semibold text-m-ink ${activeId === ALL_SAVED ? 'bg-[color:var(--m-ic)]' : ''}`}
+ >
+ {t('collections.allSaved')}
+
+
+
+ {ownedLists.map(l => (
+
onSelect(l.id)} />
+ ))}
+ {sharedLists.length > 0 && (
+ <>
+ {t('collections.shared')}
+ {sharedLists.map(l => (
+ onSelect(l.id)} />
+ ))}
+ >
+ )}
+ {incomingInvites.length > 0 && (
+ <>
+ {t('collections.invites.title')}
+ {incomingInvites.map(inv => (
+
+
+ {inv.name}
+
+ {t('collections.invites.from')} {inv.from.username}
+
+
+ onAcceptInvite(inv.collection_id)}
+ aria-label={t('collections.invites.accept')}
+ className="flex h-7 w-7 flex-none items-center justify-center rounded-full bg-m-act text-m-actfg"
+ >
+
+
+ onDeclineInvite(inv.collection_id)}
+ aria-label={t('collections.invites.decline')}
+ className="flex h-7 w-7 flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-ink"
+ >
+
+
+
+ ))}
+ >
+ )}
+
+
+
+ )
+}
diff --git a/client/src/mobile/screens/collections/MCollPlaceRow.tsx b/client/src/mobile/screens/collections/MCollPlaceRow.tsx
new file mode 100644
index 000000000..5a644b0d3
--- /dev/null
+++ b/client/src/mobile/screens/collections/MCollPlaceRow.tsx
@@ -0,0 +1,82 @@
+import { Check, MapPin } from 'lucide-react'
+import type { CollectionPlace, CollectionStatus } from '@trek/shared'
+import type { TranslationFn } from '../../../types'
+import { nextStatus } from '../../../pages/collections/collectionsModel'
+import { categoryMeta, STATUS_SPEC, tint, UNCATEGORIZED_META } from './collectionsMobileModel'
+
+interface MCollPlaceRowProps {
+ place: CollectionPlace
+ selectMode: boolean
+ selected: boolean
+ canEdit: boolean
+ onOpen: (id: number) => void
+ onToggleSelect: (id: number) => void
+ onSetStatus: (id: number, status: CollectionStatus) => void
+ t: TranslationFn
+}
+
+/**
+ * A saved place: the main card (tinted category tile, name, address) next to
+ * the status column with the status pill (tap = cycle Idea → Want to go →
+ * Visited) and the category pill. In select mode a tap toggles the selection.
+ */
+export default function MCollPlaceRow({
+ place, selectMode, selected, canEdit, onOpen, onToggleSelect, onSetStatus, t,
+}: MCollPlaceRowProps) {
+ const cat = categoryMeta(place.category)
+ const meta = cat ?? UNCATEGORIZED_META
+ const TileIcon = meta.icon
+ const status = STATUS_SPEC[place.status]
+ const StatusIcon = status.icon
+
+ const pill =
+ 'inline-flex w-[78px] box-border items-center justify-center gap-1 truncate rounded-full py-[5px] font-geist text-[0.5625rem] font-extrabold'
+
+ return (
+
+
(selectMode ? onToggleSelect(place.id) : onOpen(place.id))}
+ aria-pressed={selectMode ? selected : undefined}
+ className="flex min-w-0 flex-1 items-center gap-[11px] rounded-2xl border border-[color:var(--m-rowbr)] bg-m-sheetop px-3 py-[11px] text-left"
+ style={selected ? { boxShadow: 'inset 0 0 0 1.5px var(--m-act)' } : undefined}
+ >
+
+ {selected ? : }
+
+
+ {place.name}
+ {place.address && (
+
+
+ {place.address}
+
+ )}
+
+
+
+
+ onSetStatus(place.id, nextStatus(place.status))}
+ aria-label={t(status.labelKey)}
+ className={pill}
+ style={{ background: tint(status.color, '18'), color: status.color }}
+ >
+
+ {t(status.labelKey)}
+
+ {cat && place.category?.name && (
+
+
+ {place.category.name}
+
+ )}
+
+
+ )
+}
diff --git a/client/src/mobile/screens/collections/MCollPlaceSheet.tsx b/client/src/mobile/screens/collections/MCollPlaceSheet.tsx
new file mode 100644
index 000000000..f08add532
--- /dev/null
+++ b/client/src/mobile/screens/collections/MCollPlaceSheet.tsx
@@ -0,0 +1,289 @@
+import { useEffect, useRef, useState } from 'react'
+import Markdown from 'react-markdown'
+import remarkGfm from 'remark-gfm'
+import remarkBreaks from 'remark-breaks'
+import { Check, Copy, ExternalLink, MapPin, Pencil, Trash2, X } from 'lucide-react'
+import type { CollectionLabel, CollectionLink, CollectionPlace, CollectionStatus } from '@trek/shared'
+import type { Category, TranslationFn } from '../../../types'
+import { mapsApi } from '../../../api/client'
+import { useToast } from '../../../components/shared/Toast'
+import { getApiErrorMessage } from '../../../types'
+import { normalizeLinkUrl, STATUS_ORDER } from '../../../pages/collections/collectionsModel'
+import MSheet from '../../components/MSheet'
+import MCollCategoryPicker from './MCollCategoryPicker'
+import MCollLinksEditor from './MCollLinksEditor'
+import { STATUS_SPEC } from './collectionsMobileModel'
+import { CancelPill, Eyebrow, INPUT_CLS, PrimaryPill, TEXTAREA_CLS } from './MCollSheetKit'
+
+function linkHost(url: string): string {
+ try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url }
+}
+
+// Hero chrome sits on a photo/gradient — fixed white/black scrims in both themes.
+const HERO_CAT_CHIP =
+ 'relative inline-flex items-center gap-1 rounded-full bg-[rgba(255,255,255,.9)] px-[10px] py-[3px] font-geist text-[0.59375rem] font-extrabold text-[#101013]' // theme-lint-disable
+const HERO_CLOSE =
+ 'absolute right-[14px] top-[14px] z-[1] flex h-8 w-8 items-center justify-center rounded-full bg-[rgba(0,0,0,.28)] text-white' // theme-lint-disable
+
+interface MCollPlaceSheetProps {
+ place: CollectionPlace | null
+ canEdit: boolean
+ canDelete: boolean
+ categories: Category[]
+ labels: CollectionLabel[]
+ onClose: () => void
+ onSetStatus: (status: CollectionStatus) => void
+ onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null; label_ids?: number[] }) => Promise
+ onCopyToTrip: () => void
+ onRemove: () => void
+ t: TranslationFn
+}
+
+/**
+ * Saved-place detail: auto-cover hero with the category chip, the live status
+ * cycle (Idea → Want to go → Visited), markdown description, links, copy to
+ * trip and remove. Edit mode swaps the body for name / category / labels /
+ * description / links.
+ */
+export default function MCollPlaceSheet({
+ place, canEdit, canDelete, categories, labels, onClose, onSetStatus, onSave, onCopyToTrip, onRemove, t,
+}: MCollPlaceSheetProps) {
+ const toast = useToast()
+ // Hold the last place through the exit animation.
+ const [held, setHeld] = useState(place)
+ if (place && place !== held) setHeld(place)
+
+ const [editing, setEditing] = useState(false)
+ const [name, setName] = useState('')
+ const [categoryId, setCategoryId] = useState(null)
+ const [description, setDescription] = useState('')
+ const [links, setLinks] = useState([])
+ const [labelIds, setLabelIds] = useState([])
+ const [saving, setSaving] = useState(false)
+ const [fetchedPhoto, setFetchedPhoto] = useState(null)
+ const heldId = held?.id
+
+ // Reseed the form + cover fetch when a different place is opened.
+ const seededId = useRef(null)
+ useEffect(() => {
+ if (!held || seededId.current === held.id) return
+ seededId.current = held.id
+ setEditing(false)
+ setName(held.name)
+ setCategoryId(held.category_id ?? null)
+ setDescription(held.description ?? '')
+ setLinks(held.links ?? [])
+ setLabelIds(held.label_ids ?? [])
+ setFetchedPhoto(null)
+ if (held.image_url) return
+ const photoId = held.google_place_id || held.osm_id || (held.lat != null && held.lng != null ? `${held.lat},${held.lng}` : null)
+ if (!photoId) return
+ let cancelled = false
+ mapsApi.placePhoto(photoId, held.lat ?? undefined, held.lng ?? undefined, held.name)
+ .then(res => { if (!cancelled && res?.photoUrl) setFetchedPhoto(res.photoUrl) })
+ .catch(() => {})
+ return () => { cancelled = true }
+ }, [held, heldId])
+
+ const save = async () => {
+ if (!held) return
+ const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url)
+ setSaving(true)
+ try {
+ await onSave({ name: name.trim() || held.name, description: description.trim() || null, links: cleanLinks, category_id: categoryId, label_ids: labelIds })
+ setEditing(false)
+ } catch (err) {
+ toast.error(getApiErrorMessage(err, t('common.error')))
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const cancelEdit = () => {
+ if (!held) return
+ setEditing(false)
+ setName(held.name)
+ setCategoryId(held.category_id ?? null)
+ setDescription(held.description ?? '')
+ setLinks(held.links ?? [])
+ setLabelIds(held.label_ids ?? [])
+ }
+
+ const cover = held?.image_url || fetchedPhoto
+ const assignedLabels = labels.filter(l => (held?.label_ids ?? []).includes(l.id))
+ const toggleLabel = (id: number) => setLabelIds(labelIds.includes(id) ? labelIds.filter(x => x !== id) : [...labelIds, id])
+
+ const actionBtn =
+ 'flex flex-1 items-center justify-center gap-[6px] rounded-[13px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-2 py-[11px] text-[0.78125rem] font-semibold text-m-ink'
+
+ return (
+
+ {held && (
+ <>
+ {/* Hero: auto cover (photo when available, the design gradient otherwise) */}
+
+ {cover && (
+ <>
+
+
+ >
+ )}
+ {held.category?.name && (
+
+ {held.category.name}
+
+ )}
+
+
+
+
{held.name}
+
+
+
+ {!editing && held.address && (
+
+ {held.address}
+
+ )}
+
+ {/* Status cycle */}
+
+ {STATUS_ORDER.map(s => {
+ const meta = STATUS_SPEC[s]
+ const Icon = meta.icon
+ const on = held.status === s
+ return (
+ onSetStatus(s)}
+ aria-pressed={on}
+ className={`flex flex-1 items-center justify-center gap-[5px] rounded-[12px] py-[9px] text-[0.71875rem] font-bold ${
+ on ? 'bg-m-act text-m-actfg' : 'bg-m-sheetop text-m-ink shadow-[inset_0_0_0_1.5px_var(--m-rowbr)]'
+ }`}
+ >
+
+ {t(meta.labelKey)}
+
+ )
+ })}
+
+
+ {editing ? (
+ <>
+
{t('common.name').toUpperCase()}
+
setName(e.target.value)} className={INPUT_CLS} />
+
{t('collections.category').toUpperCase()}
+
+ {labels.length > 0 && (
+ <>
+
{t('collections.labels.title').toUpperCase()}
+
+ {labels.map(l => {
+ const on = labelIds.includes(l.id)
+ const color = l.color || '#6366F1'
+ return (
+ toggleLabel(l.id)}
+ aria-pressed={on}
+ className="flex items-center gap-[5px] rounded-full border px-3 py-[7px] text-[0.71875rem] font-bold"
+ style={on
+ ? { background: `${color}18`, color, borderColor: `${color}2e` }
+ : { background: 'var(--m-ic)', color: 'var(--m-ink)', borderColor: 'var(--m-rowbr)' }}
+ >
+
+ {l.name}
+
+ )
+ })}
+
+ >
+ )}
+
{t('collections.description').toUpperCase()}
+
+ >
+ )}
+
+ )
+}
diff --git a/client/src/mobile/screens/collections/MCollShareSheet.tsx b/client/src/mobile/screens/collections/MCollShareSheet.tsx
new file mode 100644
index 000000000..66d23fdd0
--- /dev/null
+++ b/client/src/mobile/screens/collections/MCollShareSheet.tsx
@@ -0,0 +1,289 @@
+import { useEffect, useMemo, useState } from 'react'
+import { Check, ChevronDown, Clock, Crown, Loader2, LogOut, UserPlus, X } from 'lucide-react'
+import type { CollectionMember, CollectionRole } from '@trek/shared'
+import { COLLECTION_ROLES } from '@trek/shared'
+import type { TranslationFn } from '../../../types'
+import { collectionsApi } from '../../../api/collections'
+import { useCollectionStore } from '../../../store/collectionStore'
+import { useAuthStore } from '../../../store/authStore'
+import { useToast } from '../../../components/shared/Toast'
+import { getApiErrorMessage } from '../../../utils/apiError'
+import { avatarSrc } from '../../../utils/avatarSrc'
+import MSheet from '../../components/MSheet'
+import { CancelPill, Eyebrow, PrimaryPill, SheetHeader } from './MCollSheetKit'
+
+interface MCollShareSheetProps {
+ open: boolean
+ collectionId: number | null
+ collectionName: string
+ isOwner: boolean
+ members: CollectionMember[]
+ onClose: () => void
+ onAfterLeave: () => void
+ t: TranslationFn
+}
+
+// Owner-crown amber of the design — identical in both themes.
+const OWNER_BADGE_CLS = 'text-[#D98324]' // theme-lint-disable
+
+function MemberAvatar({ member }: { member: CollectionMember }) {
+ const src = member.avatar ? avatarSrc(member.avatar) : null
+ return (
+
+ {src ? : (member.username || '?').charAt(0).toUpperCase()}
+
+ )
+}
+
+/**
+ * Fusion sharing for the active list. The owner sees the roster with per-member
+ * roles (Viewer / Editor / Admin), can cancel pending invites, remove members
+ * and invite from the available users. A member sees the roster read-only plus
+ * "Leave list" (with confirm).
+ */
+export default function MCollShareSheet({
+ open, collectionId, collectionName, isOwner, members, onClose, onAfterLeave, t,
+}: MCollShareSheetProps) {
+ const toast = useToast()
+ const currentUserId = useAuthStore(s => s.user?.id)
+ const invite = useCollectionStore(s => s.invite)
+ const cancelInvite = useCollectionStore(s => s.cancelInvite)
+ const removeMember = useCollectionStore(s => s.removeMember)
+ const setMemberRole = useCollectionStore(s => s.setMemberRole)
+ const leave = useCollectionStore(s => s.leave)
+
+ const [availableUsers, setAvailableUsers] = useState<{ id: number; username: string }[]>([])
+ const [userPickerOpen, setUserPickerOpen] = useState(false)
+ const [selectedUserId, setSelectedUserId] = useState(null)
+ const [inviteRole, setInviteRole] = useState('editor')
+ const [busyUserId, setBusyUserId] = useState(null)
+ const [inviting, setInviting] = useState(false)
+ const [confirmLeave, setConfirmLeave] = useState(false)
+ const [leaving, setLeaving] = useState(false)
+
+ useEffect(() => {
+ if (!open || !isOwner || collectionId == null) return
+ let cancelled = false
+ collectionsApi.availableUsers(collectionId)
+ .then(data => { if (!cancelled) setAvailableUsers(data.users) })
+ .catch(() => { if (!cancelled) setAvailableUsers([]) })
+ return () => { cancelled = true }
+ }, [open, isOwner, collectionId, members.length])
+
+ useEffect(() => {
+ if (open) return
+ setSelectedUserId(null)
+ setUserPickerOpen(false)
+ setConfirmLeave(false)
+ }, [open])
+
+ const sortedMembers = useMemo(() => {
+ const rank = (m: CollectionMember) => (m.is_owner ? 0 : m.status === 'accepted' ? 1 : 2)
+ return [...members].sort((a, b) => rank(a) - rank(b) || a.username.localeCompare(b.username))
+ }, [members])
+
+ const run = async (userId: number, action: () => Promise) => {
+ if (busyUserId != null) return
+ setBusyUserId(userId)
+ try {
+ await action()
+ } catch (err) {
+ toast.error(getApiErrorMessage(err, t('common.error')))
+ } finally {
+ setBusyUserId(null)
+ }
+ }
+
+ const cycleRole = (member: CollectionMember) => {
+ if (collectionId == null) return
+ const current = member.role ?? 'editor'
+ const next = COLLECTION_ROLES[(COLLECTION_ROLES.indexOf(current) + 1) % COLLECTION_ROLES.length]
+ run(member.user_id, () => setMemberRole(collectionId, member.user_id, next))
+ }
+
+ const handleInvite = async () => {
+ if (collectionId == null || selectedUserId == null || inviting) return
+ setInviting(true)
+ try {
+ await invite(collectionId, selectedUserId, inviteRole)
+ toast.success(t('collections.invite.sent'))
+ setSelectedUserId(null)
+ } catch (err) {
+ toast.error(getApiErrorMessage(err, t('collections.invite.error')))
+ } finally {
+ setInviting(false)
+ }
+ }
+
+ const handleLeave = async () => {
+ if (collectionId == null || leaving) return
+ setLeaving(true)
+ try {
+ await leave(collectionId)
+ toast.success(t('collections.share.left'))
+ onAfterLeave()
+ } catch (err) {
+ toast.error(getApiErrorMessage(err, t('common.error')))
+ } finally {
+ setLeaving(false)
+ }
+ }
+
+ const selectedUser = availableUsers.find(u => u.id === selectedUserId) ?? null
+ const badge = 'inline-flex flex-none items-center gap-[3px] font-geist text-[0.625rem] font-extrabold'
+
+ return (
+
+
+
+
+ {t('collections.share.members').toUpperCase()}
+
+ {sortedMembers.length}
+
+
+ {sortedMembers.map(member => {
+ const isSelf = member.user_id === currentUserId
+ const pending = member.status === 'pending'
+ const busy = busyUserId === member.user_id
+ return (
+
+
+
+
+ {member.username}
+ {isSelf && ({t('collections.share.you')}) }
+
+ {member.email && !pending &&
{member.email}
}
+
+ {member.is_owner ? (
+
+ {t('collections.share.owner').toUpperCase()}
+
+ ) : pending ? (
+
+ {t('collections.share.pending').toUpperCase()}
+
+ ) : isOwner ? (
+
cycleRole(member)}
+ disabled={busy}
+ aria-label={t('collections.role.label')}
+ className="flex flex-none items-center gap-1 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[10px] py-[5px] font-geist text-[0.625rem] font-extrabold text-m-ink disabled:opacity-50"
+ >
+ {busy ? : t(`collections.role.${member.role ?? 'editor'}`).toUpperCase()}
+
+
+ ) : (
+
{t(`collections.role.${member.role ?? 'editor'}`).toUpperCase()}
+ )}
+ {isOwner && !member.is_owner && collectionId != null && (
+
run(member.user_id, () =>
+ pending ? cancelInvite(collectionId, member.user_id) : removeMember(collectionId, member.user_id),
+ )}
+ disabled={busy}
+ aria-label={pending ? t('collections.share.cancel') : t('collections.share.remove')}
+ className="flex h-7 w-7 flex-none items-center justify-center rounded-full text-m-faint active:bg-[color:var(--m-ic)] disabled:opacity-50"
+ >
+
+
+ )}
+
+ )
+ })}
+
+ {isOwner ? (
+ <>
+
{t('collections.share.invite').toUpperCase()}
+ {availableUsers.length === 0 ? (
+
{t('collections.share.noUsers')}
+ ) : (
+ <>
+
+
setUserPickerOpen(v => !v)}
+ className="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-[13px] py-[11px] text-left text-[0.78125rem]"
+ >
+
+ {selectedUser?.username ?? t('collections.share.inviteUser')}
+
+
+
+
+ {inviting ? : }
+
+ {userPickerOpen && (
+
+ {availableUsers.map(u => (
+ { setSelectedUserId(u.id); setUserPickerOpen(false) }}
+ className="flex w-full items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[13px] py-[10px] text-left text-[0.8125rem] font-semibold text-m-ink first:border-t-0"
+ >
+ {u.username}
+ {selectedUserId === u.id && }
+
+ ))}
+
+ )}
+
+
+ {COLLECTION_ROLES.map(role => (
+ setInviteRole(role)}
+ aria-pressed={inviteRole === role}
+ className={`flex-1 rounded-full py-[7px] text-[0.71875rem] font-bold ${
+ inviteRole === role ? 'bg-m-act text-m-actfg' : 'border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-ink'
+ }`}
+ >
+ {t(`collections.role.${role}`)}
+
+ ))}
+
+ >
+ )}
+ >
+ ) : (
+
+
{t('collections.share.memberHint')}
+ {confirmLeave ? (
+ <>
+
{t('collections.share.leaveConfirm')}
+
+
setConfirmLeave(false)}>{t('common.cancel')}
+
+ {leaving ? : } {t('collections.share.leave')}
+
+
+ >
+ ) : (
+
setConfirmLeave(true)}
+ className="flex w-full items-center justify-center gap-[6px] rounded-[13px] border border-[color:var(--m-rowbr)] p-[11px] text-[0.78125rem] font-bold text-[color:var(--m-st-danger)]"
+ >
+ {t('collections.share.leave')}
+
+ )}
+
+ )}
+
+
+ )
+}
diff --git a/client/src/mobile/screens/collections/MCollSheetKit.tsx b/client/src/mobile/screens/collections/MCollSheetKit.tsx
new file mode 100644
index 000000000..272f0c09b
--- /dev/null
+++ b/client/src/mobile/screens/collections/MCollSheetKit.tsx
@@ -0,0 +1,86 @@
+import { ReactNode } from 'react'
+import { X } from 'lucide-react'
+
+/** Shared sheet scaffolding of the collections sheets (opaque floating cards). */
+
+export const INPUT_CLS =
+ 'w-full box-border rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-[13px] py-[11px] font-[inherit] text-[0.8125rem] text-m-ink outline-none placeholder:text-m-faint'
+
+export const TEXTAREA_CLS =
+ 'w-full box-border resize-none rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-[13px] py-[11px] font-geist text-[0.78125rem] leading-[1.5] text-m-ink outline-none placeholder:text-m-faint'
+
+interface SheetHeaderProps {
+ title: ReactNode
+ onClose: () => void
+ closeLabel: string
+}
+
+/** Sheet header: 17px/700 title + 34px round close, hairline below. */
+export function SheetHeader({ title, onClose, closeLabel }: SheetHeaderProps) {
+ return (
+
+ )
+}
+
+/** Eyebrow section label: Geist 10px/700 letter-spacing .09em, faint. */
+export function Eyebrow({ children, className = '' }: { children: ReactNode; className?: string }) {
+ return (
+
+ {children}
+
+ )
+}
+
+interface PillProps {
+ onClick?: () => void
+ disabled?: boolean
+ className?: string
+ children: ReactNode
+}
+
+/** Footer cancel pill (neutral --m-ic surface). */
+export function CancelPill({ onClick, disabled, className = '', children }: PillProps) {
+ return (
+
+ {children}
+
+ )
+}
+
+/** Footer primary pill on the --m-act surface. */
+export function PrimaryPill({ onClick, disabled, className = '', children }: PillProps) {
+ return (
+
+ {children}
+
+ )
+}
+
+/** Sheet footer row above the safe area, hairline on top. */
+export function SheetFooter({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/client/src/mobile/screens/collections/MCollTripPickerSheet.tsx b/client/src/mobile/screens/collections/MCollTripPickerSheet.tsx
new file mode 100644
index 000000000..f03852a39
--- /dev/null
+++ b/client/src/mobile/screens/collections/MCollTripPickerSheet.tsx
@@ -0,0 +1,136 @@
+import { useEffect, useMemo, useState } from 'react'
+import { CalendarDays, Copy, Loader2, MapPin, Search } from 'lucide-react'
+import type { TranslationFn } from '../../../types'
+import { tripsApi } from '../../../api/client'
+import { useTranslation } from '../../../i18n'
+import { useToast } from '../../../components/shared/Toast'
+import { getApiErrorMessage } from '../../../utils/apiError'
+import { formatDate } from '../../../utils/formatters'
+import MSheet from '../../components/MSheet'
+import { SheetHeader } from './MCollSheetKit'
+
+interface TripOption {
+ id: number
+ title: string
+ start_date?: string | null
+ end_date?: string | null
+ cover_image?: string | null
+}
+
+interface MCollTripPickerSheetProps {
+ open: boolean
+ count: number
+ onCopy: (tripId: number) => Promise<{ copied: number; skipped: { id: number; name: string }[] }>
+ onClose: () => void
+ t: TranslationFn
+}
+
+/**
+ * "Copy to trip" trip picker: searchable trip rows; tapping one copies the
+ * selected places and reconciles the dedup result into toasts.
+ */
+export default function MCollTripPickerSheet({ open, count, onCopy, onClose, t }: MCollTripPickerSheetProps) {
+ const toast = useToast()
+ const { language } = useTranslation()
+ const [trips, setTrips] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [search, setSearch] = useState('')
+ const [busyTripId, setBusyTripId] = useState(null)
+
+ useEffect(() => {
+ if (!open) return
+ let cancelled = false
+ setLoading(true)
+ setSearch('')
+ tripsApi.list()
+ .then((res: { trips?: TripOption[] }) => { if (!cancelled) setTrips(res.trips ?? []) })
+ .catch(() => { if (!cancelled) setTrips([]) })
+ .finally(() => { if (!cancelled) setLoading(false) })
+ return () => { cancelled = true }
+ }, [open])
+
+ const filtered = useMemo(() => {
+ const q = search.trim().toLowerCase()
+ if (!q) return trips
+ return trips.filter(tr => (tr.title ?? '').toLowerCase().includes(q))
+ }, [trips, search])
+
+ const dateRange = (tr: TripOption): string => {
+ const s = formatDate(tr.start_date, language)
+ const e = formatDate(tr.end_date, language)
+ if (s && e) return `${s} – ${e}`
+ return s || e || ''
+ }
+
+ const handleCopy = async (tripId: number) => {
+ if (busyTripId != null) return
+ setBusyTripId(tripId)
+ try {
+ const res = await onCopy(tripId)
+ if (res.copied > 0) toast.success(t('collections.copiedCount', { count: res.copied }))
+ if (res.skipped.length > 0) toast.info(t('collections.skippedDuplicates', { count: res.skipped.length }))
+ if (res.copied === 0 && res.skipped.length === 0) toast.info(t('collections.copyNothing'))
+ onClose()
+ } catch (err) {
+ toast.error(getApiErrorMessage(err, t('common.error')))
+ } finally {
+ setBusyTripId(null)
+ }
+ }
+
+ const title = count > 1 ? t('collections.copyN', { count }) : t('collections.copyToTripTitle')
+
+ return (
+
+
+
+
+
+ setSearch(e.target.value)}
+ placeholder={t('collections.copyToTripSearch')}
+ className="min-w-0 flex-1 bg-transparent font-geist text-[0.78125rem] text-m-ink outline-none placeholder:text-m-faint"
+ />
+
+ {loading ? (
+
+
+
+ ) : filtered.length === 0 ? (
+
{t('collections.noTrips')}
+ ) : (
+ filtered.map(trip => {
+ const busy = busyTripId === trip.id
+ return (
+
handleCopy(trip.id)}
+ disabled={busy}
+ className="mb-2 flex w-full items-center gap-[11px] rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-3 py-[10px] text-left disabled:opacity-60"
+ >
+
+ {trip.cover_image
+ ?
+ : }
+
+
+ {trip.title}
+ {dateRange(trip) && (
+
+ {dateRange(trip)}
+
+ )}
+
+ {busy
+ ?
+ : }
+
+ )
+ })
+ )}
+
+
+ )
+}
diff --git a/client/src/mobile/screens/collections/MCollections.tsx b/client/src/mobile/screens/collections/MCollections.tsx
new file mode 100644
index 000000000..37c5b4b4d
--- /dev/null
+++ b/client/src/mobile/screens/collections/MCollections.tsx
@@ -0,0 +1,542 @@
+import { ReactNode, useEffect, useState } from 'react'
+import { useSearchParams } from 'react-router-dom'
+import {
+ Bookmark, Check, CheckCheck, CheckSquare, ChevronDown, Copy, CopyPlus,
+ FolderInput, Layers, List, Loader2, Map as MapIcon, Pencil, Plus, Search, Share2,
+ Tag, Tags, Trash2, X,
+} from 'lucide-react'
+import type { CollectionStatus } from '@trek/shared'
+import MDancingTrek, { type TrekScene } from '../../components/MDancingTrek'
+import { useCollections } from '../../../pages/collections/useCollections'
+import { STATUS_ORDER } from '../../../pages/collections/collectionsModel'
+import type { StatusFilter } from '../../../store/collectionStore'
+import { ALL_SAVED } from '../../../store/collectionStore'
+import CollectionMap from '../../../components/Collections/CollectionMap'
+import MSheet from '../../components/MSheet'
+import MCollPlaceRow from './MCollPlaceRow'
+import MCollPlaceSheet from './MCollPlaceSheet'
+import MCollAddSheet from './MCollAddSheet'
+import MCollShareSheet from './MCollShareSheet'
+import MCollEditSheet from './MCollEditSheet'
+import MCollLabelsSheet from './MCollLabelsSheet'
+import MCollTripPickerSheet from './MCollTripPickerSheet'
+import MCollListPickerSheet from './MCollListPickerSheet'
+import { STATUS_SPEC } from './collectionsMobileModel'
+import { CancelPill, PrimaryPill } from './MCollSheetKit'
+
+const HEADER_CIRCLE =
+ 'flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_12px_-8px_rgba(0,0,0,.18)]'
+
+const FILTER_CHIP =
+ 'flex flex-1 items-center justify-center gap-[5px] rounded-full py-2 text-[0.75rem] font-semibold'
+
+const CHIP_IDLE = 'border border-[color:var(--m-rowbr)] bg-m-sheetop text-m-ink'
+const CHIP_ON = 'border border-transparent bg-m-act text-m-actfg'
+
+const DROP_PANEL =
+ 'rounded-[14px] border border-[color:var(--m-rowbr)] bg-m-sheetop shadow-[0_20px_44px_-18px_rgba(0,0,0,.45)]'
+
+function EmptyNote({ icon, scene, title }: { icon?: ReactNode; scene?: TrekScene; title: string }) {
+ return (
+
+ {scene ? (
+
+ ) : (
+
{icon}
+ )}
+
{title}
+
+ )
+}
+
+/**
+ * Mobile collections screen: list switcher (dropdown + left drawer), list/map
+ * view toggle with clustering, real status/label filter chips, the select mode
+ * bulk actions and all collection sheets. State and mutations come from the
+ * shared useCollections() page hook.
+ */
+export default function MCollections() {
+ const c = useCollections()
+ const { t } = c
+
+ const [drop, setDrop] = useState(false)
+ const [statusDrop, setStatusDrop] = useState(false)
+ const [labelDrop, setLabelDrop] = useState(false)
+
+ // The bottom-nav "+" hands off via the ?create= contract.
+ const [searchParams, setSearchParams] = useSearchParams()
+ const createParam = searchParams.get('create')
+ const { activeId, canEdit, setShowAddPlace, ownedLists } = c
+ useEffect(() => {
+ if (createParam !== 'place' && createParam !== '1') return
+ const next = new URLSearchParams(searchParams)
+ next.delete('create')
+ setSearchParams(next, { replace: true })
+ // Open whenever the user can add somewhere: an editable active list, or at
+ // least one owned list to pick as the target in the sheet.
+ if ((typeof activeId === 'number' && canEdit) || ownedLists.length > 0) setShowAddPlace(true)
+ }, [createParam, searchParams, setSearchParams, activeId, canEdit, ownedLists.length, setShowAddPlace])
+
+ // Close transient popovers when the active list changes under them.
+ useEffect(() => {
+ setDrop(false)
+ setStatusDrop(false)
+ setLabelDrop(false)
+ }, [c.activeId])
+
+ // The mobile toolbar has no category filter control, but the shared hook
+ // still applies one set on desktop — keep it cleared so resizing to phone
+ // doesn't silently filter the list and map.
+ const { categoryFilter, setCategoryFilter } = c
+ useEffect(() => {
+ if (categoryFilter !== 'all') setCategoryFilter('all')
+ }, [categoryFilter, setCategoryFilter])
+
+ const title = c.isAllSaved ? t('collections.allSaved') : (c.activeCollection?.name ?? t('collections.title'))
+ const isRealList = !c.isAllSaved && typeof c.activeId === 'number'
+ const canManageLabels = isRealList && c.canEdit
+ const canEditList = isRealList && c.isOwner && c.activeCollection != null
+ const canAddPlace = typeof c.activeId === 'number' && c.canEdit
+ const noLists = !c.loading && c.collections.length === 0
+ const hasPlaces = c.places.length > 0
+ const showSelect = c.isAllSaved || c.activeCollection != null
+
+ const statusLabel = c.statusFilter === 'all'
+ ? t('collections.status.filterAll')
+ : t(STATUS_SPEC[c.statusFilter].labelKey)
+ const labelLabel = c.labelFilter.length === 0
+ ? t('collections.status.filterAll')
+ : c.labelFilter.length === 1
+ ? (c.labelOptions.find(l => l.id === c.labelFilter[0])?.name ?? '1')
+ : String(c.labelFilter.length)
+
+ const selectList = (id: number | typeof ALL_SAVED) => {
+ setDrop(false)
+ c.handleSelectList(id)
+ }
+
+ const openNewList = () => {
+ setDrop(false)
+ c.setEditorTarget('new')
+ }
+
+ const pickStatusFilter = (f: StatusFilter) => {
+ c.setStatusFilter(f)
+ setStatusDrop(false)
+ }
+
+ const toggleLabelFilter = (id: number) => {
+ c.setLabelFilter(c.labelFilter.includes(id) ? c.labelFilter.filter(x => x !== id) : [...c.labelFilter, id])
+ }
+
+ const selectChip = (disabled: boolean) =>
+ `flex flex-none items-center gap-[5px] rounded-full border border-[color:var(--m-rowbr)] bg-m-sheetop px-3 py-2 text-[0.75rem] font-semibold text-m-ink ${disabled ? 'opacity-40' : ''}`
+
+ return (
+
+ {/* Header: back · list switcher · edit · share */}
+
+
setDrop(v => !v)}
+ aria-expanded={drop}
+ className="flex h-[38px] min-w-0 flex-1 items-center gap-[7px] rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] px-[14px] text-[0.8125rem] font-bold text-m-ink shadow-[0_5px_12px_-8px_rgba(0,0,0,.18)]"
+ >
+
+ {title}
+
+
+ {canEditList && (
+
c.setEditorTarget(c.activeCollection!)} aria-label={t('collections.editListTitle')} className={HEADER_CIRCLE}>
+
+
+ )}
+ {c.canShare && (
+
c.setShowShare(true)} aria-label={t('collections.share.title')} className={HEADER_CIRCLE}>
+
+
+ )}
+
+
+ {/* List switcher dropdown (in flow, like the design) */}
+ {drop && (
+
+
selectList(ALL_SAVED)}
+ className={`flex w-full items-center gap-[9px] rounded-[11px] p-[10px] text-[0.8125rem] font-semibold text-m-ink ${c.isAllSaved ? 'bg-[color:var(--m-ic)]' : ''}`}
+ >
+ {t('collections.allSaved')}
+
+
+ {[...c.ownedLists, ...c.sharedLists].map(l => {
+ const active = c.activeId === l.id
+ return (
+
selectList(l.id)}
+ className={`flex w-full items-center gap-[10px] rounded-[11px] p-[10px] text-left ${active ? 'bg-[color:var(--m-ic)]' : ''}`}
+ style={active ? { boxShadow: `inset 0 0 0 1.5px ${l.color || '#6366F1'}` } : undefined}
+ >
+
+ {l.name}
+ {l.place_count ?? 0}
+
+ )
+ })}
+
+
+ {t('collections.newList')}
+
+
+ )}
+
+ {c.loading && c.collections.length === 0 ? (
+
+
+
+ ) : noLists ? (
+
+
+
+
+ {t('collections.newList')}
+
+
+
+ ) : (
+ <>
+ {/* Toolbar: view toggle · search · filter chips / select actions */}
+
+
+
+ c.setView('list')}
+ aria-label={t('collections.view.list')}
+ aria-pressed={c.view === 'list'}
+ className={`flex h-[30px] w-[38px] items-center justify-center rounded-[9px] ${c.view === 'list' ? 'bg-m-act text-m-actfg' : 'text-m-muted'}`}
+ >
+
+
+ c.setView('map')}
+ aria-label={t('collections.view.map')}
+ aria-pressed={c.view === 'map'}
+ className={`flex h-[30px] w-[38px] items-center justify-center rounded-[9px] ${c.view === 'map' ? 'bg-m-act text-m-actfg' : 'text-m-muted'}`}
+ >
+
+
+
+
+
+ c.setSearch(e.target.value)}
+ placeholder={t('collections.search')}
+ className="min-w-0 flex-1 bg-transparent font-geist text-[0.78125rem] text-m-ink outline-none placeholder:text-m-faint"
+ />
+
+
+
+ {c.view === 'list' && !c.selectMode && (
+
+
{ setStatusDrop(v => !v); setLabelDrop(false) }}
+ aria-expanded={statusDrop}
+ className={`${FILTER_CHIP} ${c.statusFilter !== 'all' ? CHIP_ON : CHIP_IDLE}`}
+ >
+
+ {statusLabel}
+
+
+ {isRealList && (
+
{ setLabelDrop(v => !v); setStatusDrop(false) }}
+ aria-expanded={labelDrop}
+ className={`${FILTER_CHIP} ${c.labelFilter.length > 0 ? CHIP_ON : CHIP_IDLE}`}
+ >
+
+ {labelLabel}
+ 0 ? '' : 'text-m-faint'}`} />
+
+ )}
+ {canManageLabels && (
+
c.setShowLabelManager(true)} className={`${FILTER_CHIP} ${CHIP_IDLE}`}>
+
+ {t('collections.linkLabel')}
+
+ )}
+ {showSelect && (
+
c.setSelectMode(true)} className={`${FILTER_CHIP} ${CHIP_IDLE}`}>
+
+ {t('collections.select')}
+
+ )}
+
+ {(statusDrop || labelDrop) && (
+
{ setStatusDrop(false); setLabelDrop(false) }}
+ />
+ )}
+ {statusDrop && (
+
+ pickStatusFilter('all')}
+ className={`flex w-full items-center gap-[9px] rounded-[11px] p-[10px] text-[0.8125rem] font-semibold text-m-ink ${c.statusFilter === 'all' ? 'bg-[color:var(--m-ic)]' : ''}`}
+ >
+
+ {t('collections.status.filterAll')}
+ {c.counts.all}
+
+ {STATUS_ORDER.map(s => {
+ const meta = STATUS_SPEC[s]
+ const Icon = meta.icon
+ return (
+ pickStatusFilter(s)}
+ className={`flex w-full items-center gap-[9px] rounded-[11px] p-[10px] text-[0.8125rem] font-semibold text-m-ink ${c.statusFilter === s ? 'bg-[color:var(--m-ic)]' : ''}`}
+ >
+
+ {t(meta.labelKey)}
+ {c.counts[s]}
+
+ )
+ })}
+
+ )}
+ {labelDrop && (
+
+
{ c.setLabelFilter([]); setLabelDrop(false) }}
+ className={`flex w-full items-center gap-[9px] rounded-[11px] p-[10px] text-[0.8125rem] font-semibold text-m-ink ${c.labelFilter.length === 0 ? 'bg-[color:var(--m-ic)]' : ''}`}
+ >
+
+ {t('collections.status.filterAll')}
+
+ {c.labelOptions.map(l => {
+ const on = c.labelFilter.includes(l.id)
+ return (
+
toggleLabelFilter(l.id)}
+ className={`flex w-full items-center gap-[10px] rounded-[11px] p-[10px] text-left ${on ? 'bg-[color:var(--m-ic)]' : ''}`}
+ >
+
+ {l.name}
+ {l.count}
+ {on && }
+
+ )
+ })}
+ {c.labelOptions.length === 0 && (
+
{t('collections.labels.empty')}
+ )}
+
+ )}
+
+ )}
+
+ {c.view === 'list' && c.selectMode && (
+
+
+
+ {c.allVisibleSelected ? t('collections.deselectAll') : t('collections.selectAll')}
+
+
+ {t('collections.selectedCount', { count: c.selectedIds.length })}
+
+ {canManageLabels && (
+ c.setLabelPickerOpen(true)} disabled={c.selectedIds.length === 0} className={selectChip(c.selectedIds.length === 0)}>
+ {t('collections.labels.assign')}
+
+ )}
+
+ {t('collections.copyToTrip')}
+
+ {c.canEdit && (
+ c.setListPickerMode('move')} disabled={c.selectedIds.length === 0} className={selectChip(c.selectedIds.length === 0)}>
+ {t('collections.moveToList')}
+
+ )}
+ c.setListPickerMode('copy')} disabled={c.selectedIds.length === 0} className={selectChip(c.selectedIds.length === 0)}>
+ {t('collections.duplicateToList')}
+
+ {c.canDelete && (
+
+ {t('common.delete')}
+
+ )}
+ c.setSelectMode(false)} aria-label={t('common.cancel')} className={selectChip(false)}>
+
+
+
+ )}
+
+
+ {/* List view */}
+ {c.view === 'list' && (
+ c.placesLoading && !hasPlaces ? (
+
+
+
+ ) : !hasPlaces ? (
+
+ ) : c.visiblePlaces.length === 0 ? (
+
} title={t('collections.empty.noMatchTitle')} />
+ ) : (
+
+ {c.visiblePlaces.map(p => (
+ c.handleStatusChange(id, status)}
+ t={t}
+ />
+ ))}
+
+ )
+ )}
+
+ {/* Map view — the real map stack with marker clustering */}
+ {c.view === 'map' && (
+ c.mappable.length === 0 ? (
+
} title={t('collections.empty.noMatchTitle')} />
+ ) : (
+
+ c.setSelectedPlaceId(null)}
+ dark={c.dark}
+ />
+
+ )
+ )}
+ >
+ )}
+
+ {/* Sheets */}
+
c.updatePlace(c.selectedPlace!.id, patch)}
+ onCopyToTrip={c.openCopyForSelectedPlace}
+ onRemove={c.handleDetailRemove}
+ t={t}
+ />
+
+ c.setShowAddPlace(false)}
+ onAdded={c.handlePlaceAdded}
+ t={t}
+ />
+
+ c.setShowShare(false)}
+ onAfterLeave={c.handleAfterLeave}
+ t={t}
+ />
+
+ c.setEditorTarget(null)}
+ onCreated={c.handleEditorCreated}
+ onRequestDelete={c.setConfirmDeleteList}
+ t={t}
+ />
+
+ { c.setLabelPickerOpen(false); c.setShowLabelManager(true) }}
+ onClose={() => { c.setShowLabelManager(false); c.setLabelPickerOpen(false) }}
+ t={t}
+ />
+
+
+
+ l.id !== c.activeId)}
+ count={c.selectedIds.length}
+ onPick={id => (c.listPickerMode === 'move' ? c.handleMoveToList(id) : c.handleDuplicateToList(id))}
+ onClose={() => c.setListPickerMode(null)}
+ t={t}
+ />
+
+ {/* Delete-list confirm */}
+ c.setConfirmDeleteList(null)}
+ material="opaque"
+ ariaLabel={t('collections.deleteList')}
+ >
+
+
{t('collections.deleteList')}
+
{t('collections.deleteListConfirm')}
+
+
c.setConfirmDeleteList(null)}>{t('common.cancel')}
+
+ {t('common.delete')}
+
+
+
+
+
+ )
+}
diff --git a/client/src/mobile/screens/collections/collectionsMobileModel.ts b/client/src/mobile/screens/collections/collectionsMobileModel.ts
new file mode 100644
index 000000000..2d72d2676
--- /dev/null
+++ b/client/src/mobile/screens/collections/collectionsMobileModel.ts
@@ -0,0 +1,83 @@
+import {
+ BedDouble, Bike, Bookmark, Camera, CheckCircle2, Circle, CircleSlash, Coffee,
+ Landmark, MapPin, ShoppingBag, TrainFront, Trees, Umbrella, Utensils,
+} from 'lucide-react'
+import type { LucideIcon } from 'lucide-react'
+import type { CollectionStatus } from '@trek/shared'
+import { getCategoryIcon } from '../../../components/shared/categoryIcons'
+
+/**
+ * Presentation canon of the mobile collections design: the app-wide category
+ * colour table and the status colour/icon triple. Category names not in the
+ * table (custom admin categories) fall back to their own DB colour/icon.
+ */
+
+export interface MCategoryMeta {
+ color: string
+ icon: LucideIcon
+}
+
+/** Canonical category colours + icons of the mobile design. The hex values
+ * are design canon (identical in light and dark), so theme-lint is waived. */
+export const CATEGORY_SPEC: Record = {
+ Activity: { color: '#4A7DDB', icon: Bike }, // theme-lint-disable
+ Attraction: { color: '#EC4899', icon: Camera }, // theme-lint-disable
+ 'Bar/Cafe': { color: '#B4762E', icon: Coffee }, // theme-lint-disable
+ Beach: { color: '#0EA5A0', icon: Umbrella }, // theme-lint-disable
+ Hotel: { color: '#9B5DE5', icon: BedDouble }, // theme-lint-disable
+ Nature: { color: '#2FA37A', icon: Trees }, // theme-lint-disable
+ Other: { color: '#68686F', icon: MapPin }, // theme-lint-disable
+ Restaurant: { color: '#E8843D', icon: Utensils }, // theme-lint-disable
+ Shopping: { color: '#D6273B', icon: ShoppingBag }, // theme-lint-disable
+ Transport: { color: '#4A7DDB', icon: TrainFront }, // theme-lint-disable
+}
+
+/** Fallback for places without a category (list rows / pills). */
+export const UNCATEGORIZED_META: MCategoryMeta = { color: '#9A9AA1', icon: Landmark } // theme-lint-disable
+
+/** The "No category" option in pickers. */
+export const NO_CATEGORY_META: MCategoryMeta = { color: '#9A9AA1', icon: CircleSlash } // theme-lint-disable
+
+interface CategoryLike {
+ name?: string | null
+ color?: string | null
+ icon?: string | null
+}
+
+/** Colour + icon for a place's category; null when it has none. */
+export function categoryMeta(category: CategoryLike | null | undefined): MCategoryMeta | null {
+ if (!category || !category.name) return null
+ const spec = CATEGORY_SPEC[category.name]
+ if (spec) return spec
+ return {
+ color: category.color || UNCATEGORIZED_META.color,
+ icon: getCategoryIcon(category.icon ?? undefined),
+ }
+}
+
+export interface MStatusMeta {
+ color: string
+ icon: LucideIcon
+ labelKey: string
+}
+
+/** Status canon: Idea → Want to go → Visited. */
+export const STATUS_SPEC: Record = {
+ idea: { color: '#9A9AA1', icon: Circle, labelKey: 'collections.status.idea' }, // theme-lint-disable
+ want: { color: '#4A7DDB', icon: Bookmark, labelKey: 'collections.status.want' }, // theme-lint-disable
+ visited: { color: '#2FA37A', icon: CheckCircle2, labelKey: 'collections.status.visited' }, // theme-lint-disable
+}
+
+/** Hex + alpha-suffix tints the design uses for tiles/pills (e.g. #4A7DDB1f). */
+export function tint(hex: string, alpha: string): string {
+ return `${hex}${alpha}`
+}
+
+/** Swatch palette for list colours and labels. */
+export const SWATCH_COLORS = ['#6366F1', '#38BDF8', '#2FA37A', '#F59E0B', '#EF4444', '#EC4899', '#8B5CF6', '#64748B']
+
+/** Cover fallback per list: a gradient built from the list colour. */
+export function listCoverGradient(color: string | null | undefined): string {
+ const c = color || '#6366F1'
+ return `linear-gradient(115deg, ${c}, ${c}66)`
+}
diff --git a/client/src/mobile/screens/dashboard/MDashWidgets.tsx b/client/src/mobile/screens/dashboard/MDashWidgets.tsx
new file mode 100644
index 000000000..1cfc7e59f
--- /dev/null
+++ b/client/src/mobile/screens/dashboard/MDashWidgets.tsx
@@ -0,0 +1,424 @@
+import React, { useCallback, useEffect, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import {
+ ArrowRight, ArrowRightLeft, Bookmark, Calendar, ChevronDown, ChevronRight,
+ Clock, Hotel, MapPin, Plane, Plus, RefreshCw, Ticket, Utensils, X,
+} from 'lucide-react'
+import { useTranslation } from '../../../i18n'
+import { useSettingsStore } from '../../../store/settingsStore'
+import { useAddonStore } from '../../../store/addonStore'
+import { collectionsApi } from '../../../api/collections'
+import { entityGradient } from '../../../utils/gradients'
+import { CURRENCIES } from '../../../components/Budget/BudgetPanel.constants'
+import { formatTime, splitReservationDateTime } from '../../../utils/formatters'
+import { normalizeAppearance, MOBILE_DASH_TOKENS, type MobileDashToken, type Collection } from '@trek/shared'
+import type { UpcomingReservation } from '../../../pages/dashboard/dashboardModel'
+
+const RES_ICON: Record = {
+ flight: ,
+ hotel: ,
+ restaurant: ,
+}
+
+/**
+ * Inline dashboard widget panels (currency, collections, timezones, upcoming
+ * reservations) for the mobile dashboard. The blocks are rendered individually
+ * by MDashboard so they can be interleaved with the trip list in a user-chosen
+ * order; this module owns the widget bodies plus the order/visibility helpers.
+ * Visibility follows the per-device appearance widget config; collections is
+ * additionally gated by the admin addon.
+ */
+
+/** Reconcile a stored mobile-dashboard order: keep known tokens in order, drop
+ * unknown/duplicate ones, and append any missing tokens in their built-in spot. */
+export function resolveMobileDashOrder(stored: string[] | undefined): MobileDashToken[] {
+ const valid = new Set(MOBILE_DASH_TOKENS)
+ const seen = new Set()
+ const out: MobileDashToken[] = []
+ for (const tok of stored ?? []) {
+ if (valid.has(tok) && !seen.has(tok)) { seen.add(tok); out.push(tok as MobileDashToken) }
+ }
+ for (const tok of MOBILE_DASH_TOKENS) if (!seen.has(tok)) out.push(tok)
+ return out
+}
+
+/** The resolved mobile-dashboard block order from the appearance blob. */
+export function useMobileDashOrder(): MobileDashToken[] {
+ const appearance = useSettingsStore(s => s.settings.appearance)
+ return resolveMobileDashOrder(normalizeAppearance(appearance).dashboard.mobileOrder)
+}
+
+/** Which blocks are currently visible — trips always; widgets per flag (+ addon). */
+export function useMobileDashVisibility(): Record {
+ const appearance = useSettingsStore(s => s.settings.appearance)
+ const isAddonEnabled = useAddonStore(s => s.isEnabled)
+ const w = normalizeAppearance(appearance).dashboard.mobile
+ return {
+ trips: true,
+ currency: w.currency,
+ collections: isAddonEnabled('collections') && w.collections,
+ timezones: w.timezones,
+ upcomingReservations: w.upcomingReservations,
+ }
+}
+
+/** Render a single mobile dashboard widget block by token (null for 'trips'). */
+export function MobileDashWidget({ id, upcoming }: { id: MobileDashToken; upcoming: UpcomingReservation[] }): React.ReactElement | null {
+ switch (id) {
+ case 'currency': return
+ case 'collections': return
+ case 'timezones': return
+ case 'upcomingReservations': return
+ default: return null
+ }
+}
+
+function WidgetPanel({ icon, title, action, children }: {
+ icon: React.ReactElement
+ title: string
+ action?: React.ReactNode
+ children: React.ReactNode
+}): React.ReactElement {
+ return (
+
+
+ {icon}
+ {title}
+ {action && {action} }
+
+ {children}
+
+ )
+}
+
+// ── Currency converter ───────────────────────────────────────────────────────
+function MCurrencyWidget(): React.ReactElement {
+ const { t } = useTranslation()
+ const isLoaded = useSettingsStore(s => s.isLoaded)
+ const updateSetting = useSettingsStore(s => s.updateSetting)
+ const from = useSettingsStore(s => s.settings.dashboard_fx_from) || 'EUR'
+ const to = useSettingsStore(s => s.settings.dashboard_fx_to) || 'USD'
+ const setFrom = (v: string) => { updateSetting('dashboard_fx_from', v).catch(() => {}) }
+ const setTo = (v: string) => { updateSetting('dashboard_fx_to', v).catch(() => {}) }
+ const [amount, setAmount] = useState('100')
+ const [rates, setRates] = useState | null>(null)
+
+ const fetchRates = useCallback(() => {
+ fetch(`https://api.frankfurter.dev/v2/rates?base=${from}`)
+ .then(r => r.json())
+ .then((d: Array<{ quote: string; rate: number }>) => {
+ if (!Array.isArray(d)) { setRates(null); return }
+ // Frankfurter omits the base's own self-rate; seed it so `from` stays selectable.
+ const map: Record = { [from]: 1 }
+ for (const r of d) map[r.quote] = r.rate
+ setRates(map)
+ })
+ .catch(() => setRates(null))
+ }, [from])
+
+ useEffect(() => { fetchRates() }, [fetchRates])
+
+ // Same one-time localStorage → settings migration the desktop widget runs, so
+ // a phone-only user's pre-3.1.3 pair survives an upgrade too (#1311).
+ useEffect(() => {
+ if (!isLoaded) return
+ const lf = localStorage.getItem('trek_fx_from')
+ const lt = localStorage.getItem('trek_fx_to')
+ if (!lf && !lt) return
+ const writes: Promise[] = []
+ if (lf) writes.push(updateSetting('dashboard_fx_from', lf))
+ if (lt) writes.push(updateSetting('dashboard_fx_to', lt))
+ Promise.all(writes).then(() => {
+ localStorage.removeItem('trek_fx_from')
+ localStorage.removeItem('trek_fx_to')
+ }).catch(() => { /* keep localStorage; retry on next load */ })
+ }, [isLoaded, updateSetting])
+
+ const currencies = rates ? Object.keys(rates).sort() : [...CURRENCIES]
+ const rate = rates?.[to] ?? null
+ const converted = rate != null ? (parseFloat(amount.replace(',', '.')) || 0) * rate : null
+ const swap = () => { setFrom(to); setTo(from) }
+
+ return (
+ }
+ title={t('dashboard.currency')}
+ action={
+
+
+
+ }
+ >
+
+
+
{t('dashboard.fx.from')}
+
setAmount(e.target.value)}
+ inputMode="decimal"
+ aria-label={t('dashboard.fx.from')}
+ className="w-full border-none bg-transparent pt-[2px] font-[inherit] text-[1.5rem] font-bold tabular-nums text-m-ink outline-none"
+ />
+
+
+
+
+
+
+
{t('dashboard.fx.to')}
+
{converted != null ? converted.toFixed(2) : '—'}
+
+
+
+
+ {rate != null ? `1 ${from} = ${rate.toFixed(4)} ${to}` : t('dashboard.fx.unavailable')}
+
+
+ )
+}
+
+// Picker row styled like the design, backed by an invisible native select so
+// phones get their platform currency picker.
+function CurrencyPicker({ value, currencies, onChange }: {
+ value: string; currencies: string[]; onChange: (v: string) => void
+}): React.ReactElement {
+ return (
+
+ {value}
+
+ onChange(e.target.value)}
+ aria-label={value}
+ className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
+ >
+ {currencies.includes(value) ? null : {value} }
+ {currencies.map(c => {c} )}
+
+
+ )
+}
+
+// ── Collections ──────────────────────────────────────────────────────────────
+function MCollectionsWidget(): React.ReactElement {
+ const { t } = useTranslation()
+ const navigate = useNavigate()
+ const [lists, setLists] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ let cancelled = false
+ collectionsApi.list()
+ .then(data => { if (!cancelled) setLists(data.collections) })
+ .catch(() => { if (!cancelled) setLists([]) })
+ .finally(() => { if (!cancelled) setLoading(false) })
+ return () => { cancelled = true }
+ }, [])
+
+ return (
+ }
+ title={t('collections.widget.title')}
+ action={
+ navigate('/collections')} className="flex text-m-faint">
+
+
+ }
+ >
+ {loading ? null : lists.length === 0 ? (
+ {t('collections.widget.empty')}
+ ) : (
+ lists.slice(0, 4).map(list => (
+ navigate(`/collections/${list.id}`)}
+ className="mt-[11px] flex w-full items-center rounded-[14px] p-[15px_14px] text-left text-white"
+ style={{ background: list.color || entityGradient(list.id) }}
+ >
+ {list.name}
+
+
+ {list.place_count ?? 0}
+
+
+ ))
+ )}
+
+ )
+}
+
+// ── Timezones ────────────────────────────────────────────────────────────────
+const DEFAULT_ZONES = ['Europe/London', 'Asia/Tokyo']
+
+const FALLBACK_ZONES = [
+ 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Europe/Madrid', 'Europe/Moscow',
+ 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'America/Sao_Paulo',
+ 'Asia/Dubai', 'Asia/Kolkata', 'Asia/Bangkok', 'Asia/Shanghai', 'Asia/Tokyo', 'Asia/Singapore',
+ 'Australia/Sydney', 'Pacific/Auckland', 'UTC',
+]
+
+function shortZone(tz: string): string {
+ const city = tz.split('/').pop() || tz
+ return city.replace(/_/g, ' ')
+}
+
+function MTimezonesWidget(): React.ReactElement {
+ const { t, locale } = useTranslation()
+ const home = Intl.DateTimeFormat().resolvedOptions().timeZone
+ const [now, setNow] = useState(() => new Date())
+ const isLoaded = useSettingsStore(s => s.isLoaded)
+ const updateSetting = useSettingsStore(s => s.updateSetting)
+ const stored = useSettingsStore(s => s.settings.dashboard_timezones)
+ // Unset (never chosen) falls back to home + defaults; an explicit list is honoured.
+ const zones = stored ?? [home, ...DEFAULT_ZONES]
+ const setZones = (next: string[]) => { updateSetting('dashboard_timezones', next).catch(() => {}) }
+ const [adding, setAdding] = useState(false)
+
+ useEffect(() => {
+ const id = setInterval(() => setNow(new Date()), 30000)
+ return () => clearInterval(id)
+ }, [])
+
+ // Same one-time localStorage → settings migration the desktop widget runs, so
+ // a phone-only user's pre-3.1.3 zone list survives an upgrade too (#1311).
+ useEffect(() => {
+ if (!isLoaded) return
+ const raw = localStorage.getItem('trek_dashboard_tz')
+ if (!raw) return
+ let parsed: unknown
+ // A malformed/non-array value can never be written, so drop it now to avoid retrying forever.
+ try { parsed = JSON.parse(raw) } catch { localStorage.removeItem('trek_dashboard_tz'); return }
+ if (!Array.isArray(parsed)) { localStorage.removeItem('trek_dashboard_tz'); return }
+ // Only drop the localStorage source once the server has durably stored the value.
+ updateSetting('dashboard_timezones', parsed)
+ .then(() => { localStorage.removeItem('trek_dashboard_tz') })
+ .catch(() => { /* keep localStorage; retry on next load */ })
+ }, [isLoaded, updateSetting])
+
+ const allZones = React.useMemo(() => {
+ const supported = (Intl as unknown as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf
+ try { return supported ? supported('timeZone') : FALLBACK_ZONES } catch { return FALLBACK_ZONES }
+ }, [])
+
+ const addZone = (tz: string) => {
+ if (tz && !zones.includes(tz)) setZones([...zones, tz])
+ setAdding(false)
+ }
+ const removeZone = (tz: string) => setZones(zones.filter(z => z !== tz))
+
+ const timeIn = (tz: string) => now.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: tz })
+ const offsetLabel = (tz: string) => {
+ const part = new Intl.DateTimeFormat(locale, { timeZone: tz, timeZoneName: 'short' }).formatToParts(now).find(p => p.type === 'timeZoneName')
+ return part?.value || ''
+ }
+
+ return (
+ }
+ title={t('dashboard.timezone')}
+ action={
+ setAdding(a => !a)} className="flex text-m-faint">
+ {adding ? : }
+
+ }
+ >
+ {adding && (
+ addZone(e.target.value)}
+ aria-label={t('dashboard.aria.addTimezone')}
+ className="mt-[11px] w-full rounded-[10px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[10px] py-2 text-[0.75rem] font-semibold text-m-ink outline-none"
+ >
+ {t('dashboard.tz.searchPlaceholder')}
+ {allZones.filter(z => !zones.includes(z)).map(z => (
+ {z.replace(/_/g, ' ')}
+ ))}
+
+ )}
+ {zones.map(tz => (
+
+
+ {shortZone(tz)[0]?.toUpperCase()}
+
+
+
{shortZone(tz)}
+
{offsetLabel(tz)}
+
+
{timeIn(tz)}
+ {/* Rest state ends with the clock like the design; removing is part of the + edit mode. */}
+ {adding && (
+
removeZone(tz)}
+ className="flex flex-none text-m-faint"
+ >
+
+
+ )}
+
+ ))}
+ {zones.length === 0 && (
+ {t('dashboard.tz.empty')}
+ )}
+
+ )
+}
+
+// ── Upcoming reservations ────────────────────────────────────────────────────
+function MUpcomingWidget({ items }: { items: UpcomingReservation[] }): React.ReactElement {
+ const { t, locale } = useTranslation()
+ const navigate = useNavigate()
+ const timeFormat = useSettingsStore(s => s.settings.time_format)
+
+ // Land on the trip's bookings tab — same sessionStorage contract the trip
+ // shell and the bottom nav "+" use.
+ const openReservation = (tripId: number) => {
+ sessionStorage.setItem(`trip-tab-${tripId}`, 'buchungen')
+ navigate(`/trips/${tripId}`)
+ }
+
+ const subFor = (r: UpcomingReservation): string => {
+ const parsed = splitReservationDateTime(r.reservation_time)
+ const datePart = parsed.date || r.day_date || null
+ const date = datePart ? new Date(datePart + 'T00:00:00Z') : null
+ const dateStr = date && !isNaN(date.getTime())
+ ? date.toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
+ : null
+ const timeStr = parsed.time ? formatTime(parsed.time, locale, timeFormat) : null
+ const place = r.location || r.place_name || r.trip_title || null
+ return [dateStr, timeStr, place].filter(Boolean).join(' · ')
+ }
+
+ return (
+ } title={t('dashboard.upcoming.title')}>
+ {items.length === 0 ? (
+ {t('dashboard.upcoming.empty')}
+ ) : (
+ items.map(r => (
+ openReservation(r.trip_id)}
+ className="flex w-full items-center gap-[11px] border-b border-[color:var(--m-rowbr)] py-[9px] text-left"
+ >
+
+ {RES_ICON[r.type] || }
+
+
+
{r.title}
+
{subFor(r)}
+
+
+
+ ))
+ )}
+
+ )
+}
diff --git a/client/src/mobile/screens/dashboard/MDashboard.tsx b/client/src/mobile/screens/dashboard/MDashboard.tsx
new file mode 100644
index 000000000..b80fecd6e
--- /dev/null
+++ b/client/src/mobile/screens/dashboard/MDashboard.tsx
@@ -0,0 +1,596 @@
+import React, { useEffect, useState } from 'react'
+import {
+ Archive, ArchiveRestore, ArrowRight, Bell, CalendarDays, CalendarPlus, Copy,
+ LayoutGrid, List, MapPin, Pencil, Plus, RefreshCw, Trash2, Users,
+} from 'lucide-react'
+import { useTranslation } from '../../../i18n'
+import MDancingTrek from '../../components/MDancingTrek'
+import { useDashboard } from '../../../pages/dashboard/useDashboard'
+import {
+ type DashboardTrip, MS_PER_DAY, daysUntil, getTripStatus,
+} from '../../../pages/dashboard/dashboardModel'
+import { useAuthStore } from '../../../store/authStore'
+import { useInAppNotificationStore } from '../../../store/inAppNotificationStore'
+import { usePluginStore } from '../../../store/pluginStore'
+import { useTripCardBadges } from '../../../components/Plugins/TripCardBadges'
+import type { TripCardBadge } from '../../../api/client'
+import DemoBanner from '../../../components/Layout/DemoBanner'
+import { IcsSubscribeModal } from '../../../components/Planner/IcsSubscribeModal'
+import PluginWidgets from '../../../components/Plugins/PluginWidgets'
+import { entityGradient } from '../../../utils/gradients'
+import MGlassBar from '../../components/MGlassBar'
+import MIconBtn from '../../components/MIconBtn'
+import MSegmented from '../../components/MSegmented'
+import MSheet from '../../components/MSheet'
+import MUserMenu from './MUserMenu'
+import { useMobileDashOrder, useMobileDashVisibility, MobileDashWidget } from './MDashWidgets'
+import MNewTripSheet from './MNewTripSheet'
+import type { MobileDashToken } from '@trek/shared'
+
+// Localized short date for the pills; the year only shows when it isn't the
+// current one (same rule as the desktop cards).
+function fullDate(dateStr: string | null | undefined, locale: string): string | null {
+ if (!dateStr) return null
+ const date = new Date(dateStr + 'T00:00:00Z')
+ if (isNaN(date.getTime())) return null
+ const opts: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short', timeZone: 'UTC' }
+ if (date.getUTCFullYear() !== new Date().getUTCFullYear()) opts.year = 'numeric'
+ return date.toLocaleDateString(locale, opts)
+}
+
+type TripFilter = 'planned' | 'archive' | 'completed'
+
+interface CardAction {
+ key: string
+ label: string
+ icon: React.ReactElement
+ onClick: () => void
+}
+
+/**
+ * Mobile dashboard screen. Pure presentation over the shared useDashboard()
+ * data hook — every mutation runs through the same store/hook actions the
+ * desktop page uses.
+ */
+export default function MDashboard(): React.ReactElement {
+ const {
+ demoMode, locale, t, navigate,
+ spotlight, upcoming, gridTrips, isLoading, loadError, retryLoad,
+ tripFilter, setTripFilter, viewMode, toggleViewMode,
+ showForm, setShowForm, editingTrip, setEditingTrip,
+ deleteTrip, setDeleteTrip, copyTrip, setCopyTrip, setTrips,
+ handleCreate, handleUpdate, confirmDelete, handleArchive, handleUnarchive, confirmCopy,
+ } = useDashboard()
+
+ const user = useAuthStore(s => s.user)
+ const isAuthenticated = useAuthStore(s => s.isAuthenticated)
+ const unread = useInAppNotificationStore(s => s.unreadCount)
+ const fetchUnreadCount = useInAppNotificationStore(s => s.fetchUnreadCount)
+ const [menuOpen, setMenuOpen] = useState(false)
+ const [subOpen, setSubOpen] = useState(false)
+
+ // Plugin dashboard widgets + trip-card badges, mirroring the desktop page:
+ // same slot filter (only true dashboard widgets), one badge fetch for all
+ // visible cards, gated on any plugin being active. Fail-safe like desktop.
+ const widgetPlugins = usePluginStore(s => s.plugins).filter(p => p.type === 'widget' && p.slot !== 'hero' && p.slot !== 'place-detail' && p.slot !== 'day-detail' && p.slot !== 'reservation-detail')
+ const anyPluginActive = usePluginStore(s => s.plugins).length > 0
+ const badgesFor = useTripCardBadges(gridTrips.map(trip => trip.id), anyPluginActive)
+
+ // Mobile-only dashboard arrangement: the featured trip stays on top, then the
+ // trip list + widgets render in the user's chosen order (Settings → Appearance).
+ const dashOrder = useMobileDashOrder()
+ const dashVisible = useMobileDashVisibility()
+
+ useEffect(() => { if (isAuthenticated) fetchUnreadCount() }, [isAuthenticated, fetchUnreadCount])
+
+ const openCreate = () => { setEditingTrip(null); setShowForm(true) }
+ const openEdit = (trip: DashboardTrip) => { setEditingTrip(trip); setShowForm(true) }
+
+ const isArchivedFilter = tripFilter === 'archive'
+
+ // Archived cards swap edit/archive for restore + permanent delete (audit);
+ // the grid variant leaves archiving to the edit sheet like the design.
+ const actionsFor = (trip: DashboardTrip, layout: 'grid' | 'list'): CardAction[] => {
+ if (isArchivedFilter) {
+ return [
+ { key: 'copy', label: t('dashboard.aria.duplicate'), icon: , onClick: () => setCopyTrip(trip) },
+ { key: 'restore', label: t('dashboard.restore'), icon: , onClick: () => handleUnarchive(trip.id) },
+ { key: 'delete', label: t('common.delete'), icon: , onClick: () => setDeleteTrip(trip) },
+ ]
+ }
+ const base: CardAction[] = [
+ { key: 'edit', label: t('common.edit'), icon: , onClick: () => openEdit(trip) },
+ { key: 'copy', label: t('dashboard.aria.duplicate'), icon: , onClick: () => setCopyTrip(trip) },
+ ]
+ if (layout === 'list') {
+ base.push({ key: 'archive', label: t('dashboard.archive'), icon: , onClick: () => handleArchive(trip.id) })
+ }
+ base.push({ key: 'delete', label: t('common.delete'), icon: , onClick: () => setDeleteTrip(trip) })
+ return base
+ }
+
+ const statusLabel = (trip: DashboardTrip): string => {
+ if (trip.is_archived) return t('dashboard.archived')
+ const status = getTripStatus(trip)
+ const until = daysUntil(trip.start_date)
+ return status === 'ongoing' ? t('dashboard.mobile.liveNow')
+ : status === 'today' ? t('dashboard.status.today')
+ : status === 'tomorrow' ? t('dashboard.status.tomorrow')
+ : status === 'future' && until !== null ? (until > 60 ? t('dashboard.mobile.inMonths', { count: Math.round(until / 30) }) : t('dashboard.mobile.inDays', { count: until }))
+ : status === 'past' ? t('dashboard.mobile.completed')
+ : t('dashboard.card.idea')
+ }
+
+ const showEmpty = tripFilter === 'planned' && !spotlight && gridTrips.length === 0 && !isLoading && !loadError
+
+ // One dashboard block by token: 'trips' is the filter row + trip list; the
+ // rest are the inline widgets (rendered only when their toggle is on).
+ const renderBlock = (id: MobileDashToken): React.ReactNode => {
+ if (id !== 'trips') {
+ return dashVisible[id] ? : null
+ }
+ return (
+ <>
+
+
+ value={tripFilter}
+ onChange={setTripFilter}
+ variant="intrinsic"
+ options={[
+ { value: 'planned', label: t('dashboard.filter.planned') },
+ { value: 'archive', label: t('dashboard.archived') },
+ { value: 'completed', label: t('dashboard.mobile.completed') },
+ ]}
+ />
+ setSubOpen(true)}>
+
+
+
+ {viewMode === 'grid' ?
: }
+
+
+
+ {showEmpty && (
+
+
+
{t('dashboard.emptyTitle')}
+
{t('dashboard.emptyText')}
+
+
+ {t('dashboard.emptyButton')}
+
+
+ )}
+
+ {viewMode === 'grid' ? (
+
+ {gridTrips.map(trip => (
+ navigate(`/trips/${trip.id}`)}
+ />
+ ))}
+
+ ) : (
+
+ {gridTrips.map(trip => (
+ navigate(`/trips/${trip.id}`)}
+ />
+ ))}
+
+ )}
+ >
+ )
+ }
+
+ return (
+ <>
+
+ { e.currentTarget.closest('.overflow-y-auto')?.scrollTo({ top: 0, behavior: 'smooth' }) }}
+ aria-label="TREK"
+ className="flex flex-none items-center gap-[7px]"
+ >
+ {/* theme-lint-disable — brand tile stays black in both themes */}
+
+
+
+
+ navigate('/notifications')}>
+
+ {unread > 0 && (
+
+ )}
+
+ setMenuOpen(o => !o)}
+ aria-label={t('nav.profile')}
+ aria-expanded={menuOpen}
+ className="flex h-10 w-10 flex-none items-center justify-center overflow-hidden rounded-full border-2 border-[color:var(--m-avbr)] bg-[image:linear-gradient(135deg,#6A6A74,#1A1A1E)] text-[0.9375rem] font-bold text-white shadow-[0_8px_18px_-8px_rgba(0,0,0,.45)]"
+ >
+ {user?.avatar_url
+ ?
+ : (user?.username || '?')[0].toUpperCase()}
+
+
+
+ setMenuOpen(false)} />
+
+ {demoMode && }
+
+
+ {loadError && (
+
+ {t('dashboard.loadErrorBanner')}
+
+
+ {t('dashboard.retry')}
+
+
+ )}
+
+ {spotlight && (
+
navigate(`/trips/${spotlight.id}`)}
+ actions={actionsFor(spotlight, 'list')}
+ />
+ )}
+
+ {dashOrder.map(id => (
+ {renderBlock(id)}
+ ))}
+
+ {widgetPlugins.length > 0 && (
+
+ )}
+
+
+ { setShowForm(false); setEditingTrip(null) }}
+ onSave={editingTrip ? handleUpdate : handleCreate}
+ onCoverUpdate={(tripId, coverUrl) => setTrips(prev => prev.map(tr => tr.id === tripId ? { ...tr, cover_image: coverUrl } : tr))}
+ onArchive={editingTrip
+ ? () => (editingTrip.is_archived ? handleUnarchive(editingTrip.id) : handleArchive(editingTrip.id))
+ : undefined}
+ />
+
+ setDeleteTrip(null)}
+ />
+ setCopyTrip(null)}
+ />
+
+ {subOpen && (
+ setSubOpen(false)}
+ />
+ )}
+ >
+ )
+}
+
+// ── Spotlight ────────────────────────────────────────────────────────────────
+function MSpotlightCard({ trip, t, onOpen, actions }: {
+ trip: DashboardTrip
+ t: (key: string, params?: Record) => string
+ onOpen: () => void
+ actions: CardAction[]
+}): React.ReactElement {
+ const status = getTripStatus(trip)
+ const ongoing = status === 'ongoing'
+ const until = daysUntil(trip.start_date)
+
+ // Day-of-trip + total for the ongoing badge and progress bar.
+ let dayOfTrip = 0
+ let totalDays = trip.day_count ?? 0
+ if (trip.start_date && trip.end_date) {
+ const start = new Date(trip.start_date + 'T00:00:00'); start.setHours(0, 0, 0, 0)
+ const end = new Date(trip.end_date + 'T00:00:00'); end.setHours(0, 0, 0, 0)
+ const span = Math.round((end.getTime() - start.getTime()) / MS_PER_DAY) + 1
+ if (!totalDays) totalDays = span
+ if (ongoing) {
+ const today = new Date(); today.setHours(0, 0, 0, 0)
+ dayOfTrip = Math.min(span, Math.max(1, Math.round((today.getTime() - start.getTime()) / MS_PER_DAY) + 1))
+ }
+ }
+ const progress = ongoing && totalDays > 0 ? Math.round((dayOfTrip / totalDays) * 100) : 0
+
+ const primaryBadge = ongoing ? t('dashboard.status.ongoing')
+ : status === 'past' ? t('dashboard.hero.badgeRecent')
+ : t('dashboard.hero.badgeNext')
+ const secondaryBadge = ongoing ? t('dashboard.mobile.spotlightDayOf', { day: dayOfTrip, total: totalDays })
+ : status === 'today' ? t('dashboard.hero.badgeToday')
+ : status === 'tomorrow' ? t('dashboard.hero.badgeTomorrow')
+ : status === 'future' && until !== null ? t('dashboard.mobile.inDays', { count: until })
+ : null
+
+ const days = trip.day_count ?? totalDays
+ const places = trip.place_count ?? 0
+ const people = (trip.shared_count ?? 0) + 1
+
+ return (
+ { if (e.key === 'Enter') onOpen() }}
+ className="relative h-[300px] cursor-pointer overflow-hidden rounded-[26px] shadow-[0_24px_56px_-22px_rgba(0,0,0,.5)]"
+ >
+ {trip.cover_image
+ ?
+ :
}
+
+ {actions.map(a => )}
+
+
{/* theme-lint-disable — fixed dark glass on the cover photo */}
+
+ {/* theme-lint-disable — fixed on-photo badge */}
+ {primaryBadge}
+
+ {secondaryBadge && (
+
+ {secondaryBadge}
+
+ )}
+
+
{trip.title}
+ {ongoing && (
+
+
+
+ )}
+
+ } label={days === 1 ? t('dashboard.mobile.spotlightDayOne', { count: days }) : t('dashboard.mobile.spotlightDaysMany', { count: days })} />
+ } label={places === 1 ? t('dashboard.hero.destinationOne', { count: places }) : t('dashboard.hero.destinationMany', { count: places })} />
+ } label={people === 1 ? t('dashboard.hero.travelerOne', { count: people }) : t('dashboard.hero.travelerMany', { count: people })} />
+
+
+
+ )
+}
+
+function SpotlightPill({ icon, label }: { icon: React.ReactElement; label: string }): React.ReactElement {
+ return (
+
+ {icon}
+ {label}
+
+ )
+}
+
+// ── Trip cards ───────────────────────────────────────────────────────────────
+function CoverActionBtn({ action }: { action: CardAction }): React.ReactElement {
+ return (
+ { e.stopPropagation(); action.onClick() }}
+ className="flex h-[34px] w-[34px] items-center justify-center rounded-full border border-white/30 bg-white/[.22] text-white backdrop-blur-[8px]"
+ >
+ {action.icon}
+
+ )
+}
+
+function CoverBadge({ label, offset }: { label: string; offset: 8 | 12 }): React.ReactElement {
+ return (
+
+
+ {label}
+
+ )
+}
+
+// Plugin-contributed chips on a trip card (tripCardProvider hook). Server-bounded
+// primitives only — same trust model as the desktop TripCardBadges, restyled for
+// the mobile cards since the desktop CSS is scoped to .trek-dash.
+const BADGE_TONE: Record = {
+ default: 'text-m-ink',
+ success: 'text-[color:var(--m-st-confirmed)]',
+ warn: 'text-[color:var(--m-st-pending)]',
+ danger: 'text-[color:var(--m-st-danger)]',
+}
+
+function MTripBadges({ items }: { items: TripCardBadge[] }): React.ReactElement | null {
+ if (!items.length) return null
+ return (
+
+ {items.map(b => {
+ const cls = 'inline-flex max-w-full items-center gap-1 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-2 py-[3px] font-geist text-[0.625rem]'
+ const inner = (
+ <>
+
{b.label}
+ {b.value != null && b.value !== '' &&
{b.value} }
+ >
+ )
+ return b.url
+ ?
e.stopPropagation()}>{inner}
+ :
{inner}
+ })}
+
+ )
+}
+
+function coverStyle(trip: DashboardTrip): React.CSSProperties {
+ return trip.cover_image
+ ? { backgroundImage: `url(${trip.cover_image})`, backgroundSize: 'cover', backgroundPosition: 'center' }
+ : { backgroundImage: entityGradient(trip.id) }
+}
+
+function MTripGridCard({ trip, locale, badge, pluginBadges, actions, onOpen }: {
+ trip: DashboardTrip; locale: string; badge: string; pluginBadges: TripCardBadge[]
+ actions: CardAction[]; onOpen: () => void
+}): React.ReactElement {
+ return (
+ { if (e.key === 'Enter') onOpen() }}
+ className="min-w-0 cursor-pointer overflow-hidden rounded-[20px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)]"
+ >
+
+
+
+
+ {actions.map(a => )}
+
+
+
+
+
{trip.title}
+
+ {fullDate(trip.start_date, locale) ?? '—'}
+
+ {fullDate(trip.end_date, locale) ?? '—'}
+
+
+
+
+
+ )
+}
+
+function MTripListCard({ trip, locale, t, badge, pluginBadges, actions, onOpen }: {
+ trip: DashboardTrip; locale: string
+ t: (key: string, params?: Record) => string
+ badge: string; pluginBadges: TripCardBadge[]; actions: CardAction[]; onOpen: () => void
+}): React.ReactElement {
+ return (
+ { if (e.key === 'Enter') onOpen() }}
+ className="cursor-pointer overflow-hidden rounded-[22px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] shadow-[0_12px_30px_-18px_rgba(0,0,0,.35)]"
+ >
+
+
+
+
+ {actions.map(a => )}
+
+
+ {trip.title}
+
+
+
+
+
+
{fullDate(trip.start_date, locale) ?? '—'}
+
+
{fullDate(trip.end_date, locale) ?? '—'}
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function ListStat({ value, label }: { value: number; label: string }): React.ReactElement {
+ return (
+
+ )
+}
+
+// ── Confirm sheet (delete / copy) ────────────────────────────────────────────
+function MConfirmSheet({ open, title, message, confirmLabel, danger = false, onConfirm, onClose }: {
+ open: boolean; title: string; message: string; confirmLabel: string
+ danger?: boolean; onConfirm: () => void; onClose: () => void
+}): React.ReactElement {
+ const { t } = useTranslation()
+ return (
+
+
+
{title}
+
{message}
+
+
+ {t('common.cancel')}
+
+
+ {confirmLabel}
+
+
+
+
+ )
+}
diff --git a/client/src/mobile/screens/dashboard/MNewTripSheet.tsx b/client/src/mobile/screens/dashboard/MNewTripSheet.tsx
new file mode 100644
index 000000000..6ad544620
--- /dev/null
+++ b/client/src/mobile/screens/dashboard/MNewTripSheet.tsx
@@ -0,0 +1,413 @@
+import React, { useEffect, useRef, useState } from 'react'
+import { Archive, ArchiveRestore, Camera, Search, X } from 'lucide-react'
+import { useTranslation } from '../../../i18n'
+import { tripsApi } from '../../../api/client'
+import { useCanDo } from '../../../store/permissionsStore'
+import { useToast } from '../../../components/shared/Toast'
+import { normalizeImageFile } from '../../../utils/convertHeic'
+import { getApiErrorMessage } from '../../../types'
+import { CustomDatePicker } from '../../../components/shared/CustomDateTimePicker'
+import type { DashboardTrip } from '../../../pages/dashboard/dashboardModel'
+import type { Trip, TripCreateRequest } from '@trek/shared'
+import MSheet from '../../components/MSheet'
+import MIconBtn from '../../components/MIconBtn'
+import MListRow from '../../components/MListRow'
+
+interface CoverSearchPhoto {
+ id: string
+ url: string
+ thumb: string
+ description?: string | null
+ photographer?: string | null
+}
+
+interface MNewTripSheetProps {
+ open: boolean
+ /** null = create, otherwise edit */
+ trip: DashboardTrip | null
+ onClose: () => void
+ onSave: (data: TripCreateRequest) => Promise<{ trip?: Trip } | void> | void
+ onCoverUpdate?: (tripId: number, coverUrl: string | null) => void
+ /** Edit mode only: archives (or restores) the trip — the grid cards have no archive button. */
+ onArchive?: () => void
+}
+
+function FieldLabel({ children }: { children: React.ReactNode }): React.ReactElement {
+ return (
+ {children}
+ )
+}
+
+/**
+ * Create/edit trip sheet — the mobile counterpart of TripFormModal's core flow:
+ * title, date range and Unsplash cover search (plus device upload). Archiving
+ * lives here in edit mode, as decided for the grid cards.
+ */
+export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpdate, onArchive }: MNewTripSheetProps): React.ReactElement {
+ const isEditing = !!trip
+ const { t } = useTranslation()
+ const toast = useToast()
+ const can = useCanDo()
+ const fileRef = useRef(null)
+ const coverSearchSeq = useRef(0)
+ const canEditTrip = !isEditing || can('trip_edit', trip)
+ const canUploadCover = !isEditing || can('trip_cover_upload', trip)
+
+ const [title, setTitle] = useState('')
+ const [description, setDescription] = useState('')
+ const [startDate, setStartDate] = useState('')
+ const [endDate, setEndDate] = useState('')
+ const [error, setError] = useState('')
+ const [isSaving, setIsSaving] = useState(false)
+ const [coverPreview, setCoverPreview] = useState(null)
+ const [pendingCoverFile, setPendingCoverFile] = useState(null)
+ const [pendingUnsplashUrl, setPendingUnsplashUrl] = useState(null)
+ const [uploadingCover, setUploadingCover] = useState(false)
+ const [searchQuery, setSearchQuery] = useState('')
+ const [searchResults, setSearchResults] = useState([])
+ const [searchError, setSearchError] = useState('')
+ const [searching, setSearching] = useState(false)
+
+ useEffect(() => {
+ if (!open) return
+ setTitle(trip?.title || '')
+ setDescription(trip?.description || '')
+ setStartDate(trip?.start_date || '')
+ setEndDate(trip?.end_date || '')
+ setCoverPreview(trip?.cover_image || null)
+ setPendingCoverFile(null)
+ setPendingUnsplashUrl(null)
+ setSearchQuery('')
+ setSearchResults([])
+ setSearchError('')
+ setError('')
+ }, [trip, open])
+
+ // Moving the start keeps the trip length (same rule as TripFormModal).
+ const changeStart = (value: string) => {
+ if (value && endDate && startDate && endDate >= startDate) {
+ const duration = Math.round((new Date(endDate + 'T00:00:00Z').getTime() - new Date(startDate + 'T00:00:00Z').getTime()) / 86400000)
+ const newEnd = new Date(value + 'T00:00:00Z')
+ newEnd.setDate(newEnd.getDate() + duration)
+ setEndDate(newEnd.toISOString().split('T')[0])
+ } else if (value && (!endDate || endDate < value)) {
+ setEndDate(value)
+ }
+ setStartDate(value)
+ }
+
+ const handleSave = async () => {
+ setError('')
+ if (!title.trim()) { setError(t('dashboard.titleRequired')); return }
+ if (startDate && endDate && new Date(endDate) < new Date(startDate)) {
+ setError(t('dashboard.endDateError')); return
+ }
+ setIsSaving(true)
+ try {
+ const result = await onSave({
+ title: title.trim(),
+ description: description.trim() || null,
+ start_date: startDate || null,
+ end_date: endDate || null,
+ ...(!startDate && !endDate && !isEditing ? { day_count: 7 } : {}),
+ })
+ const created = result ? result.trip : undefined
+ if (pendingCoverFile && created?.id) {
+ try {
+ const fd = new FormData()
+ fd.append('cover', pendingCoverFile)
+ const data = await tripsApi.uploadCover(created.id, fd)
+ onCoverUpdate?.(created.id, data.cover_image)
+ } catch {
+ toast.error(t('dashboard.coverUploadError'))
+ }
+ } else if (pendingUnsplashUrl && created?.id) {
+ try {
+ await tripsApi.update(created.id, { cover_image: pendingUnsplashUrl })
+ onCoverUpdate?.(created.id, pendingUnsplashUrl)
+ } catch {
+ toast.error(t('dashboard.coverSaveError'))
+ }
+ }
+ onClose()
+ } catch (err: unknown) {
+ setError(err instanceof Error ? err.message : t('places.saveError'))
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ const handleCoverFile = async (file: File | undefined | null) => {
+ if (!file) return
+ const normalized = await normalizeImageFile(file)
+ setPendingUnsplashUrl(null)
+ if (isEditing && trip?.id) {
+ setUploadingCover(true)
+ try {
+ const fd = new FormData()
+ fd.append('cover', normalized)
+ const data = await tripsApi.uploadCover(trip.id, fd)
+ setCoverPreview(data.cover_image)
+ onCoverUpdate?.(trip.id, data.cover_image)
+ toast.success(t('dashboard.coverSaved'))
+ } catch {
+ toast.error(t('dashboard.coverUploadError'))
+ } finally {
+ setUploadingCover(false)
+ }
+ } else {
+ setPendingCoverFile(normalized)
+ setCoverPreview(URL.createObjectURL(normalized))
+ }
+ }
+
+ const handleSearch = async () => {
+ const query = searchQuery.trim() || title.trim()
+ if (!query) { setSearchError(t('dashboard.unsplashQueryRequired')); return }
+ // Only the latest search may apply its results (out-of-order guard).
+ const seq = ++coverSearchSeq.current
+ setSearching(true)
+ setSearchError('')
+ try {
+ const data = await tripsApi.searchCoverImages(query)
+ if (seq !== coverSearchSeq.current) return
+ const photos: CoverSearchPhoto[] = data.photos || []
+ setSearchResults(photos)
+ if (photos.length === 0) setSearchError(t('dashboard.unsplashNoResults'))
+ } catch (err: unknown) {
+ if (seq !== coverSearchSeq.current) return
+ setSearchError(getApiErrorMessage(err, t('dashboard.coverSearchError')))
+ } finally {
+ if (seq === coverSearchSeq.current) setSearching(false)
+ }
+ }
+
+ const selectUnsplash = async (photo: CoverSearchPhoto) => {
+ if (!photo.url) return
+ setPendingCoverFile(null)
+ if (isEditing && trip?.id) {
+ setUploadingCover(true)
+ try {
+ await tripsApi.update(trip.id, { cover_image: photo.url })
+ setCoverPreview(photo.url)
+ onCoverUpdate?.(trip.id, photo.url)
+ toast.success(t('dashboard.coverSaved'))
+ } catch (err: unknown) {
+ toast.error(getApiErrorMessage(err, t('dashboard.coverSaveError')))
+ } finally {
+ setUploadingCover(false)
+ }
+ } else {
+ setPendingUnsplashUrl(photo.url)
+ setCoverPreview(photo.url)
+ }
+ }
+
+ const removeCover = async () => {
+ if (pendingCoverFile || pendingUnsplashUrl) {
+ setPendingCoverFile(null)
+ setPendingUnsplashUrl(null)
+ setCoverPreview(null)
+ return
+ }
+ if (!trip?.id) return
+ try {
+ await tripsApi.update(trip.id, { cover_image: null })
+ setCoverPreview(null)
+ onCoverUpdate?.(trip.id, null)
+ } catch {
+ toast.error(t('dashboard.coverRemoveError'))
+ }
+ }
+
+ const boxCls = 'rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-[11px_12px]'
+ const inputCls = 'w-full border-none bg-transparent pt-[2px] font-[inherit] text-[0.9375rem] font-semibold text-m-ink outline-none placeholder:text-m-faint'
+
+ return (
+
+
+
+ {isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}
+
+
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ {t('dashboard.tripTitle')}
+ canEditTrip && setTitle(e.target.value)}
+ readOnly={!canEditTrip}
+ placeholder={t('dashboard.tripTitlePlaceholder')}
+ className={inputCls}
+ />
+
+
+
+ {t('dashboard.tripDescription')}
+
+
+
+
+ {t('dashboard.startDate')}
+ { if (canEditTrip) changeStart(v) }}
+ placeholder={t('dashboard.startDate')}
+ borderless
+ style={{ marginTop: 3 }}
+ />
+
+
+ {t('dashboard.endDate')}
+ { if (canEditTrip) setEndDate(v) }}
+ placeholder={t('dashboard.endDate')}
+ borderless
+ style={{ marginTop: 3 }}
+ />
+
+
+ {!isEditing && !startDate && !endDate && (
+
{t('dashboard.noDateHint')}
+ )}
+
+ {canUploadCover && (
+
+
{ handleCoverFile(e.target.files?.[0]); e.target.value = '' }}
+ />
+ {coverPreview ? (
+
+
+
+ fileRef.current?.click()}
+ disabled={uploadingCover}
+ className="flex h-[34px] items-center gap-1 rounded-full border border-white/30 bg-white/[.22] px-3 text-[0.6875rem] font-semibold text-white backdrop-blur-[8px]"
+ >
+
+ {uploadingCover ? t('common.uploading') : t('common.change')}
+
+
+
+
+
+
+ ) : (
+
fileRef.current?.click()}
+ disabled={uploadingCover}
+ className="flex w-full items-center justify-center gap-[6px] rounded-[14px] border border-dashed border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-[18px] text-[0.8125rem] font-medium text-m-muted"
+ >
+
+ {uploadingCover ? t('common.uploading') : t('dashboard.mobile.addCoverImage')}
+
+ )}
+
+
+
+ setSearchQuery(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleSearch() } }}
+ placeholder={t('dashboard.unsplashSearchPlaceholder')}
+ className={`${inputCls} pt-0 text-[0.8125rem] font-medium`}
+ />
+
+
+
+
+
+ {searchError &&
{searchError}
}
+ {searchResults.length > 0 && (
+
+ {searchResults.map(photo => (
+
selectUnsplash(photo)}
+ aria-label={t('dashboard.useUnsplashPhoto', { photographer: photo.photographer || 'Unsplash' })}
+ className={`relative h-20 overflow-hidden rounded-[12px] border ${
+ coverPreview === photo.url ? 'border-[color:var(--m-act)]' : 'border-[color:var(--m-rowbr)]'
+ }`}
+ >
+
+ {photo.photographer && (
+
+ {photo.photographer}
+
+ )}
+
+ ))}
+
+ )}
+
+ )}
+
+ {isEditing && onArchive && (
+
+ { onArchive(); onClose() }}
+ />
+
+ )}
+
+
+
+
+ {t('common.cancel')}
+
+
+ {isSaving ? t('common.saving') : isEditing ? t('common.update') : t('dashboard.createTrip')}
+
+
+
+ )
+}
diff --git a/client/src/mobile/screens/dashboard/MUserMenu.tsx b/client/src/mobile/screens/dashboard/MUserMenu.tsx
new file mode 100644
index 000000000..d23ff2834
--- /dev/null
+++ b/client/src/mobile/screens/dashboard/MUserMenu.tsx
@@ -0,0 +1,93 @@
+import React from 'react'
+import { useNavigate } from 'react-router-dom'
+import { LogOut, Moon, Settings2, Shield, Sun, SunMoon } from 'lucide-react'
+import type { LucideIcon } from 'lucide-react'
+import { useTranslation } from '../../../i18n'
+import { useAuthStore } from '../../../store/authStore'
+import { useSettingsStore } from '../../../store/settingsStore'
+import MDropdownPanel from '../../components/MDropdownPanel'
+import MListRow from '../../components/MListRow'
+
+type ThemeMode = 'dark' | 'light' | 'auto'
+
+const THEME_ICON: Record = {
+ dark: Moon,
+ light: Sun,
+ auto: SunMoon,
+}
+
+// dark_mode is boolean in old accounts and 'light' | 'dark' | 'auto' since the
+// setting grew the auto state — normalize before cycling.
+function resolveMode(darkMode: boolean | string | undefined): ThemeMode {
+ if (darkMode === true || darkMode === 'dark') return 'dark'
+ if (darkMode === 'auto') return 'auto'
+ return 'light'
+}
+
+const NEXT_MODE: Record = { dark: 'light', light: 'auto', auto: 'dark' }
+
+interface MUserMenuProps {
+ open: boolean
+ onClose: () => void
+}
+
+/**
+ * Avatar popover of the mobile top bar: profile header (with admin badge),
+ * settings, admin panel (admins only), the dark/light/auto theme cycle and
+ * sign out.
+ */
+export default function MUserMenu({ open, onClose }: MUserMenuProps): React.ReactElement {
+ const { t } = useTranslation()
+ const navigate = useNavigate()
+ const user = useAuthStore(s => s.user)
+ const logout = useAuthStore(s => s.logout)
+ const darkMode = useSettingsStore(s => s.settings.dark_mode)
+ const updateSetting = useSettingsStore(s => s.updateSetting)
+
+ const mode = resolveMode(darkMode)
+ const ThemeIcon = THEME_ICON[mode]
+ const modeLabel = t(`settings.${mode}`)
+
+ const go = (path: string) => { onClose(); navigate(path) }
+ const cycleTheme = () => { updateSetting('dark_mode', NEXT_MODE[mode]).catch(() => {}) }
+ const signOut = async () => {
+ onClose()
+ await logout()
+ navigate('/login')
+ }
+
+ return (
+
+
+
+ {user?.avatar_url
+ ?
+ : (user?.username || '?')[0].toUpperCase()}
+
+
+
+ {user?.username}
+ {user?.role === 'admin' && (
+
+ {t('nav.bottomAdminBadge')}
+
+ )}
+
+
{user?.email}
+
+
+ go('/settings')} />
+ {user?.role === 'admin' && (
+ go('/admin')} />
+ )}
+ {modeLabel}}
+ />
+
+
+
+ )
+}
diff --git a/client/src/mobile/screens/journey/MJourney.tsx b/client/src/mobile/screens/journey/MJourney.tsx
new file mode 100644
index 000000000..2bef17c92
--- /dev/null
+++ b/client/src/mobile/screens/journey/MJourney.tsx
@@ -0,0 +1,172 @@
+import { Plus, NotebookPen, Image, MapPin } from 'lucide-react'
+import { useTranslation } from '../../../i18n'
+import { useJourney } from '../../../pages/journey/useJourney'
+import type { Journey } from '../../../store/journeyStore'
+import MJourneyCreateSheet from './MJourneyCreateSheet'
+import MDancingTrek from '../../components/MDancingTrek'
+import { journeyCoverSrc, journeyCoverStyle } from './mobileJourneyMeta'
+
+type JourneyListItem = Journey & {
+ entry_count?: number
+ photo_count?: number
+ place_count?: number
+}
+
+/** Journey list — hero card for the latest journey plus a 2-column grid with counters. */
+export default function MJourney() {
+ const { t } = useTranslation()
+ const {
+ navigate, journeys, loading,
+ showCreate, setShowCreate, newTitle, setNewTitle,
+ availableTrips, selectedTripIds, setSelectedTripIds,
+ openCreateModal, handleCreate, activeJourney,
+ } = useJourney()
+
+ const list = journeys as JourneyListItem[]
+ const hero = (activeJourney as JourneyListItem | null) ?? list[0] ?? null
+ const rest = list.filter(j => j.id !== hero?.id)
+
+ return (
+
+ {/* Floating header: back + create pill */}
+
+
openCreateModal()}
+ className="flex h-[38px] flex-1 items-center justify-center gap-[6px] whitespace-nowrap rounded-full bg-m-act px-[15px] text-[0.78125rem] font-bold text-m-actfg"
+ >
+
+ {t('journey.frontpage.createJourney')}
+
+
+
+
+ {loading && list.length === 0 ? (
+
+ ) : list.length === 0 ? (
+
+
+
{t('journey.frontpage.createNew')}
+
+ ) : (
+ <>
+ {hero &&
navigate(`/journey/${hero.id}`)} />}
+
+ {rest.length > 0 && (
+ <>
+
+
+
+ {t('mobileJourney.otherJourneys')}
+
+
+
+
+ {rest.map(j => (
+ navigate(`/journey/${j.id}`)} />
+ ))}
+
+ >
+ )}
+ >
+ )}
+
+
+
+ setSelectedTripIds(prev => {
+ const next = new Set(prev)
+ if (next.has(id)) next.delete(id)
+ else next.add(id)
+ return next
+ })
+ }
+ onCreate={handleCreate}
+ onClose={() => setShowCreate(false)}
+ />
+
+ )
+}
+
+function HeroCard({ journey, onOpen }: { journey: JourneyListItem; onOpen: () => void }) {
+ const { t } = useTranslation()
+ const src = journeyCoverSrc(journey.cover_image)
+
+ return (
+
+ {src ? (
+
+ ) : (
+
+ )}
+
+
+ {t('mobileJourney.latestJourney')}
+
+
+
{journey.title}
+
+
+
+ {t('mobileJourney.entriesCount', { count: journey.entry_count ?? 0 })}
+
+
+
+ {t('mobileJourney.photosCount', { count: journey.photo_count ?? 0 })}
+
+
+
+ {t('mobileJourney.placesCount', { count: journey.place_count ?? 0 })}
+
+
+
+
+ )
+}
+
+function GridCard({ journey, onOpen }: { journey: JourneyListItem; onOpen: () => void }) {
+ const entries = journey.entry_count ?? 0
+ const photos = journey.photo_count ?? 0
+ const places = journey.place_count ?? 0
+ const hasStats = entries + photos + places > 0
+
+ return (
+
+
+
+
{journey.title}
+ {hasStats && (
+
+
+
+ {entries}
+
+
+
+ {photos}
+
+
+
+ {places}
+
+
+ )}
+
+
+ )
+}
diff --git a/client/src/mobile/screens/journey/MJourneyCreateSheet.tsx b/client/src/mobile/screens/journey/MJourneyCreateSheet.tsx
new file mode 100644
index 000000000..7aec524e6
--- /dev/null
+++ b/client/src/mobile/screens/journey/MJourneyCreateSheet.tsx
@@ -0,0 +1,141 @@
+import { Feather, Calendar, MapPin, Check, X } from 'lucide-react'
+import MSheet from '../../components/MSheet'
+import MIconBtn from '../../components/MIconBtn'
+import { useTranslation } from '../../../i18n'
+import { pickGradient } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
+
+export interface CreateSheetTrip {
+ id: number
+ title: string
+ start_date?: string | null
+ end_date?: string | null
+ place_count?: number
+ cover_image?: string | null
+}
+
+interface MJourneyCreateSheetProps {
+ open: boolean
+ title: string
+ onTitleChange: (value: string) => void
+ trips: CreateSheetTrip[]
+ selectedTripIds: Set
+ onToggleTrip: (id: number) => void
+ onCreate: () => void
+ onClose: () => void
+}
+
+function tripDays(trip: CreateSheetTrip): number | null {
+ if (!trip.start_date) return null
+ const end = new Date(trip.end_date || trip.start_date).getTime()
+ const start = new Date(trip.start_date).getTime()
+ return Math.ceil((end - start) / 86400000) + 1
+}
+
+/** shJCreate — journey name + multi-select trip cards with a bouncing check circle. */
+export default function MJourneyCreateSheet({
+ open, title, onTitleChange, trips, selectedTripIds, onToggleTrip, onCreate, onClose,
+}: MJourneyCreateSheetProps) {
+ const { t } = useTranslation()
+
+ return (
+
+
+
{t('journey.frontpage.createJourney')}
+
+
+
+
+
+
+
+ {t('journey.frontpage.journeyName')}
+
+
+
+ onTitleChange(e.target.value)}
+ placeholder={t('journey.frontpage.namePlaceholder')}
+ className="min-w-0 flex-1 bg-transparent text-[0.875rem] font-semibold text-m-ink outline-none placeholder:text-m-faint"
+ />
+
+
+
+ {t('journey.frontpage.selectTrips')}
+
+ {trips.length === 0 && (
+
{t('journey.trips.noTripsAvailable')}
+ )}
+ {trips.map(trip => {
+ const selected = selectedTripIds.has(trip.id)
+ const days = tripDays(trip)
+ return (
+
onToggleTrip(trip.id)}
+ className={`mb-[9px] flex w-full items-center gap-3 rounded-2xl bg-m-sheetop p-[10px] text-left transition-[border-color,box-shadow] duration-200 ${
+ selected
+ ? 'border-[1.5px] border-[color:var(--m-act)] shadow-[0_10px_26px_-16px_rgba(0,0,0,.55)]'
+ : 'border border-[color:var(--m-rowbr)] shadow-[0_8px_22px_-18px_rgba(0,0,0,.5)]'
+ }`}
+ >
+
+
+
+
+
{trip.title}
+
+ {days !== null && (
+
+
+ {t('mobileJourney.daysCount', { count: days })}
+
+ )}
+
+
+ {t('mobileJourney.placesCount', { count: trip.place_count ?? 0 })}
+
+
+
+
+
+
+
+ )
+ })}
+
+
+
+
+ {t('common.cancel')}
+
+
+ {t('journey.create')}
+
+
+
+ )
+}
diff --git a/client/src/mobile/screens/journey/MJourneyDetail.tsx b/client/src/mobile/screens/journey/MJourneyDetail.tsx
new file mode 100644
index 000000000..8f4f6aea4
--- /dev/null
+++ b/client/src/mobile/screens/journey/MJourneyDetail.tsx
@@ -0,0 +1,469 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { ChevronLeft, MapPin, Grid3x3, Upload, MoreHorizontal, Play, Image, Camera } from 'lucide-react'
+import JourneyMap from '../../../components/Journey/JourneyMapAuto'
+import type { JourneyMapAutoHandle } from '../../../components/Journey/JourneyMapAuto'
+import PhotoLightbox from '../../../components/Journey/PhotoLightbox'
+import ContributorInviteDialog from '../../../components/Journey/ContributorInviteDialog'
+import ConfirmDialog from '../../../components/shared/ConfirmDialog'
+import { ProviderPicker } from '../../../components/Journey/JourneyDetailPageProviderPicker'
+import { photoUrl } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
+import { useJourneyDetail } from '../../../pages/journeyDetail/useJourneyDetail'
+import { useJourneyStore } from '../../../store/journeyStore'
+import type { JourneyEntry, GalleryPhoto } from '../../../store/journeyStore'
+import { useAuthStore } from '../../../store/authStore'
+import { journeyApi, addonsApi } from '../../../api/client'
+import { normalizeImageFiles } from '../../../utils/convertHeic'
+import { isVideoFile } from '../../../utils/videoPoster'
+import { getApiErrorMessage } from '../../../types'
+import MSheet from '../../components/MSheet'
+import MDancingTrek from '../../components/MDancingTrek'
+import MListRow from '../../components/MListRow'
+import MJourneyEntryCard from './MJourneyEntryCard'
+import MJourneyEntrySheet from './MJourneyEntrySheet'
+import MJourneySettingsSheet from './MJourneySettingsSheet'
+
+/**
+ * Journey detail — integrated map with the horizontal 280px card timeline
+ * (Journey tab) and the 2-column photo gallery (Gallery tab). Cards and map
+ * markers stay in sync; tapping the centered card opens the entry sheet.
+ */
+export default function MJourneyDetail() {
+ const {
+ id, navigate, toast, t,
+ current, loading,
+ canEditEntries, canEditJourney,
+ view, setView,
+ editingEntry, setEditingEntry,
+ lightbox, setLightbox, deleteTarget, setDeleteTarget,
+ showInvite, setShowInvite,
+ showSettings, setShowSettings,
+ hideSkeletons,
+ sidebarMapItems,
+ loadJourney, updateEntry, deleteEntry, uploadPhotos,
+ } = useJourneyDetail()
+
+ const mapRef = useRef(null)
+ const carouselRef = useRef(null)
+ const cardRefs = useRef>(new Map())
+ const [activeIndex, setActiveIndex] = useState(0)
+
+ const entries = (current?.entries || []).filter(e => !hideSkeletons || e.type !== 'skeleton')
+
+ const syncMapToCard = useCallback((index: number) => {
+ const entry = entries[index]
+ if (!entry) return
+ const mapEntry = sidebarMapItems.find(m => String(m.id) === String(entry.id))
+ try {
+ if (mapEntry) mapRef.current?.focusMarker(String(mapEntry.id))
+ else mapRef.current?.highlightMarker(null)
+ } catch { /* map not initialised yet */ }
+ }, [entries, sidebarMapItems])
+
+ // Pick the card closest to the horizontal center once scrolling settles.
+ const pickNearestCard = useCallback(() => {
+ const el = carouselRef.current
+ if (!el) return
+ const center = el.getBoundingClientRect().left + el.clientWidth / 2
+ let bestIdx = 0
+ let bestDist = Infinity
+ cardRefs.current.forEach((node, idx) => {
+ const r = node.getBoundingClientRect()
+ const d = Math.abs(r.left + r.width / 2 - center)
+ if (d < bestDist) { bestDist = d; bestIdx = idx }
+ })
+ setActiveIndex(prev => {
+ if (prev !== bestIdx) syncMapToCard(bestIdx)
+ return bestIdx
+ })
+ }, [syncMapToCard])
+
+ useEffect(() => {
+ const el = carouselRef.current
+ if (!el || entries.length === 0) return
+ let settleTimer: number | null = null
+ const onScroll = () => {
+ if (settleTimer != null) window.clearTimeout(settleTimer)
+ settleTimer = window.setTimeout(pickNearestCard, 150)
+ }
+ el.addEventListener('scroll', onScroll, { passive: true })
+ return () => {
+ el.removeEventListener('scroll', onScroll)
+ if (settleTimer != null) window.clearTimeout(settleTimer)
+ }
+ }, [entries.length, pickNearestCard])
+
+ // Initial focus — give Leaflet time to initialise and fit bounds first.
+ useEffect(() => {
+ if (entries.length === 0) return
+ const timer = window.setTimeout(() => syncMapToCard(0), 500)
+ return () => window.clearTimeout(timer)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [entries.length])
+
+ const scrollCardIntoCenter = useCallback((idx: number) => {
+ cardRefs.current.get(idx)?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
+ }, [])
+
+ const handleMarkerClick = useCallback((markerId: string) => {
+ const idx = entries.findIndex(e => String(e.id) === markerId)
+ if (idx === -1) return
+ setActiveIndex(idx)
+ scrollCardIntoCenter(idx)
+ }, [entries, scrollCardIntoCenter])
+
+ const handleCardTap = (entry: JourneyEntry, idx: number) => {
+ if (idx === activeIndex) setEditingEntry(entry)
+ else {
+ setActiveIndex(idx)
+ scrollCardIntoCenter(idx)
+ syncMapToCard(idx)
+ }
+ }
+
+ // Gallery upload — device files plus the connected photo providers (Immich/Synology).
+ const galleryFileRef = useRef(null)
+ const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([])
+ const [showUploadMenu, setShowUploadMenu] = useState(false)
+ const [pickerProvider, setPickerProvider] = useState(null)
+ const [uploading, setUploading] = useState(false)
+
+ useEffect(() => {
+ (async () => {
+ try {
+ const addonsData = await addonsApi.enabled()
+ const enabled = (addonsData.addons || []).filter(
+ (a: { type: string; enabled: boolean }) => a.type === 'photo_provider' && a.enabled,
+ )
+ const connected: { id: string; name: string }[] = []
+ for (const p of enabled) {
+ try {
+ const res = await fetch(`/api/integrations/memories/${p.id}/status`, { credentials: 'include' })
+ if (res.ok && (await res.json()).connected) connected.push({ id: p.id, name: p.name })
+ } catch { /* provider stays hidden */ }
+ }
+ setAvailableProviders(connected)
+ } catch { /* no providers */ }
+ })()
+ }, [])
+
+ const handleGalleryUpload = async (e: React.ChangeEvent) => {
+ const files = e.target.files
+ if (!files?.length || !current) return
+ setUploading(true)
+ try {
+ const all = Array.from(files)
+ const videos = all.filter(isVideoFile)
+ const images = all.filter(f => !isVideoFile(f))
+ const normalized = [...(images.length ? await normalizeImageFiles(images) : []), ...videos]
+ const { failed } = await useJourneyStore.getState().uploadGalleryPhotos(current.id, normalized)
+ if (failed.length > 0) {
+ toast.error(t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(normalized.length) }))
+ } else {
+ toast.success(t('journey.photosUploaded', { count: String(files.length) }))
+ }
+ loadJourney(Number(id))
+ } catch (err) {
+ toast.error(getApiErrorMessage(err, t('journey.photosUploadFailed')))
+ } finally {
+ setUploading(false)
+ }
+ e.target.value = ''
+ }
+
+ const openLightbox = (photos: GalleryPhoto[], index: number) => {
+ setLightbox({
+ photos: photos.map(p => ({
+ id: p.id,
+ src: photoUrl(p, 'original'),
+ caption: p.caption ?? null,
+ provider: p.provider,
+ asset_id: p.asset_id,
+ owner_id: p.owner_id,
+ mediaType: p.media_type,
+ })),
+ index,
+ })
+ }
+
+ if (loading || !current) {
+ return (
+
+ )
+ }
+
+ const gallery = current.gallery || []
+ const dark = document.documentElement.classList.contains('dark')
+
+ return (
+
+ {/* Integrated map — always mounted, the gallery overlays it */}
+
+
+
+
+ {/* Gallery tab overlay */}
+ {view === 'gallery' && (
+
+ {gallery.length === 0 ? (
+
+
+
{t('journey.detail.noPhotos')}
+
+ ) : (
+
+ {gallery.map((photo, i) => (
+
openLightbox(gallery, i)}
+ className="relative aspect-square overflow-hidden rounded-[14px]"
+ >
+ {photo.media_type === 'video' && !photo.thumbnail_path ? (
+
+ ) : (
+
+ )}
+ {photo.media_type === 'video' && (
+
+
+
+
+
+ )}
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* Header: back — segment — upload/settings */}
+
+
navigate('/journey')}
+ aria-label={t('journey.detail.backToJourney')}
+ className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]"
+ >
+
+
+
+ setView('timeline')}
+ className={`flex items-center gap-[5px] rounded-full px-[13px] py-[6px] text-[0.75rem] font-bold ${
+ view === 'timeline' ? 'bg-m-act text-m-actfg' : 'text-m-muted'
+ }`}
+ >
+
+ {t('journey.detail.journeyTab')}
+
+ setView('gallery')}
+ className={`flex items-center gap-[5px] rounded-full px-[13px] py-[6px] text-[0.75rem] font-bold ${
+ view === 'gallery' ? 'bg-m-act text-m-actfg' : 'text-m-muted'
+ }`}
+ >
+
+ {t('journey.share.gallery')}
+
+
+
+ {view === 'gallery' && canEditEntries && (
+ (availableProviders.length > 0 ? setShowUploadMenu(true) : galleryFileRef.current?.click())}
+ disabled={uploading}
+ aria-label={t('common.upload')}
+ className="flex h-[38px] w-[38px] items-center justify-center rounded-full bg-m-act text-m-actfg shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)] disabled:opacity-60"
+ >
+ {uploading ? (
+
+ ) : (
+
+ )}
+
+ )}
+ {view === 'timeline' && canEditJourney && (
+ setShowSettings(true)}
+ aria-label={t('journey.settings.title')}
+ className="flex h-[38px] w-[38px] items-center justify-center rounded-full bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]"
+ >
+
+
+ )}
+
+
+
+ {/* Horizontal card timeline */}
+ {view === 'timeline' && entries.length > 0 && (
+
+ {entries.map((entry, i) => (
+
{ if (node) cardRefs.current.set(i, node); else cardRefs.current.delete(i) }}
+ style={{ scrollSnapAlign: 'center' }}
+ >
+ handleCardTap(entry, i)} />
+
+ ))}
+
+ )}
+
+
+
+ {/* Upload source chooser (device / providers) */}
+
setShowUploadMenu(false)} variant="bottom" ariaLabel={t('common.upload')}>
+
+ { setShowUploadMenu(false); galleryFileRef.current?.click() }}
+ />
+ {availableProviders.map(p => (
+ { setShowUploadMenu(false); setPickerProvider(p.id) }}
+ />
+ ))}
+
+
+
+ {/* Entry sheet (new / edit / read-only) */}
+ {editingEntry && (
+
setEditingEntry(null)}
+ onSave={async data => {
+ let entryId = editingEntry.id
+ if (editingEntry.id === 0) {
+ const created = await useJourneyStore.getState().createEntry(current.id, data)
+ entryId = created.id
+ } else {
+ await updateEntry(editingEntry.id, data)
+ }
+ return entryId
+ }}
+ onUploadPhotos={uploadPhotos}
+ onDelete={editingEntry.id > 0 && canEditEntries
+ ? () => { const target = editingEntry; setEditingEntry(null); setDeleteTarget(target) }
+ : undefined}
+ onDone={() => {
+ setEditingEntry(null)
+ loadJourney(Number(id))
+ }}
+ />
+ )}
+
+ {/* Journey settings */}
+ {showSettings && (
+ setShowSettings(false)}
+ onSaved={() => { setShowSettings(false); loadJourney(Number(id)) }}
+ onOpenInvite={() => setShowInvite(true)}
+ onRefresh={() => loadJourney(Number(id))}
+ />
+ )}
+
+ {/* Contributor invite */}
+ {showInvite && (
+ c.user_id)}
+ onClose={() => setShowInvite(false)}
+ onInvited={() => { setShowInvite(false); loadJourney(Number(id)) }}
+ />
+ )}
+
+ {/* Provider photo picker (Immich / Synology) */}
+ {pickerProvider && (
+ e.type !== 'skeleton' || e.title)}
+ trips={current.trips}
+ existingAssetIds={new Set(gallery.filter(p => p.asset_id).map(p => p.asset_id!))}
+ onClose={() => setPickerProvider(null)}
+ onAdd={async (groups, entryId) => {
+ let added = 0
+ let anyFailed = false
+ for (const group of groups) {
+ try {
+ const result = entryId
+ ? await journeyApi.addProviderPhotos(entryId, pickerProvider, group.assetIds, undefined, group.passphrase, group.mediaTypes)
+ : await journeyApi.addProviderPhotosToGallery(current.id, pickerProvider, group.assetIds, group.passphrase, group.mediaTypes)
+ added += result.added || 0
+ } catch {
+ anyFailed = true
+ }
+ }
+ if (added > 0) {
+ toast.success(t('journey.photosAdded', { count: added }))
+ loadJourney(Number(id))
+ } else if (anyFailed) {
+ toast.error(t('common.error'))
+ }
+ setPickerProvider(null)
+ }}
+ />
+ )}
+
+ {/* Delete entry confirm */}
+ setDeleteTarget(null)}
+ onConfirm={async () => {
+ if (!deleteTarget) return
+ await deleteEntry(deleteTarget.id)
+ setDeleteTarget(null)
+ loadJourney(Number(id))
+ }}
+ title={t('journey.entries.deleteTitle')}
+ message={t('journey.deleteConfirmMessage', { title: deleteTarget?.title || '' })}
+ confirmLabel={t('common.delete')}
+ danger
+ />
+
+ {/* Lightbox */}
+ {lightbox && (
+ ({
+ id: p.id.toString(),
+ src: p.src,
+ caption: p.caption,
+ provider: p.provider,
+ asset_id: p.asset_id,
+ owner_id: p.owner_id,
+ mediaType: p.mediaType,
+ }))}
+ startIndex={lightbox.index}
+ onClose={() => setLightbox(null)}
+ />
+ )}
+
+ )
+}
diff --git a/client/src/mobile/screens/journey/MJourneyEntryCard.tsx b/client/src/mobile/screens/journey/MJourneyEntryCard.tsx
new file mode 100644
index 000000000..6635507b0
--- /dev/null
+++ b/client/src/mobile/screens/journey/MJourneyEntryCard.tsx
@@ -0,0 +1,90 @@
+import { MapPin } from 'lucide-react'
+import { useTranslation } from '../../../i18n'
+import { formatLocationName } from '../../../utils/formatters'
+import { stripMarkdown } from '../../../components/Journey/stripMarkdown'
+import type { JourneyEntry } from '../../../store/journeyStore'
+import { moodMeta, weatherMeta } from './mobileJourneyMeta'
+
+interface MJourneyEntryCardProps {
+ entry: JourneyEntry
+ number: number
+ onClick: () => void
+}
+
+/** One 280px card of the horizontal journey timeline (photo column, number badge, mood/weather dots). */
+export default function MJourneyEntryCard({ entry, number, onClick }: MJourneyEntryCardProps) {
+ const { t, locale } = useTranslation()
+ const firstPhoto = entry.photos?.[0]
+ const mood = moodMeta(entry.mood)
+ const weather = weatherMeta(entry.weather)
+ const location = formatLocationName(entry.location_name)
+
+ const dateLabel = new Date(entry.entry_date + 'T00:00:00').toLocaleDateString(locale, {
+ day: 'numeric',
+ month: 'short',
+ })
+ const storyPreview = entry.story ? stripMarkdown(entry.story) : ''
+ const title = entry.title || (entry.type === 'checkin' ? t('journey.detail.journeyTab') : t('journey.editor.titlePlaceholder'))
+
+ return (
+
+ {firstPhoto && (
+
+
+ {location && (
+
+
+ {location}
+
+ )}
+
+ )}
+
+
+
+ {number}
+
+
+ {dateLabel}
+
+
+ {mood && (
+
+
+
+ )}
+ {weather && (
+
+
+
+ )}
+
+
+ {title}
+ {storyPreview && (
+
+ {storyPreview}
+
+ )}
+ {!firstPhoto && location && (
+
+
+ {location}
+
+ )}
+
+
+ )
+}
diff --git a/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx b/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx
new file mode 100644
index 000000000..773a5e99e
--- /dev/null
+++ b/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx
@@ -0,0 +1,605 @@
+import { useRef, useState } from 'react'
+import { Plus, Image, X, MapPin, Trash2, CheckCircle2, MinusCircle } from 'lucide-react'
+import MSheet from '../../components/MSheet'
+import MIconBtn from '../../components/MIconBtn'
+import { useTranslation } from '../../../i18n'
+import { useToast } from '../../../components/shared/Toast'
+import { journeyApi, mapsApi } from '../../../api/client'
+import { getApiErrorMessage } from '../../../types'
+import { normalizeImageFiles } from '../../../utils/convertHeic'
+import type { ResilientResult, UploadProgress } from '../../../utils/uploadQueue'
+import type { JourneyEntry, JourneyPhoto, GalleryPhoto } from '../../../store/journeyStore'
+import { photoUrl } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
+import JournalBody from '../../../components/Journey/JournalBody'
+import { MOBILE_MOODS, MOBILE_WEATHERS } from './mobileJourneyMeta'
+
+const PRO_COLOR = '#2FA37A'
+const CON_COLOR = '#D6273B'
+
+interface LocationResult {
+ name: string
+ address?: string
+ lat: number
+ lng: number
+}
+
+interface MJourneyEntrySheetProps {
+ entry: JourneyEntry
+ galleryPhotos: GalleryPhoto[]
+ readOnly?: boolean
+ onClose: () => void
+ onSave: (data: Record) => Promise
+ onUploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise>
+ onDelete?: () => void
+ onDone: () => void
+}
+
+/**
+ * shJEntry — the journey entry sheet: title, photos (upload / from gallery),
+ * markdown story, pros & cons, date + time, location search, mood (4),
+ * weather (6) and tags. Read-only for viewer contributors.
+ */
+export default function MJourneyEntrySheet({
+ entry, galleryPhotos, readOnly = false, onClose, onSave, onUploadPhotos, onDelete, onDone,
+}: MJourneyEntrySheetProps) {
+ const { t } = useTranslation()
+ const toast = useToast()
+
+ const [title, setTitle] = useState(entry.title || '')
+ const [story, setStory] = useState(entry.story || '')
+ const [entryDate, setEntryDate] = useState(entry.entry_date || new Date().toISOString().split('T')[0])
+ const [entryTime, setEntryTime] = useState(entry.entry_time?.slice(0, 5) || '')
+ const [locationName, setLocationName] = useState(entry.location_name || '')
+ const [locationLat, setLocationLat] = useState(entry.location_lat ?? null)
+ const [locationLng, setLocationLng] = useState(entry.location_lng ?? null)
+ const [locationQuery, setLocationQuery] = useState('')
+ const [locationResults, setLocationResults] = useState([])
+ const [showLocationResults, setShowLocationResults] = useState(false)
+ const locationTimerRef = useRef | null>(null)
+ const [mood, setMood] = useState(entry.mood || '')
+ const [weather, setWeather] = useState(entry.weather || '')
+ const [pros, setPros] = useState(entry.pros_cons?.pros ?? [])
+ const [cons, setCons] = useState(entry.pros_cons?.cons ?? [])
+ const [tags, setTags] = useState(entry.tags ?? [])
+ const [tagInput, setTagInput] = useState('')
+ const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || [])
+ const [pendingFiles, setPendingFiles] = useState([])
+ const [pendingLinkIds, setPendingLinkIds] = useState([])
+ const [showGalleryPick, setShowGalleryPick] = useState(false)
+ const [saving, setSaving] = useState(false)
+ const [uploadProgress, setUploadProgress] = useState<{ done: number; total: number } | null>(null)
+ const fileRef = useRef(null)
+
+ const isDirty =
+ title !== (entry.title || '') ||
+ story !== (entry.story || '') ||
+ entryDate !== (entry.entry_date || new Date().toISOString().split('T')[0]) ||
+ entryTime !== (entry.entry_time?.slice(0, 5) || '') ||
+ locationName !== (entry.location_name || '') ||
+ mood !== (entry.mood || '') ||
+ weather !== (entry.weather || '') ||
+ pros.filter(p => p.trim()).join('\n') !== (entry.pros_cons?.pros ?? []).join('\n') ||
+ cons.filter(c => c.trim()).join('\n') !== (entry.pros_cons?.cons ?? []).join('\n') ||
+ tags.join('\n') !== (entry.tags ?? []).join('\n') ||
+ pendingFiles.length > 0 ||
+ pendingLinkIds.length > 0
+
+ const handleClose = () => {
+ if (!readOnly && isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
+ onClose()
+ }
+
+ const handleSave = async () => {
+ setSaving(true)
+ try {
+ const entryId = await onSave({
+ title: title || null,
+ story: story || null,
+ entry_date: entryDate,
+ entry_time: entryTime || null,
+ location_name: locationName || null,
+ location_lat: locationLat,
+ location_lng: locationLng,
+ mood: mood || null,
+ weather: weather || null,
+ tags: tags.filter(tag => tag.trim()),
+ pros_cons: { pros: pros.filter(p => p.trim()), cons: cons.filter(c => c.trim()) },
+ type: entry.type === 'skeleton' && (story.trim() || pendingFiles.length > 0 || pendingLinkIds.length > 0)
+ ? 'entry'
+ : undefined,
+ })
+ if (pendingFiles.length > 0 && entryId) {
+ const toUpload = pendingFiles
+ setUploadProgress({ done: 0, total: toUpload.length })
+ try {
+ const { failed } = await onUploadPhotos(entryId, toUpload, {
+ onProgress: p => setUploadProgress({ done: p.done, total: p.total }),
+ })
+ setPendingFiles(failed)
+ if (failed.length > 0) {
+ toast.error(t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(toUpload.length) }))
+ }
+ } catch (err) {
+ toast.error(getApiErrorMessage(err, t('journey.editor.uploadFailed')))
+ } finally {
+ setUploadProgress(null)
+ }
+ }
+ if (pendingLinkIds.length > 0 && entryId) {
+ for (const photoId of pendingLinkIds) {
+ try { await journeyApi.linkPhoto(entryId, photoId) } catch { /* linked photo stays in gallery */ }
+ }
+ }
+ onDone()
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleFileChange = async (e: React.ChangeEvent) => {
+ const files = e.target.files
+ if (!files?.length) return
+ const normalized = await normalizeImageFiles(files)
+ setPendingFiles(prev => [...prev, ...normalized])
+ }
+
+ const searchLocation = (query: string) => {
+ setLocationQuery(query)
+ setShowLocationResults(true)
+ if (locationTimerRef.current) clearTimeout(locationTimerRef.current)
+ if (query.trim().length < 2) {
+ setLocationResults([])
+ return
+ }
+ locationTimerRef.current = setTimeout(async () => {
+ try {
+ const res = await mapsApi.search(query)
+ setLocationResults((res.places || []).slice(0, 6).map((p: { name: string; address?: string; lat: number | string; lng: number | string }) => ({
+ name: p.name, address: p.address, lat: Number(p.lat), lng: Number(p.lng),
+ })))
+ } catch {
+ setLocationResults([])
+ }
+ }, 400)
+ }
+
+ const addTag = () => {
+ const value = tagInput.trim().replace(/,+$/, '')
+ if (!value) return
+ if (!tags.includes(value)) setTags(prev => [...prev, value])
+ setTagInput('')
+ }
+
+ const availableGalleryPhotos = galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id))
+
+ const eyebrow = 'font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint'
+ const fieldShell = 'rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)]'
+
+ return (
+
+
+
+ {entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}
+
+
+
+
+
+
+
+ {readOnly ? (
+
{title || t('journey.editor.titlePlaceholder')}
+ ) : (
+
setTitle(e.target.value)}
+ placeholder={t('journey.editor.titlePlaceholder')}
+ className="w-full bg-transparent pb-[10px] pt-1 text-[1.25rem] font-extrabold text-m-ink outline-none placeholder:text-m-faint"
+ />
+ )}
+
+ {!readOnly && (
+ <>
+
{ (e.target as HTMLInputElement).value = '' }} />
+
+
fileRef.current?.click()}
+ disabled={saving}
+ className="flex flex-1 items-center justify-center gap-[6px] rounded-[14px] border-[1.5px] border-dashed border-[color:var(--m-rowbr)] p-3 text-[0.75rem] font-semibold text-m-muted disabled:opacity-50"
+ >
+ {uploadProgress ? (
+ <>
+
+ {t('journey.editor.uploadingProgress', { done: String(uploadProgress.done), total: String(uploadProgress.total) })}
+ >
+ ) : (
+ <>
+
+ {t('journey.editor.uploadPhotos')}
+ >
+ )}
+
+
setShowGalleryPick(v => !v)}
+ disabled={galleryPhotos.length === 0}
+ className={`flex flex-1 items-center justify-center gap-[6px] rounded-[14px] border-[1.5px] p-3 text-[0.75rem] font-semibold disabled:opacity-40 ${
+ showGalleryPick
+ ? 'border-[color:var(--m-act)] text-m-ink'
+ : 'border-dashed border-[color:var(--m-rowbr)] text-m-muted'
+ }`}
+ >
+
+ {t('journey.editor.fromGallery')}
+
+
+
+ {showGalleryPick && (
+
+
+ {availableGalleryPhotos.map(gp => (
+
{
+ if (entry.id > 0) {
+ try {
+ const linked = await journeyApi.linkPhoto(entry.id, gp.id)
+ if (linked) setPhotos(prev => [...prev, linked])
+ } catch { /* keep picker open on failure */ }
+ } else {
+ setPendingLinkIds(prev => [...prev, gp.id])
+ setPhotos(prev => [...prev, gp])
+ }
+ }}
+ >
+
+
+ ))}
+ {availableGalleryPhotos.length === 0 && (
+
+ {t('journey.editor.allPhotosAdded')}
+
+ )}
+
+
+ )}
+ >
+ )}
+
+ {(photos.length > 0 || pendingFiles.length > 0) && (
+
+ {photos.map((p, idx) => (
+
+
+ {!readOnly && idx > 0 && photos.length > 1 && (
+
{
+ setPhotos(prev => {
+ const next = [...prev]
+ const [moved] = next.splice(idx, 1)
+ next.unshift(moved)
+ next.forEach((ph, i) => { journeyApi.updatePhoto(ph.id, { sort_order: i }).catch(() => {}) })
+ return next
+ })
+ }}
+ className="absolute bottom-[3px] left-[3px] rounded-full bg-black/60 px-[6px] py-[1px] font-geist text-[0.5rem] font-bold text-white"
+ >
+ {t('journey.editor.photoFirst')}
+
+ )}
+ {!readOnly && (
+
{
+ setPhotos(prev => prev.filter(x => x.id !== p.id))
+ if (entry.id > 0) {
+ try { await journeyApi.unlinkPhoto(entry.id, p.id) } catch { /* refreshed on next load */ }
+ } else {
+ setPendingLinkIds(prev => prev.filter(id => id !== p.id))
+ }
+ }}
+ aria-label={t('common.delete')}
+ className="absolute right-[3px] top-[3px] flex h-5 w-5 items-center justify-center rounded-full bg-black/60 text-white"
+ >
+
+
+ )}
+
+ ))}
+ {pendingFiles.map((f, i) => (
+
+
+
setPendingFiles(prev => prev.filter((_, j) => j !== i))}
+ aria-label={t('common.delete')}
+ className="absolute right-[3px] top-[3px] flex h-5 w-5 items-center justify-center rounded-full bg-black/60 text-white"
+ >
+
+
+
+ ))}
+
+ )}
+
+ {readOnly ? (
+ story && (
+
+
+
+ )
+ ) : (
+