Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d398568da | |||
| 4051af58a7 | |||
| 3681db6cd4 | |||
| 44cefc2fe2 | |||
| 176bfaefe0 | |||
| 2372dde716 | |||
| 42ed858853 |
@@ -65,7 +65,7 @@
|
||||
},
|
||||
{
|
||||
"feature": "AI assistant chat",
|
||||
"hooks": ["src/components/Assistant/useAssistantThread.ts"],
|
||||
"hooks": ["src/components/Assistant/useAssistantThread.ts", "src/components/Assistant/useAssistantStatus.ts"],
|
||||
"desktop": ["src/components/Assistant/AssistantWindow.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/assistant/MAssistantScreen.tsx"]
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"components/Collections/CollectionMap.tsx": 1,
|
||||
"components/Studio/LineHandles.tsx": 1,
|
||||
"components/Studio/StudioSelectionChrome.tsx": 2,
|
||||
"components/Studio/inspector/FocalPad.tsx": 1,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
|
||||
"src/components/Budget/CostsPanel.test.tsx": 2736,
|
||||
"src/components/Budget/CostsPanel.tsx": 1798,
|
||||
"src/components/Map/MapView.tsx": 1399,
|
||||
"src/components/Map/MapView.tsx": 1385,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2919,
|
||||
"src/components/Map/MapViewGL.tsx": 2290,
|
||||
"src/components/Map/RouteCalculator.ts": 1076,
|
||||
|
||||
@@ -5,7 +5,9 @@ import { MASKED_SETTING_VALUE } from '@trek/shared';
|
||||
import { createElement, type ReactNode } from 'react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { seedStore } from '../../../../tests/helpers/store';
|
||||
import { TranslationProvider } from '../../../i18n/TranslationContext';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
|
||||
const repo = vi.hoisted(() => ({
|
||||
@@ -59,6 +61,8 @@ async function renderLoaded() {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAssistantStore.setState({ status: null });
|
||||
// The settings show while the assistant is on, and only then is its status asked for.
|
||||
seedStore(useAddonStore, { loaded: true, addons: [{ id: 'ai_assistant', enabled: true }] });
|
||||
repo.status.mockRejectedValue(new Error('not needed'));
|
||||
repo.adminSettings.mockResolvedValue(SETTINGS);
|
||||
repo.saveAdminSettings.mockImplementation(async (next: AiAdminSettings) => next);
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { AssistantChatTrip } from './assistantViewContext';
|
||||
import type { EntityTarget } from './cards/cardTypes';
|
||||
import { chatPagePath, useAssistantHome, useRouteJournalChat } from './useAssistantHome';
|
||||
import { useAssistantHotkey } from './useAssistantHotkey';
|
||||
import { useAssistantStatus } from './useAssistantStatus';
|
||||
import { useAssistantThread } from './useAssistantThread';
|
||||
|
||||
/**
|
||||
@@ -202,15 +203,12 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
|
||||
const available = useAssistantStore((s) => s.status?.available === true);
|
||||
const open = useAssistantStore((s) => s.window.open);
|
||||
const minimized = useAssistantStore((s) => s.window.minimized);
|
||||
const loadStatus = useAssistantStore((s) => s.loadStatus);
|
||||
const toggle = useAssistantStore((s) => s.toggle);
|
||||
const [picked, setPicked] = useState<WindowTrip | null>(null);
|
||||
const [wasOpen, setWasOpen] = useState(open);
|
||||
const home = useAssistantHome(setPicked);
|
||||
|
||||
useEffect(() => {
|
||||
void loadStatus();
|
||||
}, [loadStatus]);
|
||||
useAssistantStatus();
|
||||
useAssistantHotkey(available, toggle);
|
||||
|
||||
// A trip picked outside the planner is only for this opening; the next one opens on the home chat again.
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* FE-AI-STATUS-001 to FE-AI-STATUS-003
|
||||
*
|
||||
* The views of the assistant ask for its status through this hook: not while
|
||||
* both addons that open /api/ai are off, where the server answers 404, and
|
||||
* as soon as one comes on, which the addon list can say after the view
|
||||
* mounted (the first load, an admin's switch). The assistant's own addon
|
||||
* switching asks again while AI parsing keeps /api/ai open.
|
||||
*/
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { AiStatusResponse } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { aiConnectionsRepo } from '../../repo/aiConnectionsRepo';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import { useAssistantStatus } from './useAssistantStatus';
|
||||
|
||||
const INITIAL = useAssistantStore.getState();
|
||||
const STATUS = { available: true, retention: { mode: 'days', days: 90 } } as AiStatusResponse;
|
||||
|
||||
/** The addon list as it lands from the server, with these addons on. */
|
||||
const addonsOn = (...ids: string[]) =>
|
||||
seedStore(useAddonStore, { loaded: true, addons: ids.map((id) => ({ id, enabled: true })) });
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useAssistantStore.setState(INITIAL, true);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('useAssistantStatus', () => {
|
||||
it('FE-AI-STATUS-001: asks nothing before the addon list or with both addons off, and asks once one comes on', async () => {
|
||||
const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS);
|
||||
renderHook(() => useAssistantStatus());
|
||||
act(() => addonsOn('mcp'));
|
||||
expect(status).not.toHaveBeenCalled();
|
||||
expect(useAssistantStore.getState().status).toBeNull();
|
||||
|
||||
act(() => addonsOn('ai_assistant'));
|
||||
await waitFor(() => expect(useAssistantStore.getState().status).toEqual(STATUS));
|
||||
expect(status).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The list read again with the assistant still on asks nothing more.
|
||||
act(() => addonsOn('ai_assistant', 'mcp'));
|
||||
expect(status).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-AI-STATUS-002: with AI parsing on it asks at once, and the last addon going off hides the assistant', async () => {
|
||||
const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS);
|
||||
addonsOn('llm_parsing');
|
||||
renderHook(() => useAssistantStatus());
|
||||
await waitFor(() => expect(useAssistantStore.getState().status).toEqual(STATUS));
|
||||
expect(status).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => addonsOn());
|
||||
expect(useAssistantStore.getState().status).toBeNull();
|
||||
expect(status).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-AI-STATUS-003: with AI parsing on, the assistant switched on or off asks again', async () => {
|
||||
const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS);
|
||||
addonsOn('llm_parsing');
|
||||
renderHook(() => useAssistantStatus());
|
||||
await waitFor(() => expect(status).toHaveBeenCalledTimes(1));
|
||||
|
||||
act(() => addonsOn('llm_parsing', 'ai_assistant'));
|
||||
await waitFor(() => expect(status).toHaveBeenCalledTimes(2));
|
||||
|
||||
act(() => addonsOn('llm_parsing'));
|
||||
await waitFor(() => expect(status).toHaveBeenCalledTimes(3));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { aiRoutesOpen, useAssistantStore } from '../../store/assistantStore';
|
||||
|
||||
/**
|
||||
* Asks for the assistant's status when a view of it mounts, and again when
|
||||
* either addon that opens /api/ai is switched: the addon list can land after
|
||||
* the view, and an admin can switch either addon while it is open. The
|
||||
* assistant's own addon counts on its own, since the status says it runs only
|
||||
* while that one is on, even with AI parsing keeping /api/ai open. The store
|
||||
* asks the server only while one is on (aiRoutesOpen).
|
||||
*/
|
||||
export function useAssistantStatus(): void {
|
||||
const loadStatus = useAssistantStore((s) => s.loadStatus);
|
||||
const routesOpen = useAddonStore(aiRoutesOpen);
|
||||
const assistantOn = useAddonStore((s) => s.isEnabled('ai_assistant'));
|
||||
|
||||
useEffect(() => {
|
||||
void loadStatus();
|
||||
}, [routesOpen, assistantOn, loadStatus]);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* The collection map frames every place of the list in the box it is given (FE-COMP-COLMAP-*).
|
||||
*
|
||||
* Real Leaflet on purpose, as in MapView.leaflet.test.tsx. The places went missing between
|
||||
* two sizes: the renderer opened on a camera framed for the browser window, while this map
|
||||
* is a box a fraction of that, and a mocked map has neither. The window here is a desktop
|
||||
* of 1600 x 1000 and the map the 567 x 724 split it gets beside the list, where the demo's
|
||||
* cafes opened on northern France with one pin in sight.
|
||||
*/
|
||||
import type { CollectionPlace } from '@trek/shared';
|
||||
import L from 'leaflet';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, render, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import CollectionMap from './CollectionMap';
|
||||
|
||||
vi.mock('../../hooks/useGeolocation', () => ({
|
||||
useGeolocation: () => ({
|
||||
position: null,
|
||||
mode: 'off',
|
||||
error: null,
|
||||
errorCode: null,
|
||||
cycleMode: vi.fn(),
|
||||
setMode: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../Ferratas/FerrataLayerLeaflet', () => ({ FerrataMapLayerLeaflet: () => null }));
|
||||
|
||||
// A raster template keeps the basemap a plain TileLayer: the vector default needs WebGL.
|
||||
vi.mock('../../hooks/useTileUrl', () => ({ useTileUrl: () => 'https://tiles.example.test/{z}/{x}/{y}.png' }));
|
||||
|
||||
vi.mock('../../services/photoService', () => ({
|
||||
getCached: vi.fn(() => null),
|
||||
isLoading: vi.fn(() => false),
|
||||
fetchPhoto: vi.fn(),
|
||||
onThumbReady: vi.fn(() => () => {}),
|
||||
getAllThumbs: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
// Every map this file builds, newest last.
|
||||
const maps: L.Map[] = [];
|
||||
L.Map.addInitHook(function (this: L.Map) {
|
||||
maps.push(this);
|
||||
});
|
||||
|
||||
/** Half a selected pin (44 px): a point this far inside an edge keeps its whole pin on the map. */
|
||||
const PIN = 22;
|
||||
/** The desktop panel's control bar ends 52 px below the map's top edge. */
|
||||
const BAR = 52;
|
||||
/** The satellite and locate buttons fill the bottom 54 px of the map's corners. */
|
||||
const CONTROLS = 54;
|
||||
|
||||
// Leaflet sizes the map from its container, which jsdom lays out at 0 x 0.
|
||||
const box = { width: 567, height: 724 };
|
||||
const sized = ['clientWidth', 'clientHeight'] as const;
|
||||
const originals = new Map<string, PropertyDescriptor | undefined>();
|
||||
const startWindow = { width: window.innerWidth, height: window.innerHeight };
|
||||
|
||||
function setWindow(width: number, height: number) {
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: width });
|
||||
Object.defineProperty(window, 'innerHeight', { configurable: true, writable: true, value: height });
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
for (const key of sized) {
|
||||
originals.set(key, Object.getOwnPropertyDescriptor(HTMLElement.prototype, key));
|
||||
Object.defineProperty(HTMLElement.prototype, key, {
|
||||
configurable: true,
|
||||
get: () => (key === 'clientWidth' ? box.width : box.height),
|
||||
});
|
||||
}
|
||||
});
|
||||
afterAll(() => {
|
||||
for (const key of sized) {
|
||||
const original = originals.get(key);
|
||||
if (original) Object.defineProperty(HTMLElement.prototype, key, original);
|
||||
else delete (HTMLElement.prototype as unknown as Record<string, unknown>)[key];
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// The desktop split beside the list, unless a test says otherwise.
|
||||
setWindow(1600, 1000);
|
||||
Object.assign(box, { width: 567, height: 724 });
|
||||
});
|
||||
afterEach(() => {
|
||||
setWindow(startWindow.width, startWindow.height);
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
function place(id: number, name: string, lat: number, lng: number): CollectionPlace {
|
||||
return { id, collection_id: 1, name, lat, lng, status: 'idea' };
|
||||
}
|
||||
|
||||
// The demo's cafes: Vienna twice, Trieste, Rome, Paris, Lisbon, Porto and Copenhagen.
|
||||
const CAFES = [
|
||||
place(1, 'Cafe Central', 48.2105, 16.366),
|
||||
place(2, 'Cafe Sperl', 48.1999, 16.3634),
|
||||
place(3, 'Caffe San Marco', 45.6513, 13.777),
|
||||
place(4, "Sant'Eustachio il Caffe", 41.8984, 12.4753),
|
||||
place(5, 'Cafe de Flore', 48.8542, 2.3331),
|
||||
place(6, 'A Brasileira', 38.7102, -9.1417),
|
||||
place(7, 'Cafe Majestic', 41.1472, -8.6064),
|
||||
place(8, 'The Coffee Collective', 55.696, 12.5509),
|
||||
];
|
||||
|
||||
// The demo's viewpoints, from the Norwegian fjords to the Cinque Terre.
|
||||
const VIEWPOINTS = [
|
||||
place(11, 'Seceda', 46.6046, 11.722),
|
||||
place(12, 'Preikestolen', 58.9863, 6.1904),
|
||||
place(13, 'Trolltunga', 60.1241, 6.74),
|
||||
place(14, 'Rigi Kulm', 47.056, 8.485),
|
||||
place(15, 'Zugspitze', 47.4211, 10.9852),
|
||||
place(16, 'Eibsee loop', 47.4575, 10.9918),
|
||||
place(17, 'Hallstatt overlook', 47.5622, 13.6493),
|
||||
place(18, 'Monterosso al Mare', 44.1456, 9.654),
|
||||
];
|
||||
|
||||
// Temples and shrines across Japan: eight as well, and far outside the cafes' frame.
|
||||
const TEMPLES = [
|
||||
place(31, 'Fushimi Inari', 34.9671, 135.7727),
|
||||
place(32, 'Kiyomizu-dera', 34.9949, 135.785),
|
||||
place(33, 'Senso-ji', 35.7148, 139.7967),
|
||||
place(34, 'Chureito Pagoda', 35.5014, 138.8016),
|
||||
place(35, 'Itsukushima', 34.2959, 132.3199),
|
||||
place(36, 'Himeji Castle', 34.8394, 134.6939),
|
||||
place(37, 'Shirakawa-go', 36.2578, 136.9066),
|
||||
place(38, 'Todai-ji', 34.689, 135.8398),
|
||||
];
|
||||
|
||||
// "All saved" across the globe, from Patagonia to Taiwan.
|
||||
const WORLD = [
|
||||
place(21, 'Torres del Paine', -50.942, -73.4068),
|
||||
place(22, 'Pico do Arieiro', 32.7356, -16.9281),
|
||||
place(23, 'Landmannalaugar', 63.9926, -19.0607),
|
||||
place(24, 'Taroko Gorge', 24.1594, 121.6211),
|
||||
place(25, 'Lycian Way', 36.5497, 29.1153),
|
||||
...VIEWPOINTS.slice(0, 3),
|
||||
];
|
||||
|
||||
function mapOf(places: CollectionPlace[], underBar: boolean) {
|
||||
return (
|
||||
<CollectionMap places={places} selectedPlaceId={null} onOpenPlace={vi.fn()} dark={false} underBar={underBar} />
|
||||
);
|
||||
}
|
||||
|
||||
async function renderMap(places: CollectionPlace[], underBar = true) {
|
||||
const view = render(mapOf(places, underBar));
|
||||
await waitFor(() => expect(view.container.querySelector('.leaflet-container')).not.toBeNull());
|
||||
return { view, map: maps[maps.length - 1] };
|
||||
}
|
||||
|
||||
/** Where each place sits on the map, in pixels from its top left corner. */
|
||||
function pointsOf(map: L.Map, places: CollectionPlace[]): L.Point[] {
|
||||
return places.map((p) => map.latLngToContainerPoint([p.lat ?? 0, p.lng ?? 0]));
|
||||
}
|
||||
|
||||
/** Every pin whole on the map, below the bar when there is one, and above the bottom controls. */
|
||||
function expectEveryPinClear(map: L.Map, places: CollectionPlace[], top: number) {
|
||||
for (const { x, y } of pointsOf(map, places)) {
|
||||
expect(x).toBeGreaterThanOrEqual(PIN);
|
||||
expect(x).toBeLessThanOrEqual(box.width - PIN);
|
||||
expect(y).toBeGreaterThanOrEqual(top + PIN);
|
||||
expect(y).toBeLessThanOrEqual(box.height - CONTROLS - PIN);
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether an element around the map carries the class. */
|
||||
function wrappedIn(map: L.Map, className: string): boolean {
|
||||
for (let el = map.getContainer().parentElement; el; el = el.parentElement) {
|
||||
if (el.classList.contains(className)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The places fill a good part of the free box along one axis: framed, not shown from orbit. */
|
||||
function expectFramedClosely(map: L.Map, places: CollectionPlace[], top: number) {
|
||||
const points = pointsOf(map, places);
|
||||
const spanX = Math.max(...points.map((p) => p.x)) - Math.min(...points.map((p) => p.x));
|
||||
const spanY = Math.max(...points.map((p) => p.y)) - Math.min(...points.map((p) => p.y));
|
||||
const freeX = box.width - 2 * PIN;
|
||||
const freeY = box.height - top - CONTROLS - 2 * PIN;
|
||||
expect(Math.max(spanX / freeX, spanY / freeY)).toBeGreaterThan(0.4);
|
||||
}
|
||||
|
||||
describe('CollectionMap framing', () => {
|
||||
it('FE-COMP-COLMAP-001: the desktop split opens with every cafe on screen, clear of the edges and the bar', async () => {
|
||||
const { map } = await renderMap(CAFES);
|
||||
|
||||
expectEveryPinClear(map, CAFES, BAR);
|
||||
expectFramedClosely(map, CAFES, BAR);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLMAP-002: another list of the same length frames again', async () => {
|
||||
const { view, map } = await renderMap(CAFES);
|
||||
|
||||
// Both lists hold eight places, so a fit keyed on the count never noticed the switch.
|
||||
// The temples lie far east of the cafes' frame, so their pins come clear only by a new fit.
|
||||
expect(pointsOf(map, TEMPLES).every((p) => p.x > box.width)).toBe(true);
|
||||
view.rerender(mapOf(TEMPLES, true));
|
||||
|
||||
await waitFor(() => expectEveryPinClear(map, TEMPLES, BAR));
|
||||
expectFramedClosely(map, TEMPLES, BAR);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLMAP-003: a map the user moved stays put through a reload, a new order or an edited place', async () => {
|
||||
const { view, map } = await renderMap(CAFES);
|
||||
act(() => {
|
||||
map.setView([50, 10], 7, { animate: false });
|
||||
});
|
||||
|
||||
const reloaded = [...CAFES].reverse().map((p) => ({ ...p, status: 'visited' as const }));
|
||||
view.rerender(mapOf(reloaded, true));
|
||||
|
||||
expect(map.getZoom()).toBe(7);
|
||||
expect(map.getCenter().lat).toBeCloseTo(50, 5);
|
||||
expect(map.getCenter().lng).toBeCloseTo(10, 5);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLMAP-004: a filter that hides every place leaves the map where it was', async () => {
|
||||
const { view, map } = await renderMap(CAFES);
|
||||
const zoom = map.getZoom();
|
||||
const center = map.getCenter();
|
||||
|
||||
view.rerender(mapOf([], true));
|
||||
|
||||
expect(map.getZoom()).toBe(zoom);
|
||||
expect(map.getCenter().lat).toBeCloseTo(center.lat, 5);
|
||||
expect(map.getCenter().lng).toBeCloseTo(center.lng, 5);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLMAP-005: a list of one opens on its town, not on its street', async () => {
|
||||
const { map } = await renderMap([CAFES[0]]);
|
||||
|
||||
expectEveryPinClear(map, [CAFES[0]], BAR);
|
||||
expect(map.getZoom()).toBeGreaterThanOrEqual(10);
|
||||
expect(map.getZoom()).toBeLessThanOrEqual(13);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLMAP-006: "All saved" across the globe keeps Patagonia and Taiwan on screen', async () => {
|
||||
const { map } = await renderMap(WORLD);
|
||||
|
||||
expectEveryPinClear(map, WORLD, BAR);
|
||||
});
|
||||
|
||||
it("FE-COMP-COLMAP-007: the phone's card frames every cafe clear of its edges and its bottom controls", async () => {
|
||||
setWindow(390, 844);
|
||||
Object.assign(box, { width: 356, height: 414 });
|
||||
|
||||
const { map } = await renderMap(CAFES, false);
|
||||
|
||||
expectEveryPinClear(map, CAFES, 0);
|
||||
expectFramedClosely(map, CAFES, 0);
|
||||
// The locate and satellite buttons stand on --bottom-nav-h, which lifts them over the tab
|
||||
// bar under a full-screen map. The card ends above the tab bar, so its wrapper zeroes it
|
||||
// and the buttons sit in the bottom 54 px the frame keeps free.
|
||||
expect(wrappedIn(map, '[--bottom-nav-h:0px]')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
import { MapViewAuto } from '../Map/MapViewAuto'
|
||||
import type { CollectionPlace } from '@trek/shared'
|
||||
import { mappablePlaces } from '../../pages/collections/collectionsModel'
|
||||
import { useTileUrl } from '../../hooks/useTileUrl'
|
||||
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults'
|
||||
import type { ViewportPadding } from '../../utils/mapViewport'
|
||||
|
||||
interface CollectionMapProps {
|
||||
places: CollectionPlace[]
|
||||
@@ -12,20 +13,75 @@ interface CollectionMapProps {
|
||||
/** Clicking the map background clears the selection. */
|
||||
onDeselect?: () => void
|
||||
dark: boolean
|
||||
/** A control bar floats over the top of the map (the desktop panel's), so the frame starts below it. */
|
||||
underBar?: boolean
|
||||
}
|
||||
|
||||
/** How far a pin reaches from its point: half a selected pin (44 px) and the rating badge on its corner. */
|
||||
const PIN_REACH = 26
|
||||
/** What every edge keeps clear: a pin's reach and a little air, so no pin touches the frame. */
|
||||
const EDGE = PIN_REACH + 14
|
||||
/** The desktop panel's control bar: 12 px off the top edge and at most 40 px tall. */
|
||||
const TOP_BAR = 52
|
||||
/** The round controls in the bottom corners (satellite, locate): 12 px off the edge and 42 px tall. */
|
||||
const BOTTOM_CONTROLS = 54
|
||||
/**
|
||||
* Half the side of the square framed around a lone place, in degrees: about 11 km
|
||||
* from north to south, so a list of one opens on its town rather than on the street
|
||||
* the closest fit would zoom to.
|
||||
*/
|
||||
const LONE_PLACE_SPAN = 0.05
|
||||
|
||||
/** What the frame keeps clear of pins, in map pixels per edge. */
|
||||
function framePadding(underBar: boolean): ViewportPadding {
|
||||
const top = (underBar ? TOP_BAR : 0) + EDGE
|
||||
return { top, right: EDGE, bottom: BOTTOM_CONTROLS + PIN_REACH, left: EDGE } // rtl-lint-disable: pixels of the map, not sides of the text
|
||||
}
|
||||
|
||||
/**
|
||||
* Map view — reuses the trip map stack (MapViewAuto → Leaflet / GL with marker
|
||||
* The coordinates the map frames. A lone place, or several on one spot, has no extent
|
||||
* to fit, so a small square around it stands in.
|
||||
*/
|
||||
function framePoints(places: CollectionPlace[]): [number, number][] {
|
||||
const points = places.flatMap(p => (
|
||||
typeof p.lat === 'number' && typeof p.lng === 'number' ? [[p.lat, p.lng] as [number, number]] : []
|
||||
))
|
||||
if (points.length === 0) return points
|
||||
const [lat, lng] = points[0]
|
||||
if (points.some(([a, b]) => a !== lat || b !== lng)) return points
|
||||
return [
|
||||
[Math.max(-90, lat - LONE_PLACE_SPAN), lng - LONE_PLACE_SPAN],
|
||||
[Math.min(90, lat + LONE_PLACE_SPAN), lng + LONE_PLACE_SPAN],
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Map view: reuses the trip map stack (MapViewAuto → Leaflet / GL with marker
|
||||
* clustering). One of the three list views; clicking a marker selects the place.
|
||||
* The parent `.col-mapwrap` supplies the rounded, bordered box + height, so this
|
||||
* just fills it.
|
||||
*
|
||||
* The map frames every place itself, through the renderers' focus props. Their
|
||||
* opening camera is framed for the browser window, and this map is a box a fraction
|
||||
* of that size (the desktop split, the phone's card), so it opened zoomed in on the
|
||||
* middle of the list with most places outside; and the count it used to key the fit
|
||||
* on never changed between two lists of eight. The frame is fitted in the map's own
|
||||
* box instead, clear of the edges and of the controls floating over it, and lands
|
||||
* while the map is built rather than flying in.
|
||||
*/
|
||||
export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, onDeselect, dark }: CollectionMapProps): React.ReactElement {
|
||||
const pts = mappablePlaces(places)
|
||||
export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, onDeselect, dark, underBar = false }: CollectionMapProps): React.ReactElement {
|
||||
const pts = useMemo(() => mappablePlaces(places), [places])
|
||||
const tileUrl = useTileUrl(dark ? OFM_DARK : OFM_POSITRON)
|
||||
const focusPoints = useMemo(() => framePoints(pts), [pts])
|
||||
// The places as a set: another list, a filter or a new place frames again, while a
|
||||
// reload, another sort order or an edited note leaves a map the user moved alone.
|
||||
const focusKey = useMemo(() => pts.map(p => p.id).sort((a, b) => a - b).join(','), [pts])
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%' }}>
|
||||
// The map's own controls stand on --bottom-nav-h to clear the phone's tab bar on a
|
||||
// full-screen map. This one ends above the tab bar, so they belong on its bottom edge,
|
||||
// where the frame's bottom padding keeps the pins out from under them.
|
||||
<div className="[--bottom-nav-h:0px]" style={{ width: '100%', height: '100%' }}>
|
||||
<MapViewAuto
|
||||
places={pts}
|
||||
selectedPlaceId={selectedPlaceId}
|
||||
@@ -35,10 +91,10 @@ export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, on
|
||||
// question a small picture cannot answer.
|
||||
onMarkerClick={onOpenPlace}
|
||||
onMapClick={onDeselect ? () => onDeselect() : undefined}
|
||||
// No center/zoom: the map frames itself on the collection's places at mount, and
|
||||
// falls back to the world view for a collection with none.
|
||||
tileUrl={tileUrl}
|
||||
fitKey={pts.length}
|
||||
focusPoints={focusPoints}
|
||||
focusKey={focusKey}
|
||||
fitPadding={framePadding(underBar)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* The collections page's map panel tells the map whether its bar floats over it (FE-COMP-COLMAPPANEL-*).
|
||||
*
|
||||
* The frame itself is CollectionMap.test.tsx's. The page tests stand the panel in with a double,
|
||||
* so this is where the bar's height reaches the frame: without it the top pins land under the bar.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render } from '../../../tests/helpers/render';
|
||||
import CollectionMapPanel from './CollectionMapPanel';
|
||||
|
||||
const handedToMap = vi.hoisted(() => ({ underBar: undefined as boolean | undefined }));
|
||||
vi.mock('./CollectionMap', () => ({
|
||||
default: (props: { underBar?: boolean }) => {
|
||||
handedToMap.underBar = props.underBar;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
function panel(overlay: boolean) {
|
||||
return (
|
||||
<CollectionMapPanel
|
||||
places={[]}
|
||||
selectedPlaceId={null}
|
||||
onSelect={vi.fn()}
|
||||
onDeselect={vi.fn()}
|
||||
dark={false}
|
||||
overlay={overlay}
|
||||
view="list"
|
||||
onToggleView={vi.fn()}
|
||||
search=""
|
||||
onSearch={vi.fn()}
|
||||
t={(key) => key}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('CollectionMapPanel', () => {
|
||||
it('FE-COMP-COLMAPPANEL-001: the desktop split floats its bar over the map, which frames its places below it', () => {
|
||||
const { container } = render(panel(true));
|
||||
|
||||
expect(container.querySelector('.col-map-topbar')).not.toBeNull();
|
||||
expect(handedToMap.underBar).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLMAPPANEL-002: a map without the bar frames its places from its own top edge', () => {
|
||||
const { container } = render(panel(false));
|
||||
|
||||
expect(container.querySelector('.col-map-topbar')).toBeNull();
|
||||
expect(handedToMap.underBar).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,7 @@ export default function CollectionMapPanel({
|
||||
onOpenPlace={onSelect}
|
||||
onDeselect={onDeselect}
|
||||
dark={dark}
|
||||
underBar={overlay}
|
||||
/>
|
||||
{overlay && (
|
||||
<div className="col-map-topbar">
|
||||
|
||||
@@ -534,6 +534,21 @@ describe('MapView', () => {
|
||||
expect(mapMock.fitBounds).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-089: a frame handed over while the map is built lands at once, a later one flies', () => {
|
||||
// The map opens on a guess framed for the browser window. A map in a smaller box, the
|
||||
// collection's beside its list, hands its own frame over at once, and must open there
|
||||
// rather than show the guess and then fly to the frame.
|
||||
const cafes: [number, number][] = [[48.21, 16.37], [38.71, -9.14], [55.7, 12.55]]
|
||||
const { rerender } = render(<MapView places={[]} focusKey="list:17" focusPoints={cafes} />)
|
||||
expect(mapMock.fitBounds.mock.calls).toEqual([[expect.anything(), expect.objectContaining({ animate: false, maxZoom: 16 })]])
|
||||
mapMock.fitBounds.mockClear()
|
||||
|
||||
// Another list on the same map is a change the user asked for, and it flies as before.
|
||||
const viewpoints: [number, number][] = [[60.12, 6.74], [44.15, 9.65], [47.56, 13.65]]
|
||||
rerender(<MapView places={[]} focusKey="list:20" focusPoints={viewpoints} />)
|
||||
expect(mapMock.fitBounds.mock.calls).toEqual([[expect.anything(), expect.objectContaining({ animate: true, maxZoom: 16 })]])
|
||||
})
|
||||
|
||||
it('keeps selection fit and the pending route-arrival refit for semantic changes', () => {
|
||||
const selectedPlace = buildMapPlace({ id: 901, lat: 48, lng: 11 })
|
||||
const route: [number, number][][] = [[[48, 11], [48.2, 11.3]]]
|
||||
|
||||
@@ -427,35 +427,16 @@ function MapController({ center, zoom }: MapControllerProps) {
|
||||
// fit to that day's destinations immediately, then — once the day's route has
|
||||
// finished computing asynchronously — re-fit once more to include the full route
|
||||
// polyline, so a route that bulges past its stops stays in view (#1128).
|
||||
interface BoundsControllerProps {
|
||||
// The focus props are the contract's, documented there once rather than copied here.
|
||||
interface BoundsControllerProps extends Pick<MapViewProps, 'focusPoints' | 'focusKey' | 'fitPadding'> {
|
||||
places: Place[]
|
||||
routeCoords: [number, number][]
|
||||
fitKey: number
|
||||
paddingOpts: L.FitBoundsOptions
|
||||
/** The map was built already framed on these places, so the opening fit has nothing to do. */
|
||||
framedOnMount?: boolean
|
||||
/**
|
||||
* An explicit stretch of map to frame, independent of the day being shown.
|
||||
*
|
||||
* `fitKey` cannot express this: it carries no coordinates, and each renderer decides
|
||||
* for itself that it means "the selected day". Weighing the ways of driving one leg
|
||||
* needs that leg on screen, which is neither the day nor the trip.
|
||||
*/
|
||||
focusPoints?: [number, number][]
|
||||
/** Changes only when the caller intentionally wants a new initial frame. */
|
||||
focusKey?: number | string
|
||||
/** False while the map is locked (#2010): an arriving route no longer re-fits it. */
|
||||
follow?: boolean
|
||||
/**
|
||||
* What the caller's own chrome covers while `focusPoints` is framed, in pixels per edge.
|
||||
*
|
||||
* `paddingOpts` knows the planner's panels and nothing else, and on a phone it is a flat
|
||||
* margin. A shell that lays its own bars over the map passes what they cover, so the
|
||||
* frame lands in the part still visible. Only the fit on `focusPoints` reads it.
|
||||
* Compared by value: the same numbers in a new object do not refit, while new numbers
|
||||
* refit the points already handed over, because the chrome they must clear has moved.
|
||||
*/
|
||||
fitPadding?: ViewportPadding
|
||||
}
|
||||
|
||||
/** A padding box as Leaflet's fit options spell it, corner pairs rather than edges. */
|
||||
@@ -473,6 +454,7 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
|
||||
const fitRan = useRef(false)
|
||||
const didInitialFocus = useRef(false)
|
||||
const prevFocusKey = useRef(focusKey)
|
||||
const built = useRef(false)
|
||||
|
||||
const fitTo = useCallback((coords: [number, number][], padding: L.FitBoundsOptions = paddingOpts) => {
|
||||
if (coords.length === 0) return
|
||||
@@ -493,7 +475,7 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
|
||||
* (#1982). MapViewGL never had the nudge, so this also brings the two
|
||||
* renderers back into agreement.
|
||||
*/
|
||||
map.fitBounds(bounds, { ...padding, maxZoom: 16, animate: true })
|
||||
map.fitBounds(bounds, { maxZoom: 16, animate: true, ...padding })
|
||||
}
|
||||
} catch {}
|
||||
}, [map, paddingOpts])
|
||||
@@ -534,6 +516,10 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
|
||||
// Frame whatever was handed over. Nothing happens when it empties, so closing the
|
||||
// picker leaves the map where the user left it rather than snapping back.
|
||||
useEffect(() => {
|
||||
// The first run is the map being built. A frame handed over by then is where the map
|
||||
// opens, so it lands at once instead of flying in from the window-sized opening guess.
|
||||
const opening = !built.current
|
||||
built.current = true
|
||||
if (!focusPoints?.length) return
|
||||
if (focusKey !== undefined) {
|
||||
const shouldFit = !didInitialFocus.current || focusKey !== prevFocusKey.current
|
||||
@@ -543,7 +529,7 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
|
||||
}
|
||||
// A day fit that has not run yet must not overwrite this a moment later.
|
||||
awaitingRoute.current = false
|
||||
fitTo(focusPoints, fitPadding ? leafletPadding(fitPadding) : paddingOpts)
|
||||
fitTo(focusPoints, { ...(fitPadding ? leafletPadding(fitPadding) : paddingOpts), animate: !opening })
|
||||
}, [focusPoints, focusKey, fitPaddingKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return null
|
||||
|
||||
@@ -7,8 +7,8 @@ import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
|
||||
// The planner-waypoint and location-search camera cases of MapViewGL, and the tour
|
||||
// map's via ferrata layer (RC-10), with the same map doubles as MapViewGL.test.tsx.
|
||||
// The planner-waypoint, location-search and antimeridian camera cases of MapViewGL, and
|
||||
// the tour map's via ferrata layer (RC-10), with the same map doubles as MapViewGL.test.tsx.
|
||||
|
||||
// Stable fake map so fitBounds call counts survive re-renders. The canvas
|
||||
// container is a single element so listeners registered by the component are
|
||||
@@ -356,6 +356,31 @@ describe('MapViewGL', () => {
|
||||
expect(glMap.fitBounds).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('frames handed-over points across the antimeridian the short way, as the opening camera does', async () => {
|
||||
loadOnAttach();
|
||||
// Fiji, Tonga and Samoa, a collection's list: a box from plain min and max would run
|
||||
// from -175 to 178, round the far side of the world, with each pin on an edge.
|
||||
render(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
followSelection={false}
|
||||
focusKey="1,2,3"
|
||||
focusPoints={[
|
||||
[-18.14, 178.44],
|
||||
[-21.14, -175.2],
|
||||
[-13.83, -171.76],
|
||||
]}
|
||||
glProvider="maplibre-gl"
|
||||
/>
|
||||
);
|
||||
await act(async () => {});
|
||||
|
||||
expect(glMap.fitBounds).toHaveBeenCalledOnce();
|
||||
const framed = glBounds.instances.find((bounds) => bounds === glMap.fitBounds.mock.calls[0][0]);
|
||||
const lngs = framed?.extend.mock.calls.map(([lngLat]) => Math.round(lngLat[0] * 100) / 100);
|
||||
expect(lngs).toEqual([178.44, 184.8, 188.24]);
|
||||
});
|
||||
|
||||
it.each(['maplibre-gl', 'mapbox-gl'] as const)(
|
||||
'RC-08: %s previews waypoint drags locally and commits only on pointer up without changing the camera',
|
||||
async (provider) => {
|
||||
|
||||
@@ -944,7 +944,7 @@ describe('MapViewGL', () => {
|
||||
/>,
|
||||
)
|
||||
await act(async () => {})
|
||||
expect(glMap.fitBounds).toHaveBeenCalledOnce()
|
||||
expect(glMap.fitBounds.mock.calls).toEqual([[expect.anything(), expect.objectContaining({ duration: 0 })]])
|
||||
glMap.fitBounds.mockClear()
|
||||
const mapConstructions = (maplibregl.Map as any).mock.calls.length
|
||||
const retainedCenter = { lng: 135.8, lat: 35.2 }
|
||||
@@ -979,7 +979,7 @@ describe('MapViewGL', () => {
|
||||
/>,
|
||||
)
|
||||
await act(async () => {})
|
||||
expect(glMap.fitBounds).toHaveBeenCalledOnce()
|
||||
expect(glMap.fitBounds.mock.calls).toEqual([[expect.anything(), expect.objectContaining({ duration: 400 })]])
|
||||
})
|
||||
|
||||
it('keeps selection fit and the pending route-arrival refit for semantic changes', async () => {
|
||||
|
||||
@@ -48,7 +48,7 @@ import { addGpxTrackLayers, GPX_HIT_LAYER_ID, hasGpxTrackLayers, selectGpxTrack,
|
||||
import { buildPoiPopupHtml } from './placePopup'
|
||||
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
|
||||
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
|
||||
import { computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
|
||||
import { alongShortestArc, computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
|
||||
import { selectedPlaceTarget } from './selectedPlaceTarget'
|
||||
import { PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID } from './placeClusterLayers'
|
||||
|
||||
@@ -2091,7 +2091,7 @@ export function MapViewGL({
|
||||
didInitialFocusRef.current = true
|
||||
}
|
||||
const bounds = new gl.LngLatBounds()
|
||||
focusPoints.forEach(([lat, lng]) => bounds.extend([lng, lat]))
|
||||
alongShortestArc(focusPoints).forEach(([lat, lng]) => bounds.extend([lng, lat]))
|
||||
// A day fit still waiting on its route must not overwrite this a moment later.
|
||||
pendingRouteFitRef.current = null
|
||||
try {
|
||||
@@ -2099,7 +2099,7 @@ export function MapViewGL({
|
||||
padding: fitPadding ?? paddingOpts,
|
||||
maxZoom: 15,
|
||||
pitch: enableMapbox3d ? 45 : 0,
|
||||
duration: 400,
|
||||
duration: mapReady ? 400 : 0, // handed over while the map is built: it opens there, no flight in
|
||||
})
|
||||
} catch { /* noop */ }
|
||||
}, [focusPoints, focusKey, fitPaddingKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildUser } from '../../../../tests/helpers/factories';
|
||||
import { render, screen } from '../../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
@@ -33,6 +34,8 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAssistantStore.setState({ status: null });
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
// The card shows while the assistant is on, and only then is its status asked for.
|
||||
seedStore(useAddonStore, { loaded: true, addons: [{ id: 'ai_assistant', enabled: true }] });
|
||||
repo.models.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildAdmin, buildUser } from '../../../../tests/helpers/factories';
|
||||
import { render, screen, waitFor } from '../../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
|
||||
@@ -51,6 +52,8 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAssistantStore.setState({ status: null });
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
// The card shows while the assistant is on, and only then is its status asked for.
|
||||
seedStore(useAddonStore, { loaded: true, addons: [{ id: 'ai_assistant', enabled: true }] });
|
||||
repo.models.mockResolvedValue([{ id: 'claude-sonnet', label: 'Claude Sonnet', supportsTools: true }]);
|
||||
repo.disconnect.mockResolvedValue(undefined);
|
||||
repo.chatgptStart.mockResolvedValue({
|
||||
|
||||
@@ -4,7 +4,9 @@ import type { AiConnectionView, AiStatusResponse } from '@trek/shared';
|
||||
import { createElement, type ReactNode } from 'react';
|
||||
import { MemoryRouter, useLocation } from 'react-router';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { seedStore } from '../../../../tests/helpers/store';
|
||||
import { TranslationProvider } from '../../../i18n/TranslationContext';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
|
||||
const repo = vi.hoisted(() => ({
|
||||
@@ -61,6 +63,8 @@ function renderConnections(phone = false) {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAssistantStore.setState({ status: null, window: { open: false, minimized: false } });
|
||||
// The card shows while the assistant is on, and only then is its status asked for.
|
||||
seedStore(useAddonStore, { loaded: true, addons: [{ id: 'ai_assistant', enabled: true }] });
|
||||
repo.status.mockResolvedValue(status());
|
||||
repo.models.mockResolvedValue([
|
||||
{ id: 'claude-sonnet', label: 'Claude Sonnet', supportsTools: true },
|
||||
|
||||
@@ -19,7 +19,8 @@ import type { StudioUploader } from './studioUpload'
|
||||
* has a handful of completely different kinds of thing you reach for (which
|
||||
* page, what from the journey, what arrangement, what to draw), and putting
|
||||
* them behind tabs in a single 200px column would make each of them cramped.
|
||||
* An icon rail costs 64px and gives the panel the room a photo browser needs.
|
||||
* An icon rail costs 64px, a little more where a section's name holds a longer
|
||||
* word, and gives the panel the room a photo browser needs.
|
||||
*
|
||||
* Which section is open is the shell's when it says so (`section` and
|
||||
* `onSection`), so the inspector on the other side can open the Theme or the
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { BookDocument } from '@trek/shared';
|
||||
import { normalizeBookDocument } from '@trek/shared';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
|
||||
@@ -9,11 +11,11 @@ import type { StudioUploader } from '../studioUpload';
|
||||
import { RAIL_SECTIONS, type RailSection } from './railSections';
|
||||
|
||||
/**
|
||||
* FE-STUDIO-RAIL-001 to FE-STUDIO-RAIL-006: the rail down the side of Studio
|
||||
* FE-STUDIO-RAIL-001 to FE-STUDIO-RAIL-007: the rail down the side of Studio
|
||||
* and the panel it opens. Every section is a tab that opens its own panel
|
||||
* under one head, the keyboard reaches every tab, a long name wraps inside
|
||||
* the rail, and the shell can hold the open section so the inspector can open
|
||||
* one from the other side.
|
||||
* the rail and only between words, and the shell can hold the open section so
|
||||
* the inspector can open one from the other side.
|
||||
*/
|
||||
|
||||
const SOURCE: JourneySource = { entries: [], photos: [], photoEntries: {} };
|
||||
@@ -54,6 +56,16 @@ function draw(over: { section?: RailSection; onSection?: (s: RailSection) => voi
|
||||
);
|
||||
}
|
||||
|
||||
/** Studio's stylesheet, which jsdom does not apply: its rules are the assertion. */
|
||||
const STUDIO_CSS = readFileSync(resolve(process.cwd(), 'src/styles/studio.css'), 'utf8');
|
||||
|
||||
/** The rule written for exactly this selector, searched in `source` (one media query, say). */
|
||||
function rule(selector: string, source = STUDIO_CSS): string {
|
||||
const at = source.indexOf(`${selector} {`);
|
||||
expect(at, `${selector} missing from studio.css`).toBeGreaterThan(-1);
|
||||
return source.slice(at, source.indexOf('}', at) + 1);
|
||||
}
|
||||
|
||||
/** The head of the panel that is open: the name every panel opens on. */
|
||||
const panelTitle = () => must(document.querySelector('.st-side .st-panel-head .st-what-label')).textContent;
|
||||
|
||||
@@ -168,4 +180,42 @@ describe('the rail', () => {
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'journey.studio.stickers' }));
|
||||
expect(screen.getByRole('textbox', { name: 'journey.studio.searchStickers' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-STUDIO-RAIL-007: wraps a name only between words, the rail growing to its longest word instead', () => {
|
||||
// "Elements" in the label's 9.5px Poppins is 50.4px wide and a 52px tab left
|
||||
// it 48px, so overflow-wrap: anywhere cut it into "Element" and "s" wherever
|
||||
// no hyphenation dictionary was at hand. Catalan's "Maquetacions" is 77px and
|
||||
// a larger text size widens every word, so no fixed width holds them all.
|
||||
draw();
|
||||
const selector = '.trek-dash .st-rail-btn .st-rail-label';
|
||||
const names = [...document.querySelectorAll('.st-rail-label')];
|
||||
expect(names).toHaveLength(RAIL_SECTIONS.length);
|
||||
// Every name the rail renders is one the rule reaches; .trek-dash is the shell's root.
|
||||
for (const name of names) expect(name.matches(selector.replace('.trek-dash ', ''))).toBe(true);
|
||||
const label = rule(selector);
|
||||
expect(label).toMatch(/overflow-wrap:\s*normal;/);
|
||||
expect(label).toMatch(/word-break:\s*keep-all;/);
|
||||
expect(label).toMatch(/hyphens:\s*manual;/);
|
||||
expect(label).not.toMatch(/anywhere|break-all|break-word|hyphens:\s*auto/);
|
||||
// The type stays on the caption step the user's text size scales.
|
||||
expect(label).toMatch(/font-size:\s*calc\([\d.]+px \* var\(--fs-scale-caption, 1\)\)/);
|
||||
|
||||
// A tab takes the rail's width, and the rail's column is 64px or its
|
||||
// min-content, which is the longest word, in each of the three layouts.
|
||||
expect(rule('.trek-dash .st-rail-btn')).not.toMatch(/[\s;{]width:/);
|
||||
const columns = [...STUDIO_CSS.matchAll(/\.st-body \{[^}]*?grid-template-columns:\s*([^;]+);/g)].map((m) => m[1]);
|
||||
expect(columns).toEqual([
|
||||
'minmax(64px, min-content) 236px minmax(0, 1fr) 300px',
|
||||
'minmax(64px, min-content) 236px minmax(0, 1fr)',
|
||||
'minmax(64px, min-content) minmax(0, 1fr)',
|
||||
]);
|
||||
|
||||
// Below 1080 the panel floats over the workbench's own cell, so it starts
|
||||
// where the rail ends, not at a fixed 64px from the edge.
|
||||
const at = STUDIO_CSS.indexOf('@media (max-width: 1079px) {');
|
||||
expect(at, 'the narrow layout missing from studio.css').toBeGreaterThan(-1);
|
||||
const narrow = STUDIO_CSS.slice(at, STUDIO_CSS.indexOf('\n}', at));
|
||||
expect(narrow).toContain('.trek-dash .st-side, .trek-dash .st-work-wrap { grid-area: 1 / 2; }');
|
||||
expect(rule('.trek-dash .st-side', narrow)).not.toMatch(/position:\s*absolute|inset-inline-start/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,9 +11,9 @@ import { RAIL_IDS, RAIL_SECTIONS, type RailSection } from './railSections';
|
||||
* A list of tabs to a screen reader and to the keyboard, as it is to the eye:
|
||||
* Tab reaches the open section, the arrows move up and down the rail, Home and
|
||||
* End jump to its ends, and Enter or Space opens a section (studioRoving.ts).
|
||||
* A name longer than the rail is wide wraps onto a second line and breaks
|
||||
* inside a word only when the word alone is wider than the rail; the title
|
||||
* carries it whole either way.
|
||||
* A name longer than the rail is wide wraps onto a second line, only ever
|
||||
* between words: where one word alone is wider, the rail grows to fit it
|
||||
* (studio.css) instead of cutting it. The title carries the name whole.
|
||||
*/
|
||||
export function StudioRail({
|
||||
section,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useMatch, useNavigate } from 'react-router';
|
||||
|
||||
import type { AssistantChatTrip } from '../../../components/Assistant/assistantViewContext';
|
||||
import type { EntityTarget } from '../../../components/Assistant/cards/cardTypes';
|
||||
import { chatPagePath, useAssistantHome, useRouteJournalChat } from '../../../components/Assistant/useAssistantHome';
|
||||
import { useAssistantStatus } from '../../../components/Assistant/useAssistantStatus';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
import { AssistantScreen, type MapHops } from '../trip/assistant/MAssistantScreen';
|
||||
|
||||
@@ -26,7 +27,6 @@ export default function MAssistantHomeScreen() {
|
||||
const available = useAssistantStore((s) => s.status?.available === true);
|
||||
const open = useAssistantStore((s) => s.window.open);
|
||||
const minimized = useAssistantStore((s) => s.window.minimized);
|
||||
const loadStatus = useAssistantStore((s) => s.loadStatus);
|
||||
const close = useAssistantStore((s) => s.close);
|
||||
|
||||
// The trip's screen (or the journal's) takes over with the window still open, so its chat comes up there.
|
||||
@@ -46,10 +46,8 @@ export default function MAssistantHomeScreen() {
|
||||
[close, navigate]
|
||||
);
|
||||
|
||||
// The desktop window loads this itself; on the phone the pages outside a trip ask once.
|
||||
useEffect(() => {
|
||||
void loadStatus();
|
||||
}, [loadStatus]);
|
||||
// The desktop window loads this itself; on the phone the pages outside a trip ask here.
|
||||
useAssistantStatus();
|
||||
|
||||
if (inTrip || !available || !open || minimized) return null;
|
||||
return (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildUser } from '../../../../tests/helpers/factories';
|
||||
import { render, screen, waitFor } from '../../../../tests/helpers/render';
|
||||
import { seedStore } from '../../../../tests/helpers/store';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
|
||||
@@ -47,6 +48,8 @@ function status(over: Partial<AiStatusResponse> = {}): AiStatusResponse {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAssistantStore.setState({ status: null });
|
||||
// The card shows while the assistant is on, and only then is its status asked for.
|
||||
seedStore(useAddonStore, { loaded: true, addons: [{ id: 'ai_assistant', enabled: true }] });
|
||||
repo.models.mockResolvedValue([
|
||||
{ id: 'gemini-flash', label: 'Gemini Flash', supportsTools: true, free: true },
|
||||
{ id: 'gemini-pro', label: 'Gemini Pro', supportsTools: true },
|
||||
|
||||
@@ -23,6 +23,7 @@ import RoutePeekLazy from '../../../../components/Assistant/map/RoutePeekLazy';
|
||||
import { moveRoutePeek } from '../../../../components/Assistant/map/routePeekControl';
|
||||
import { useAssistantHintsSetting } from '../../../../components/Assistant/useAssistantHints';
|
||||
import { useAssistantLiveStep } from '../../../../components/Assistant/useAssistantLiveStep';
|
||||
import { useAssistantStatus } from '../../../../components/Assistant/useAssistantStatus';
|
||||
import { useAssistantThread } from '../../../../components/Assistant/useAssistantThread';
|
||||
import { useTripMemory } from '../../../../components/Assistant/useTripMemory';
|
||||
import { useKeyboardInset } from '../../../../hooks/useKeyboardInset';
|
||||
@@ -395,13 +396,10 @@ export default function MAssistantScreen({ planner, shell }: MTripSheetsProps) {
|
||||
const minimized = useAssistantStore((s) => s.window.minimized);
|
||||
const peeking = useAssistantStore((s) => s.routePeek?.tripId === planner.tripId);
|
||||
const running = useAssistantStore((s) => s.byTrip[planner.tripId]?.isRunning === true);
|
||||
const loadStatus = useAssistantStore((s) => s.loadStatus);
|
||||
const hops = useMapHops({ planner, shell });
|
||||
|
||||
// The desktop window loads this itself; on the phone the trip screen asks once.
|
||||
useEffect(() => {
|
||||
void loadStatus();
|
||||
}, [loadStatus]);
|
||||
// The desktop window loads this itself; on the phone the trip screen asks here.
|
||||
useAssistantStatus();
|
||||
|
||||
if (!available || !open || !planner.trip) return null;
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* FE-AI-STORE-001 to FE-AI-STORE-012
|
||||
* FE-AI-STORE-001 to FE-AI-STORE-014
|
||||
*
|
||||
* The store runs a turn end to end against a faked repo: the request it
|
||||
* sends, how the stream lands in the chat, and what each way of failing
|
||||
@@ -9,11 +9,14 @@
|
||||
*/
|
||||
import type { AiStatusResponse, AssistantMessage, AssistantThreadResponse, UiStreamChunk } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { seedStore } from '../../tests/helpers/store';
|
||||
import { AssistantRequestError } from '../api/assistant';
|
||||
import { aiConnectionsRepo } from '../repo/aiConnectionsRepo';
|
||||
import { assistantRepo } from '../repo/assistantRepo';
|
||||
import { OnlineOnlyError } from '../repo/onlineOnly';
|
||||
import { useAddonStore } from './addonStore';
|
||||
import { chatOf, useAssistantStore } from './assistantStore';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { useSettingsStore } from './settingsStore';
|
||||
|
||||
const TRIP = 4;
|
||||
@@ -60,6 +63,10 @@ async function* hangingStream(first: UiStreamChunk[], signal: AbortSignal): Asyn
|
||||
});
|
||||
}
|
||||
|
||||
/** The addon list as it lands from the server, with these addons on. */
|
||||
const addonsOn = (...ids: string[]) =>
|
||||
seedStore(useAddonStore, { loaded: true, addons: ids.map((id) => ({ id, enabled: true })) });
|
||||
|
||||
const chat = () => chatOf(useAssistantStore.getState(), TRIP);
|
||||
const store = () => useAssistantStore.getState();
|
||||
|
||||
@@ -76,6 +83,7 @@ beforeEach(() => {
|
||||
lastApplied: null,
|
||||
});
|
||||
useSettingsStore.setState((s) => ({ settings: { ...s.settings, language: 'de' } }));
|
||||
useAuthStore.setState({ managed: false });
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
@@ -311,6 +319,7 @@ describe('assistantStore: threads, window and status', () => {
|
||||
});
|
||||
|
||||
it('FE-AI-STORE-012: status fails closed, offline keeps the last answer, and the privacy notice is acknowledged', async () => {
|
||||
addonsOn('ai_assistant');
|
||||
vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValueOnce(STATUS);
|
||||
await store().loadStatus();
|
||||
expect(store().status).toEqual(STATUS);
|
||||
@@ -328,4 +337,39 @@ describe('assistantStore: threads, window and status', () => {
|
||||
await store().loadStatus();
|
||||
expect(store().status).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-STORE-013: the status is asked for only while the assistant or AI parsing is on', async () => {
|
||||
const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS);
|
||||
useAddonStore.setState({ addons: [], loaded: false });
|
||||
await store().loadStatus();
|
||||
addonsOn('mcp');
|
||||
await store().loadStatus();
|
||||
expect(status).not.toHaveBeenCalled();
|
||||
expect(store().status).toBeNull();
|
||||
|
||||
addonsOn('llm_parsing');
|
||||
await store().loadStatus();
|
||||
expect(status).toHaveBeenCalledTimes(1);
|
||||
expect(store().status).toEqual(STATUS);
|
||||
|
||||
addonsOn('ai_assistant');
|
||||
await store().loadStatus();
|
||||
expect(status).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Switched off again, the assistant goes away without a request.
|
||||
addonsOn();
|
||||
await store().loadStatus();
|
||||
expect(status).toHaveBeenCalledTimes(2);
|
||||
expect(store().status).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-STORE-014: a managed install, which refuses /api/ai, is not asked whatever its addons say', async () => {
|
||||
const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS);
|
||||
useAssistantStore.setState({ status: STATUS });
|
||||
useAuthStore.setState({ managed: true });
|
||||
addonsOn('ai_assistant', 'llm_parsing');
|
||||
await store().loadStatus();
|
||||
expect(status).not.toHaveBeenCalled();
|
||||
expect(store().status).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { Assignment } from '../types';
|
||||
import { browserTimeZone } from '../utils/localDate';
|
||||
import { randomId } from '../utils/randomId';
|
||||
import type { RouteProfileFocus } from '../utils/routeGeometry';
|
||||
import { useAddonStore } from './addonStore';
|
||||
import { assistantScopeOf, assistantTargetOf, type AssistantChatKey } from './assistantChatKey';
|
||||
import {
|
||||
blockDays,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
type WindowRect,
|
||||
} from './assistantMapModel';
|
||||
import { applyChunk, closeUndo, markDecisions } from './assistantMessages';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { useSettingsStore } from './settingsStore';
|
||||
import { useTripStore } from './tripStore';
|
||||
|
||||
@@ -120,7 +122,10 @@ interface UndoBlockResult {
|
||||
}
|
||||
|
||||
interface AssistantState {
|
||||
/** What the assistant runs on for this user; null until loaded, and the assistant stays hidden until then. */
|
||||
/**
|
||||
* What the assistant runs on for this user; null until loaded, while no addon
|
||||
* opens /api/ai (aiRoutesOpen) and on a managed install, and the assistant stays hidden then.
|
||||
*/
|
||||
status: AiStatusResponse | null;
|
||||
window: { open: boolean; minimized: boolean };
|
||||
/** The chats by `AssistantChatKey`: a trip's id, or a journey's key. */
|
||||
@@ -316,6 +321,19 @@ export function chatOf(state: Pick<AssistantState, 'byTrip'>, key: AssistantChat
|
||||
return state.byTrip[key] ?? EMPTY_CHAT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the addons open /api/ai: the server opens it while the assistant or
|
||||
* AI parsing is on, the two sharing one connection, and answers 404 while
|
||||
* both are off. Closed until the addon list is in. A selector on the addon
|
||||
* store; a managed install refuses /api/ai whatever it says (loadStatus).
|
||||
*/
|
||||
export function aiRoutesOpen({
|
||||
loaded,
|
||||
isEnabled,
|
||||
}: Pick<ReturnType<typeof useAddonStore.getState>, 'loaded' | 'isEnabled'>): boolean {
|
||||
return loaded && (isEnabled('ai_assistant') || isEnabled('llm_parsing'));
|
||||
}
|
||||
|
||||
function httpStatus(err: unknown): number | undefined {
|
||||
return (err as { response?: { status?: number } } | null)?.response?.status;
|
||||
}
|
||||
@@ -568,6 +586,12 @@ function backToAfter(byTrip: Record<number, AssistantTripChat>): Record<number,
|
||||
function shellActions(ctx: Ctx) {
|
||||
return {
|
||||
loadStatus: async () => {
|
||||
// While /api/ai is closed the request could only get a 404, or the 403 of a managed install: nothing is asked,
|
||||
// and the assistant stays hidden. The managed flag comes with the app config, which can land after the addons.
|
||||
if (useAuthStore.getState().managed || !aiRoutesOpen(useAddonStore.getState())) {
|
||||
ctx.set({ status: null });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ctx.set({ status: await aiConnectionsRepo.status() });
|
||||
} catch (err) {
|
||||
|
||||
@@ -211,16 +211,17 @@
|
||||
.trek-dash .st-sep { width: 1px; height: 20px; background: var(--line-2); margin: 0 2px; flex: none; }
|
||||
|
||||
/* ----------------- body: rail | workbench | inspector ----------------- */
|
||||
/* The rail is 64px, or as wide as the longest word in a section's name. */
|
||||
.trek-dash .st-body {
|
||||
display: grid;
|
||||
grid-template-columns: 64px 236px minmax(0, 1fr) 300px;
|
||||
grid-template-columns: minmax(64px, min-content) 236px minmax(0, 1fr) 300px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Under 1280 the inspector floats over the workbench instead of narrowing it —
|
||||
below roughly 700px of canvas a spread stops being legible at any zoom. */
|
||||
@media (max-width: 1279px) {
|
||||
.trek-dash .st-body { grid-template-columns: 64px 236px minmax(0, 1fr); }
|
||||
.trek-dash .st-body { grid-template-columns: minmax(64px, min-content) 236px minmax(0, 1fr); }
|
||||
.trek-dash .st-inspector {
|
||||
position: absolute;
|
||||
top: 56px;
|
||||
@@ -231,12 +232,11 @@
|
||||
}
|
||||
}
|
||||
@media (max-width: 1079px) {
|
||||
.trek-dash .st-body { grid-template-columns: 64px minmax(0, 1fr); }
|
||||
.trek-dash .st-body { grid-template-columns: minmax(64px, min-content) minmax(0, 1fr); }
|
||||
/* The panel floats over the workbench's cell, so it starts wherever the rail ends. */
|
||||
.trek-dash .st-side, .trek-dash .st-work-wrap { grid-area: 1 / 2; }
|
||||
.trek-dash .st-side {
|
||||
position: absolute;
|
||||
top: 56px;
|
||||
inset-inline-start: 64px;
|
||||
bottom: 0;
|
||||
justify-self: start;
|
||||
width: 236px;
|
||||
box-shadow: var(--sh-lg);
|
||||
}
|
||||
@@ -457,7 +457,6 @@
|
||||
}
|
||||
.trek-dash .st-rail-btn {
|
||||
flex: none;
|
||||
width: 52px;
|
||||
min-height: 52px;
|
||||
padding: 6px 2px;
|
||||
display: flex;
|
||||
@@ -469,9 +468,9 @@
|
||||
color: var(--ink-3);
|
||||
transition: background .16s, color .16s;
|
||||
}
|
||||
/* Two lines at most, broken inside a word that is longer than the rail is wide
|
||||
("Путешествие", "Tərtibatlar"); the button's title carries the whole name. */
|
||||
.trek-dash .st-rail-btn span {
|
||||
/* Wrapped between words, never inside one: the rail grows to the longest word
|
||||
("Maquetacions", "Путешествие"), and keep-all holds "コンテンツ" whole too. */
|
||||
.trek-dash .st-rail-btn .st-rail-label {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
@@ -481,8 +480,9 @@
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
hyphens: auto;
|
||||
overflow-wrap: normal;
|
||||
word-break: keep-all;
|
||||
hyphens: manual;
|
||||
}
|
||||
.trek-dash .st-rail-btn:focus { outline: none; }
|
||||
.trek-dash .st-rail-btn:focus-visible { box-shadow: 0 0 0 2px var(--accent-soft), 0 0 0 3px var(--accent); }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
alongShortestArc,
|
||||
computeMapViewport,
|
||||
MAX_ZOOM_GL,
|
||||
MAX_ZOOM_RASTER,
|
||||
@@ -263,3 +264,26 @@ describe('computeMapViewport', () => {
|
||||
expect(withPadding.zoom).toBe(equivalent.zoom)
|
||||
})
|
||||
})
|
||||
|
||||
describe('alongShortestArc', () => {
|
||||
const FIJI: [number, number] = [-18.14, 178.44]
|
||||
const TONGA: [number, number] = [-21.14, -175.2]
|
||||
const SAMOA: [number, number] = [-13.83, -171.76]
|
||||
|
||||
it('carries the places past the antimeridian on beyond 180, so a min-max box between them is the short one', () => {
|
||||
const lngs = alongShortestArc([FIJI, TONGA, SAMOA]).map(([, lng]) => Math.round(lng * 100) / 100)
|
||||
|
||||
// From Fiji east across the date line: under ten degrees wide, not the 350 the other way round.
|
||||
expect(lngs).toEqual([178.44, 184.8, 188.24])
|
||||
})
|
||||
|
||||
it('leaves places that do not cross the antimeridian as they are', () => {
|
||||
const cafes: [number, number][] = [[48.21, 16.37], [38.71, -9.14], [55.7, 12.55]]
|
||||
|
||||
expect(alongShortestArc(cafes)).toEqual(cafes)
|
||||
})
|
||||
|
||||
it('has nothing to lay out for no places', () => {
|
||||
expect(alongShortestArc([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -146,6 +146,18 @@ function lngExtent(lngs: number[], wrapsWorld: boolean): { west: number; east: n
|
||||
return { west, east, span: east - west }
|
||||
}
|
||||
|
||||
/**
|
||||
* `[lat, lng]` points with their longitudes laid along the narrowest arc that holds them all
|
||||
* (lngExtent), for a renderer that wraps the world: a point past the antimeridian goes on
|
||||
* beyond 180 instead of starting over at -180. A bounding box built from them by plain min and max (MapLibre's and Mapbox's
|
||||
* extend) then spans the few degrees from Fiji to Samoa, not the rest of the world.
|
||||
*/
|
||||
export function alongShortestArc(points: readonly [number, number][]): [number, number][] {
|
||||
if (points.length === 0) return []
|
||||
const { west } = lngExtent(points.map(([, lng]) => lng), true)
|
||||
return points.map(([lat, lng]) => [lat, lng < west ? lng + 360 : lng])
|
||||
}
|
||||
|
||||
/** Zoom at which a span covering `fraction` of the world fills `px` pixels. */
|
||||
function zoomForFraction(px: number, tileSize: number, fraction: number, maxZoom: number): number {
|
||||
// A zero fraction (identical coordinates) would divide by zero and yield Infinity.
|
||||
|
||||
@@ -9,7 +9,7 @@ import { dbNow } from '../../db/types';
|
||||
import { logError, logInfo } from '../audit/audit-log.logger';
|
||||
import { withRequestContext } from '../database/request-context';
|
||||
import { LlmParseService } from '../llm-parse/llm-parse.service';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { ASSISTANT_FILES_PREFIX, StorageService } from '../storage/storage.service';
|
||||
import type { AgentToolRun } from './assistant-agent';
|
||||
import { NO_SUCH_ATTACHMENT, NO_TEXT, type AttachedTurn } from './assistant-attachment-tools';
|
||||
import { AssistantKeepFileService } from './assistant-keep-file.service';
|
||||
@@ -32,9 +32,10 @@ const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
/**
|
||||
* Where the bytes sit: under the trip files' category, which is never served
|
||||
* by path (`/uploads/files` refuses every request), in a folder of their own.
|
||||
* Storage names the folder, so a demo instance still deletes these bytes.
|
||||
*/
|
||||
const STORAGE_CATEGORY = 'files';
|
||||
const KEY_PREFIX = 'assistant/';
|
||||
const KEY_PREFIX = ASSISTANT_FILES_PREFIX;
|
||||
|
||||
/** The most text kept of one file; a model is sent a window of it, never all of it. */
|
||||
const MAX_STORED_TEXT_CHARS = 200_000;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { User } from '../../types';
|
||||
import { RuntimeEnvService } from '../app-config/runtime-env.service';
|
||||
import { CurrentUser } from '../auth-core/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
||||
import { DEMO_WRITE_ERROR, isDemoWriteBlocked } from '../common/demo-write';
|
||||
import { ResponseContract } from '../common/response-contract';
|
||||
import { SpoolCleanupInterceptor } from '../common/spool-cleanup.interceptor';
|
||||
import { BLOCKED_EXTENSIONS, MAX_FILE_SIZE } from '../files/files.constants';
|
||||
@@ -130,6 +132,8 @@ export class CollabController {
|
||||
constructor(
|
||||
private readonly collab: CollabService,
|
||||
private readonly storage: StorageService,
|
||||
// Whether DEMO_MODE is on, read on every upload: the demo account's are refused.
|
||||
private readonly env: RuntimeEnvService,
|
||||
) {}
|
||||
|
||||
private async requireTrip(tripId: string, user: User) {
|
||||
@@ -242,6 +246,12 @@ export class CollabController {
|
||||
};
|
||||
try {
|
||||
const trip = await this.requireTrip(tripId, user);
|
||||
// After the 404, as on the trip-file upload, so the demo account cannot
|
||||
// learn which trips exist; refused like every other upload of it
|
||||
// (common/demo-write.ts).
|
||||
if (isDemoWriteBlocked(this.env, user.email)) {
|
||||
throw new HttpException(DEMO_WRITE_ERROR, 403);
|
||||
}
|
||||
if (!(await this.collab.canUploadFiles(trip, user))) {
|
||||
throw new HttpException({ error: 'No permission to upload files' }, 403);
|
||||
}
|
||||
@@ -491,6 +501,11 @@ export class CollabController {
|
||||
cleanupSpool();
|
||||
throw err;
|
||||
}
|
||||
// Only the images are refused to the demo account; its text still posts.
|
||||
if (uploaded.length && isDemoWriteBlocked(this.env, user.email)) {
|
||||
cleanupSpool();
|
||||
throw new HttpException(DEMO_WRITE_ERROR, 403);
|
||||
}
|
||||
if (uploaded.length && !(await this.collab.canUploadFiles(trip, user))) {
|
||||
cleanupSpool();
|
||||
throw new HttpException({ error: 'No permission to upload files' }, 403);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CollabPolls } from '../../db/entities/CollabPolls.entity';
|
||||
import { Trips } from '../../db/entities/Trips.entity';
|
||||
import { PluginGuardsModule } from '../../nest-rpc/plugin-guards.module';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
import { AppConfigModule } from '../app-config/app-config.module';
|
||||
import { RateLimitModule } from '../common/rate-limit.module';
|
||||
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
@@ -57,6 +58,8 @@ import { MulterModule } from '@nestjs/platform-express';
|
||||
PluginGuardsModule,
|
||||
AddonsModule,
|
||||
RateLimitModule,
|
||||
// RuntimeEnvService: the controller refuses the demo account's uploads in demo mode.
|
||||
AppConfigModule,
|
||||
],
|
||||
controllers: [CollabController],
|
||||
providers: [CollabService, CollabMcp, CollabRpc],
|
||||
|
||||
@@ -3,7 +3,7 @@ import { RuntimeEnvService } from '../app-config/runtime-env.service';
|
||||
import { DEMO_ACCOUNT_SETTINGS, isDemoEmail } from './demo';
|
||||
import { DomainError } from './domain-error';
|
||||
|
||||
/** The exact 403 body the six upload endpoints have always returned. */
|
||||
/** The exact 403 body the upload endpoints answer the demo account with. */
|
||||
export const DEMO_WRITE_ERROR = {
|
||||
error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.',
|
||||
} as const;
|
||||
@@ -12,9 +12,11 @@ export const DEMO_WRITE_ERROR = {
|
||||
* Whether this user's writes are blocked because the instance runs in demo mode.
|
||||
*
|
||||
* The condition was copy-pasted into six upload handlers, each with its own copy
|
||||
* of the message. It lives here once now, and every one of them calls it.
|
||||
* of the message. It lives here once now, and every one of them calls it, as do
|
||||
* the journey photo, video and cover uploads and the collab note file and chat
|
||||
* image uploads that came without it.
|
||||
*
|
||||
* Deliberately a function and not a guard, though all six sites are handlers and
|
||||
* Deliberately a function and not a guard, though every site is a handler and
|
||||
* a guard is the obvious shape. Guards run before the multipart parser, so
|
||||
* throwing from one leaves the request body unread and Node resets the
|
||||
* connection: the client sees ECONNRESET instead of the 403. PROFILE-015 catches
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Injectable } from '@nestjs/common';
|
||||
* hazard, confirmed by `task-0-report.md` §6), so an injected `EntityManager`
|
||||
* resolving the current request's fork is exactly the right shape.
|
||||
*
|
||||
* `isDemoWriteBlocked`/`DEMO_WRITE_ERROR` (the six-upload-endpoint sibling
|
||||
* `isDemoWriteBlocked`/`DEMO_WRITE_ERROR` (the upload endpoints' sibling
|
||||
* check) stay exactly where they are, in `common/demo-write.ts` — a
|
||||
* different, pure-function check over an already-known email string that
|
||||
* this task does not touch.
|
||||
|
||||
@@ -22,6 +22,8 @@ import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import crypto from 'node:crypto';
|
||||
import type { User } from '../../types';
|
||||
import { RuntimeEnvService } from '../app-config/runtime-env.service';
|
||||
import { isDemoWriteBlocked, DEMO_WRITE_ERROR } from '../common/demo-write';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { journeyThumbName } from '../memories/thumbnail.service';
|
||||
import { JourneyService } from './journey.service';
|
||||
@@ -172,6 +174,8 @@ export class JourneyController {
|
||||
private readonly photoCapture: JourneyPhotoCaptureService,
|
||||
// Which entries the gallery's photos sit on, told to every tab once written.
|
||||
private readonly photoLinks: JourneyPhotoLinksService,
|
||||
// Whether DEMO_MODE is on, read on every upload (refuseDemoUpload).
|
||||
private readonly env: RuntimeEnvService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -199,6 +203,7 @@ export class JourneyController {
|
||||
* The commit has to happen first (the Immich mirror reads the final path), so
|
||||
* every refusal after it has to take the bytes back out. Nothing sweeps
|
||||
* orphans, and a loop of rejected POSTs would otherwise just fill the disk.
|
||||
* A demo instance keeps them all the same (StorageService.delete).
|
||||
*/
|
||||
private async discardJourneyUploads(files: Express.Multer.File[]): Promise<void> {
|
||||
for (const f of files) {
|
||||
@@ -207,6 +212,22 @@ export class JourneyController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse the shared demo account's upload in demo mode, with the 403 the
|
||||
* other upload routes answer (common/demo-write.ts). Called in the handler,
|
||||
* once multer has drained the body, so the 403 arrives instead of an
|
||||
* ECONNRESET. It runs before the commit: the spooled parts are all there is
|
||||
* to take back, and a committed object would stay, since a demo instance
|
||||
* keeps what is deleted (StorageService.delete).
|
||||
*/
|
||||
private refuseDemoUpload(user: User, files: ReadonlyArray<Express.Multer.File | undefined>): void {
|
||||
if (!isDemoWriteBlocked(this.env, user.email)) return;
|
||||
for (const f of files) {
|
||||
if (f?.path) { try { fs.unlinkSync(f.path); } catch { /* best-effort */ } }
|
||||
}
|
||||
throw new HttpException(DEMO_WRITE_ERROR, 403);
|
||||
}
|
||||
|
||||
// ── Static prefix routes (before /:id) ──────────────────────────────────
|
||||
@Get()
|
||||
@ResponseContract(journeyListResponseSchema)
|
||||
@@ -270,6 +291,7 @@ export class JourneyController {
|
||||
@Post('entries/:entryId/photos')
|
||||
@UseInterceptors(FilesInterceptor('photos'))
|
||||
async uploadEntryPhotos(@CurrentUser() user: User, @Param('entryId') entryId: string, @UploadedFiles() files: Express.Multer.File[] | undefined, @Body() body: JourneyEntryPhotoUploadDto) {
|
||||
this.refuseDemoUpload(user, files ?? []);
|
||||
if (!files?.length) {
|
||||
throw new HttpException({ error: 'No files uploaded' }, 400);
|
||||
}
|
||||
@@ -336,6 +358,7 @@ export class JourneyController {
|
||||
) {
|
||||
const video = files?.video?.[0];
|
||||
const poster = files?.poster?.[0];
|
||||
this.refuseDemoUpload(user, [video, poster]);
|
||||
const cleanup = () => {
|
||||
for (const f of [video, poster]) {
|
||||
if (f?.path) { try { fs.unlinkSync(f.path); } catch { /* best-effort */ } }
|
||||
@@ -477,6 +500,7 @@ export class JourneyController {
|
||||
@Post(':id/gallery/photos')
|
||||
@UseInterceptors(FilesInterceptor('photos'))
|
||||
async uploadGalleryPhotos(@CurrentUser() user: User, @Param('id') id: string, @UploadedFiles() files: Express.Multer.File[] | undefined) {
|
||||
this.refuseDemoUpload(user, files ?? []);
|
||||
if (!files?.length) {
|
||||
throw new HttpException({ error: 'No files uploaded' }, 400);
|
||||
}
|
||||
@@ -509,6 +533,7 @@ export class JourneyController {
|
||||
) {
|
||||
const video = files?.video?.[0];
|
||||
const poster = files?.poster?.[0];
|
||||
this.refuseDemoUpload(user, [video, poster]);
|
||||
// multer already spooled both parts; clean them up on a pre-commit rejection
|
||||
// so a bad POST doesn't orphan a 500 MB clip (#823).
|
||||
const cleanup = () => {
|
||||
@@ -579,6 +604,7 @@ export class JourneyController {
|
||||
@HttpCode(200) // Express answers cover with res.json (200).
|
||||
@UseInterceptors(FileInterceptor('cover'))
|
||||
async cover(@CurrentUser() user: User, @Param('id') id: string, @UploadedFile() file: Express.Multer.File | undefined, @Headers('x-socket-id') socketId?: string) {
|
||||
this.refuseDemoUpload(user, [file]);
|
||||
if (!file) {
|
||||
throw new HttpException({ error: 'No file uploaded' }, 400);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
import { AppConfigModule } from '../app-config/app-config.module';
|
||||
import { UserConnectionRepositoriesModule } from '../database/user-connection-repositories.module';
|
||||
import { AllowedFileTypesModule } from '../files/allowed-file-types.module';
|
||||
import { AllowedFileTypesService } from '../files/allowed-file-types.service';
|
||||
@@ -42,6 +43,8 @@ import { MulterModule } from '@nestjs/platform-express';
|
||||
}),
|
||||
StorageModule,
|
||||
AddonsModule,
|
||||
// RuntimeEnvService: the controller refuses the demo account's uploads in demo mode.
|
||||
AppConfigModule,
|
||||
MemoriesModule,
|
||||
JourneyDomainModule,
|
||||
JourneyPhotoCaptureModule,
|
||||
|
||||
@@ -106,6 +106,8 @@ export class ThumbnailService {
|
||||
let removed = 0;
|
||||
for await (const stat of this.storage.list('journey', 'thumbs/')) {
|
||||
if (!stat.key.endsWith('.jpg') || live.has(stat.key)) continue;
|
||||
// A demo instance keeps it (StorageService.keepsOnDelete): not reclaimed, so not counted.
|
||||
if (this.storage.keepsOnDelete('journey', stat.key)) continue;
|
||||
try {
|
||||
await this.storage.delete('journey', stat.key);
|
||||
removed++;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { RuntimeEnvService } from '../app-config/runtime-env.service';
|
||||
import { logDebug } from '../audit/audit-log.logger';
|
||||
import { contentTypeFor } from './content-type';
|
||||
import type { ReplicaFailure } from './drivers/mirror.driver';
|
||||
import { decideRange, isNotModified, validatorsFor, type ServingHeaders, type Validators } from './http-serving';
|
||||
@@ -33,6 +35,47 @@ interface Resolved {
|
||||
key: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The folder under `files` the assistant keeps the files attached to a
|
||||
* conversation in (assistant-attachments.service.ts takes it from here). They
|
||||
* live by a retention of days, so no demo baseline can rely on their bytes, and
|
||||
* a demo instance deletes them as any other does: attaching is not refused to
|
||||
* the demo account, and kept bytes of a public visitor's files would stay for good.
|
||||
*/
|
||||
export const ASSISTANT_FILES_PREFIX = 'assistant/';
|
||||
|
||||
/**
|
||||
* What a delete does on a demo instance, per category: `true` leaves the
|
||||
* object where it is. The hourly demo reset (src/demo/demo-reset.ts) puts only
|
||||
* the database back to its baseline, so a file a visitor deleted in between
|
||||
* would be missing from the restored rows for good. The categories holding
|
||||
* content that rows point at are kept; the caches and the backups are deleted
|
||||
* as on any other instance. A Record, so a new category does not compile until
|
||||
* it is decided here.
|
||||
*/
|
||||
const KEPT_ON_DEMO: Readonly<Record<ServedCategory, boolean>> = {
|
||||
// Trip files, collab note files and chat images. The assistant's attachments
|
||||
// under ASSISTANT_FILES_PREFIX are the exception, see keepsOnDelete.
|
||||
files: true,
|
||||
// Journey photos, videos, posters, covers and thumbs. A thumb a row records
|
||||
// is served as it is and never made again, so it stays with its photo.
|
||||
journey: true,
|
||||
// Trip and collection covers.
|
||||
covers: true,
|
||||
avatars: true,
|
||||
// Images uploaded for places.
|
||||
places: true,
|
||||
// The legacy shared photo folder, served and backed up like the uploads.
|
||||
photos: true,
|
||||
// A cache of the photos the map providers hand back, written by every lookup
|
||||
// and swept nightly. Kept, it would grow without bound on a public demo.
|
||||
'photos-google': false,
|
||||
// A cache of Immich and Synology assets that lives an hour and is fetched again on a miss.
|
||||
'photos-trek': false,
|
||||
// Never part of the baseline: the admin deletes them and auto-backup rotates them.
|
||||
backups: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* The facade all call sites use — and the ONLY entry point: callers address
|
||||
* storage exclusively as (category, name) on this injected service. They
|
||||
@@ -47,7 +90,12 @@ interface Resolved {
|
||||
*/
|
||||
@Injectable()
|
||||
export class StorageService {
|
||||
constructor(private readonly registry: StorageRegistryService) {}
|
||||
constructor(
|
||||
private readonly registry: StorageRegistryService,
|
||||
// Whether DEMO_MODE is on, read on every delete. A hand-built facade (the
|
||||
// unit suites) reads the same live env the container's provider does.
|
||||
private readonly runtimeEnv: RuntimeEnvService = new RuntimeEnvService(),
|
||||
) {}
|
||||
|
||||
/** Driver-agnostic global scratch space (data/tmp). */
|
||||
tempDir(): string {
|
||||
@@ -90,8 +138,32 @@ export class StorageService {
|
||||
return (await this.stat(category, name)) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether delete() leaves this object where it is: on a demo instance, one of
|
||||
* a category KEPT_ON_DEMO keeps, the assistant's attachments excepted. A
|
||||
* sweep asks first, so it never counts as reclaimed an object that stays.
|
||||
*/
|
||||
keepsOnDelete(category: ServedCategory, name: string): boolean {
|
||||
if (!KEPT_ON_DEMO[category]) return false;
|
||||
if (category === 'files' && name.startsWith(ASSISTANT_FILES_PREFIX)) return false;
|
||||
return this.runtimeEnv.isDemoMode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent, like the drivers. On a demo instance an object keepsOnDelete()
|
||||
* names is left alone and the call resolves as if it were gone; a later put()
|
||||
* under the same name still replaces it. That holds for the bytes a request
|
||||
* takes back out after a refused upload too: on a demo they stay, as a
|
||||
* visitor's uploads already do once the hourly reset drops their rows. The
|
||||
* name is validated first in both modes, so a hostile one is refused either way.
|
||||
*/
|
||||
async delete(category: ServedCategory, name: string): Promise<void> {
|
||||
const { driver, key } = this.resolve(category, name);
|
||||
if (this.keepsOnDelete(category, name)) {
|
||||
// The category only: the name can come from a request, and a log line is no place for it.
|
||||
logDebug(`[storage] demo mode keeps a deleted ${category} object, the hourly reset restores rows that name it`);
|
||||
return;
|
||||
}
|
||||
return driver.delete(key);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,8 +32,9 @@ import { Test } from '@nestjs/testing';
|
||||
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import path from 'node:path';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi, type MockInstance } from 'vitest';
|
||||
|
||||
vi.mock('../../src/db/database', async () => {
|
||||
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
|
||||
@@ -329,4 +330,50 @@ describe('Collab e2e (real auth guard + temp SQLite)', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('demo mode', () => {
|
||||
// Over the wire, because a refusal from a guard would reset the connection
|
||||
// while the client is still sending (PROFILE-015): the 403 has to arrive with
|
||||
// its body, and nothing of the upload may be written.
|
||||
const refused = { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' };
|
||||
|
||||
beforeAll(async () => {
|
||||
// Pinned id: sessionCookie(2) signs for exactly this user, the owner of trip 6.
|
||||
await makeUser(orm, { id: 2, username: 'demo', email: 'demo@trek.app', role: 'user', password_version: 0 });
|
||||
await insertRow(orm, Trips, { id: 6, title: 'Demo trip', user: 2 });
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('DEMO_MODE', 'true');
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('403 with the demo body for a note file, and no file row', async () => {
|
||||
const res = await request(server)
|
||||
.post('/api/trips/6/collab/notes/1/files')
|
||||
.set('Cookie', sessionCookie(2))
|
||||
.attach('file', path.join(__dirname, '../fixtures/test.pdf'));
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual(refused);
|
||||
expect(await countRows(orm, TripFiles)).toBe(0);
|
||||
});
|
||||
|
||||
it('403 with the demo body for a chat image, and no message row; a text message still posts', async () => {
|
||||
const res = await request(server)
|
||||
.post('/api/trips/6/collab/messages')
|
||||
.set('Cookie', sessionCookie(2))
|
||||
.field('text', 'look')
|
||||
.attach('images', path.join(__dirname, '../fixtures/small-image.jpg'));
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual(refused);
|
||||
expect(await countRows(orm, CollabMessages)).toBe(0);
|
||||
const text = await request(server)
|
||||
.post('/api/trips/6/collab/messages')
|
||||
.set('Cookie', sessionCookie(2))
|
||||
.send({ text: 'hi' });
|
||||
expect(text.status).toBe(201);
|
||||
expect(text.body.message).toMatchObject({ text: 'hi', trip_id: 6, user_id: 2 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import path from 'node:path';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
@@ -749,4 +749,39 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => {
|
||||
expect(jsvc.linkPhotoToEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('demo mode', () => {
|
||||
// Over the wire, because a refusal from a guard would reset the connection
|
||||
// while the client is still sending (PROFILE-015): the 403 has to arrive with
|
||||
// its body, and nothing of the upload may reach the service.
|
||||
const image = path.join(__dirname, '../fixtures/small-image.jpg');
|
||||
const clip = { filename: 'clip.mp4', contentType: 'video/mp4' };
|
||||
|
||||
beforeAll(() => {
|
||||
seedUser(db as never, { id: 2, username: 'demo', email: 'demo@trek.app' });
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('DEMO_MODE', 'true');
|
||||
jsvc.uploadGalleryPhotos.mockClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/api/journeys/entries/3/photos', 'photos'],
|
||||
['/api/journeys/entries/3/video', 'video'],
|
||||
['/api/journeys/9/gallery/photos', 'photos'],
|
||||
['/api/journeys/9/gallery/video', 'video'],
|
||||
['/api/journeys/9/cover', 'cover'],
|
||||
])('JOURNEY-E2E-020: POST %s answers the demo account 403 with the demo body', async (url, field) => {
|
||||
const req = request(server).post(url).set('Cookie', sessionCookie(2));
|
||||
const res = await (field === 'video'
|
||||
? req.attach(field, Buffer.from('not a clip'), clip)
|
||||
: req.attach(field, image));
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({ error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' });
|
||||
expect(jsvc.uploadGalleryPhotos).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
||||
import { JwtAuthGuard } from '../../../src/nest/auth-core/jwt-auth.guard';
|
||||
import {
|
||||
CollabController,
|
||||
@@ -14,7 +15,7 @@ import { HttpException } from '@nestjs/common';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as User;
|
||||
|
||||
@@ -49,6 +50,14 @@ const storageStub = {
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as StorageService;
|
||||
|
||||
/**
|
||||
* Build the controller over its stubs. The env is the live one, so a case about
|
||||
* demo mode stubs DEMO_MODE and every other case runs outside it.
|
||||
*/
|
||||
function ctl(service: CollabService, storage: StorageService = storageStub): CollabController {
|
||||
return new CollabController(service, storage, new RuntimeEnvService());
|
||||
}
|
||||
|
||||
async function thrownAsync(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
|
||||
try {
|
||||
await fn();
|
||||
@@ -66,15 +75,15 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
describe('notes', () => {
|
||||
it('GET lists', async () => {
|
||||
const s = svc({ listNotes: vi.fn().mockResolvedValue([{ id: 1 }]) } as Partial<CollabService>);
|
||||
expect(await new CollabController(s, storageStub).listNotes(user, '5')).toEqual({ notes: [{ id: 1 }] });
|
||||
expect(await ctl(s, storageStub).listNotes(user, '5')).toEqual({ notes: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
it('POST creates through the posting use case, which broadcasts and notifies (empty title now 400s in the Zod pipe)', async () => {
|
||||
const createNote = vi.fn().mockReturnValue({ id: 9 });
|
||||
const s = svc({ createNote } as Partial<CollabService>);
|
||||
expect(
|
||||
await new CollabController(s, storageStub).createNote(user, '5', { title: 'T', content: 'c' }, 'sock'),
|
||||
).toEqual({ note: { id: 9 } });
|
||||
expect(await ctl(s, storageStub).createNote(user, '5', { title: 'T', content: 'c' }, 'sock')).toEqual({
|
||||
note: { id: 9 },
|
||||
});
|
||||
const fields = { title: 'T', content: 'c', category: undefined, color: undefined, website: undefined };
|
||||
expect(createNote).toHaveBeenCalledWith('5', 1, fields);
|
||||
expect(s.restWriter).toHaveBeenCalledWith('5', user, 'sock');
|
||||
@@ -86,15 +95,17 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
it('PUT 404 when missing, else updates + broadcasts', async () => {
|
||||
expect(
|
||||
await thrownAsync(() =>
|
||||
new CollabController(
|
||||
svc({ updateNote: vi.fn().mockResolvedValue(null) } as Partial<CollabService>),
|
||||
storageStub,
|
||||
).updateNote(user, '5', '9', {}),
|
||||
ctl(svc({ updateNote: vi.fn().mockResolvedValue(null) } as Partial<CollabService>), storageStub).updateNote(
|
||||
user,
|
||||
'5',
|
||||
'9',
|
||||
{},
|
||||
),
|
||||
),
|
||||
).toEqual({ status: 404, body: { error: 'Note not found' } });
|
||||
const broadcast = vi.fn();
|
||||
const s = svc({ updateNote: vi.fn().mockResolvedValue({ id: 9 }), broadcast } as Partial<CollabService>);
|
||||
expect(await new CollabController(s, storageStub).updateNote(user, '5', '9', { title: 'b' }, 'sock')).toEqual({
|
||||
expect(await ctl(s, storageStub).updateNote(user, '5', '9', { title: 'b' }, 'sock')).toEqual({
|
||||
note: { id: 9 },
|
||||
});
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'collab:note:updated', { note: { id: 9 } }, 'sock');
|
||||
@@ -103,15 +114,16 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
it('DELETE 404 when missing, else success + broadcasts', async () => {
|
||||
expect(
|
||||
await thrownAsync(() =>
|
||||
new CollabController(
|
||||
svc({ deleteNote: vi.fn().mockResolvedValue(false) } as Partial<CollabService>),
|
||||
storageStub,
|
||||
).deleteNote(user, '5', '9'),
|
||||
ctl(svc({ deleteNote: vi.fn().mockResolvedValue(false) } as Partial<CollabService>), storageStub).deleteNote(
|
||||
user,
|
||||
'5',
|
||||
'9',
|
||||
),
|
||||
),
|
||||
).toEqual({ status: 404, body: { error: 'Note not found' } });
|
||||
const broadcast = vi.fn();
|
||||
const s = svc({ deleteNote: vi.fn().mockResolvedValue(true), broadcast } as Partial<CollabService>);
|
||||
expect(await new CollabController(s, storageStub).deleteNote(user, '5', '9', 'sock')).toEqual({ success: true });
|
||||
expect(await ctl(s, storageStub).deleteNote(user, '5', '9', 'sock')).toEqual({ success: true });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'collab:note:deleted', { noteId: 9 }, 'sock');
|
||||
});
|
||||
});
|
||||
@@ -121,24 +133,22 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
it('403 without file_upload, 400 without file, 404 unknown note, else commits + returns result', async () => {
|
||||
expect(
|
||||
await thrownAsync(() =>
|
||||
new CollabController(svc({ canUploadFiles: vi.fn().mockResolvedValue(false) }), storageStub).addNoteFile(
|
||||
ctl(svc({ canUploadFiles: vi.fn().mockResolvedValue(false) }), storageStub).addNoteFile(user, '5', '9', file),
|
||||
),
|
||||
).toEqual({ status: 403, body: { error: 'No permission to upload files' } });
|
||||
expect(await thrownAsync(() => ctl(svc(), storageStub).addNoteFile(user, '5', '9', undefined))).toEqual({
|
||||
status: 400,
|
||||
body: { error: 'No file uploaded' },
|
||||
});
|
||||
expect(
|
||||
await thrownAsync(() =>
|
||||
ctl(svc({ addNoteFile: vi.fn().mockResolvedValue(null) } as Partial<CollabService>), storageStub).addNoteFile(
|
||||
user,
|
||||
'5',
|
||||
'9',
|
||||
file,
|
||||
),
|
||||
),
|
||||
).toEqual({ status: 403, body: { error: 'No permission to upload files' } });
|
||||
expect(
|
||||
await thrownAsync(() => new CollabController(svc(), storageStub).addNoteFile(user, '5', '9', undefined)),
|
||||
).toEqual({ status: 400, body: { error: 'No file uploaded' } });
|
||||
expect(
|
||||
await thrownAsync(() =>
|
||||
new CollabController(
|
||||
svc({ addNoteFile: vi.fn().mockResolvedValue(null) } as Partial<CollabService>),
|
||||
storageStub,
|
||||
).addNoteFile(user, '5', '9', file),
|
||||
),
|
||||
).toEqual({ status: 404, body: { error: 'Note not found' } });
|
||||
const broadcast = vi.fn();
|
||||
const s = svc({
|
||||
@@ -146,7 +156,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
getFormattedNoteById: vi.fn().mockResolvedValue({ id: 9 }),
|
||||
broadcast,
|
||||
} as Partial<CollabService>);
|
||||
expect(await new CollabController(s, storageStub).addNoteFile(user, '5', '9', file, 'sock')).toEqual({
|
||||
expect(await ctl(s, storageStub).addNoteFile(user, '5', '9', file, 'sock')).toEqual({
|
||||
file: { id: 3 },
|
||||
});
|
||||
expect(storageStub.put).toHaveBeenCalledWith('files', 'a.pdf', { tmpPath: undefined });
|
||||
@@ -161,7 +171,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
const spooled = { filename: 'a.pdf', path: spool } as Express.Multer.File;
|
||||
expect(
|
||||
await thrownAsync(() =>
|
||||
new CollabController(svc({ canUploadFiles: vi.fn().mockResolvedValue(false) }), storageStub).addNoteFile(
|
||||
ctl(svc({ canUploadFiles: vi.fn().mockResolvedValue(false) }), storageStub).addNoteFile(
|
||||
user,
|
||||
'5',
|
||||
'9',
|
||||
@@ -175,12 +185,10 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
fs.writeFileSync(spool2, 'x');
|
||||
expect(
|
||||
await thrownAsync(() =>
|
||||
new CollabController(svc({ verifyTripAccess: vi.fn().mockResolvedValue(null) }), storageStub).addNoteFile(
|
||||
user,
|
||||
'5',
|
||||
'9',
|
||||
{ filename: 'b.pdf', path: spool2 } as Express.Multer.File,
|
||||
),
|
||||
ctl(svc({ verifyTripAccess: vi.fn().mockResolvedValue(null) }), storageStub).addNoteFile(user, '5', '9', {
|
||||
filename: 'b.pdf',
|
||||
path: spool2,
|
||||
} as Express.Multer.File),
|
||||
),
|
||||
).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
expect(fs.existsSync(spool2)).toBe(false);
|
||||
@@ -188,10 +196,12 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
// Past the commit the spool file is gone, so the final object is what has
|
||||
// to go instead.
|
||||
await thrownAsync(() =>
|
||||
new CollabController(
|
||||
svc({ addNoteFile: vi.fn().mockResolvedValue(null) } as Partial<CollabService>),
|
||||
storageStub,
|
||||
).addNoteFile(user, '5', '9', file),
|
||||
ctl(svc({ addNoteFile: vi.fn().mockResolvedValue(null) } as Partial<CollabService>), storageStub).addNoteFile(
|
||||
user,
|
||||
'5',
|
||||
'9',
|
||||
file,
|
||||
),
|
||||
);
|
||||
expect(storageStub.delete).toHaveBeenCalledWith('files', 'a.pdf');
|
||||
});
|
||||
@@ -199,7 +209,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
it('DELETE file 404 when missing, else success', async () => {
|
||||
expect(
|
||||
await thrownAsync(() =>
|
||||
new CollabController(
|
||||
ctl(
|
||||
svc({ deleteNoteFile: vi.fn().mockResolvedValue(false) } as Partial<CollabService>),
|
||||
storageStub,
|
||||
).deleteNoteFile(user, '5', '9', '3'),
|
||||
@@ -210,22 +220,22 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
getFormattedNoteById: vi.fn().mockResolvedValue({ id: 9 }),
|
||||
broadcast: vi.fn(),
|
||||
} as Partial<CollabService>);
|
||||
expect(await new CollabController(s, storageStub).deleteNoteFile(user, '5', '9', '3')).toEqual({ success: true });
|
||||
expect(await ctl(s, storageStub).deleteNoteFile(user, '5', '9', '3')).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('polls', () => {
|
||||
it('POST creates (missing question / <2 options now 400 in the Zod pipe)', async () => {
|
||||
const s = svc({ createPoll: vi.fn().mockResolvedValue({ id: 7 }), broadcast: vi.fn() } as Partial<CollabService>);
|
||||
expect(
|
||||
await new CollabController(s, storageStub).createPoll(user, '5', { question: 'q', options: ['a', 'b'] }),
|
||||
).toEqual({ poll: { id: 7 } });
|
||||
expect(await ctl(s, storageStub).createPoll(user, '5', { question: 'q', options: ['a', 'b'] })).toEqual({
|
||||
poll: { id: 7 },
|
||||
});
|
||||
});
|
||||
|
||||
it('vote maps not_found/closed/invalid_index, else broadcasts the poll', async () => {
|
||||
expect(
|
||||
await thrownAsync(() =>
|
||||
new CollabController(
|
||||
ctl(
|
||||
svc({ votePoll: vi.fn().mockResolvedValue({ error: 'not_found' }) } as Partial<CollabService>),
|
||||
storageStub,
|
||||
).votePoll(user, '5', '7', { option_index: 0 }),
|
||||
@@ -233,7 +243,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
).toEqual({ status: 404, body: { error: 'Poll not found' } });
|
||||
expect(
|
||||
await thrownAsync(() =>
|
||||
new CollabController(
|
||||
ctl(
|
||||
svc({ votePoll: vi.fn().mockResolvedValue({ error: 'closed' }) } as Partial<CollabService>),
|
||||
storageStub,
|
||||
).votePoll(user, '5', '7', { option_index: 0 }),
|
||||
@@ -241,7 +251,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
).toEqual({ status: 400, body: { error: 'Poll is closed' } });
|
||||
expect(
|
||||
await thrownAsync(() =>
|
||||
new CollabController(
|
||||
ctl(
|
||||
svc({ votePoll: vi.fn().mockResolvedValue({ error: 'invalid_index' }) } as Partial<CollabService>),
|
||||
storageStub,
|
||||
).votePoll(user, '5', '7', { option_index: 9 }),
|
||||
@@ -249,7 +259,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
).toEqual({ status: 400, body: { error: 'Invalid option index' } });
|
||||
const broadcast = vi.fn();
|
||||
const s = svc({ votePoll: vi.fn().mockResolvedValue({ poll: { id: 7 } }), broadcast } as Partial<CollabService>);
|
||||
expect(await new CollabController(s, storageStub).votePoll(user, '5', '7', { option_index: 0 }, 'sock')).toEqual({
|
||||
expect(await ctl(s, storageStub).votePoll(user, '5', '7', { option_index: 0 }, 'sock')).toEqual({
|
||||
poll: { id: 7 },
|
||||
});
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'collab:poll:voted', { poll: { id: 7 } }, 'sock');
|
||||
@@ -258,27 +268,29 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
it('close 404 when missing, else broadcasts', async () => {
|
||||
expect(
|
||||
await thrownAsync(() =>
|
||||
new CollabController(
|
||||
svc({ closePoll: vi.fn().mockResolvedValue(null) } as Partial<CollabService>),
|
||||
storageStub,
|
||||
).closePoll(user, '5', '7'),
|
||||
ctl(svc({ closePoll: vi.fn().mockResolvedValue(null) } as Partial<CollabService>), storageStub).closePoll(
|
||||
user,
|
||||
'5',
|
||||
'7',
|
||||
),
|
||||
),
|
||||
).toEqual({ status: 404, body: { error: 'Poll not found' } });
|
||||
const s = svc({ closePoll: vi.fn().mockResolvedValue({ id: 7 }), broadcast: vi.fn() } as Partial<CollabService>);
|
||||
expect(await new CollabController(s, storageStub).closePoll(user, '5', '7')).toEqual({ poll: { id: 7 } });
|
||||
expect(await ctl(s, storageStub).closePoll(user, '5', '7')).toEqual({ poll: { id: 7 } });
|
||||
});
|
||||
|
||||
it('delete 404 when missing, else success', async () => {
|
||||
expect(
|
||||
await thrownAsync(() =>
|
||||
new CollabController(
|
||||
svc({ deletePoll: vi.fn().mockResolvedValue(false) } as Partial<CollabService>),
|
||||
storageStub,
|
||||
).deletePoll(user, '5', '7'),
|
||||
ctl(svc({ deletePoll: vi.fn().mockResolvedValue(false) } as Partial<CollabService>), storageStub).deletePoll(
|
||||
user,
|
||||
'5',
|
||||
'7',
|
||||
),
|
||||
),
|
||||
).toEqual({ status: 404, body: { error: 'Poll not found' } });
|
||||
const s = svc({ deletePoll: vi.fn().mockResolvedValue(true), broadcast: vi.fn() } as Partial<CollabService>);
|
||||
expect(await new CollabController(s, storageStub).deletePoll(user, '5', '7')).toEqual({ success: true });
|
||||
expect(await ctl(s, storageStub).deletePoll(user, '5', '7')).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -288,25 +300,24 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
it('POST 400 whitespace-only, 400 reply_not_found, else creates + notifies (length checks now in the Zod pipe)', async () => {
|
||||
// A request with no file part keeps the wording it always had.
|
||||
expect(
|
||||
await thrownAsync(() =>
|
||||
new CollabController(svc(), storageStub).createMessage(user, '5', { text: ' ' }, undefined),
|
||||
),
|
||||
await thrownAsync(() => ctl(svc(), storageStub).createMessage(user, '5', { text: ' ' }, undefined)),
|
||||
).toEqual({ status: 400, body: { error: 'Message text is required' } });
|
||||
expect(
|
||||
await thrownAsync(() => new CollabController(svc(), storageStub).createMessage(user, '5', { text: ' ' }, [])),
|
||||
).toEqual({ status: 400, body: { error: 'Message text or image is required' } });
|
||||
expect(await thrownAsync(() => ctl(svc(), storageStub).createMessage(user, '5', { text: ' ' }, []))).toEqual({
|
||||
status: 400,
|
||||
body: { error: 'Message text or image is required' },
|
||||
});
|
||||
expect(
|
||||
await thrownAsync(() =>
|
||||
new CollabController(
|
||||
ctl(
|
||||
svc({ createMessage: vi.fn().mockResolvedValue({ error: 'reply_not_found' }) } as Partial<CollabService>),
|
||||
storageStub,
|
||||
).createMessage(user, '5', { text: 'hi', reply_to: 99 }, undefined),
|
||||
),
|
||||
).toEqual({ status: 400, body: { error: 'Reply target message not found' } });
|
||||
const s = svc({ createMessage: vi.fn().mockResolvedValue({ message: { id: 3 } }) } as Partial<CollabService>);
|
||||
expect(
|
||||
await new CollabController(s, storageStub).createMessage(user, '5', { text: 'hello' }, undefined, 'sock'),
|
||||
).toEqual({ message: { id: 3 } });
|
||||
expect(await ctl(s, storageStub).createMessage(user, '5', { text: 'hello' }, undefined, 'sock')).toEqual({
|
||||
message: { id: 3 },
|
||||
});
|
||||
// The posting use case sends and notifies; the route hands it the trimmed text and its writer.
|
||||
expect(s.postMessage).toHaveBeenCalledWith('5', { text: 'hello', replyTo: null, files: [] }, restWriter('sock'));
|
||||
expect(s.broadcast).not.toHaveBeenCalled();
|
||||
@@ -317,7 +328,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
// refusals are the only thing standing in front of the route now.
|
||||
expect(
|
||||
await thrownAsync(() =>
|
||||
new CollabController(
|
||||
ctl(
|
||||
svc({ verifyTripAccess: vi.fn().mockResolvedValue(null) } as Partial<CollabService>),
|
||||
storageStub,
|
||||
).createMessage(user, '5', { text: 'hi' }, undefined),
|
||||
@@ -325,10 +336,12 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
expect(
|
||||
await thrownAsync(() =>
|
||||
new CollabController(
|
||||
svc({ canEdit: vi.fn().mockResolvedValue(false) } as Partial<CollabService>),
|
||||
storageStub,
|
||||
).createMessage(user, '5', { text: 'hi' }, undefined),
|
||||
ctl(svc({ canEdit: vi.fn().mockResolvedValue(false) } as Partial<CollabService>), storageStub).createMessage(
|
||||
user,
|
||||
'5',
|
||||
{ text: 'hi' },
|
||||
undefined,
|
||||
),
|
||||
),
|
||||
).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
});
|
||||
@@ -336,7 +349,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
it('react 404 unknown, else broadcasts reactions (empty emoji now 400s in the Zod pipe)', async () => {
|
||||
expect(
|
||||
await thrownAsync(() =>
|
||||
new CollabController(
|
||||
ctl(
|
||||
svc({ reactMessage: vi.fn().mockResolvedValue({ found: false, reactions: [] }) } as Partial<CollabService>),
|
||||
storageStub,
|
||||
).react(user, '5', '3', { emoji: '👍' }),
|
||||
@@ -347,7 +360,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
reactMessage: vi.fn().mockResolvedValue({ found: true, reactions: [{ emoji: '👍', count: 1 }] }),
|
||||
broadcast,
|
||||
} as Partial<CollabService>);
|
||||
expect(await new CollabController(s, storageStub).react(user, '5', '3', { emoji: '👍' }, 'sock')).toEqual({
|
||||
expect(await ctl(s, storageStub).react(user, '5', '3', { emoji: '👍' }, 'sock')).toEqual({
|
||||
reactions: [{ emoji: '👍', count: 1 }],
|
||||
});
|
||||
expect(broadcast).toHaveBeenCalledWith(
|
||||
@@ -361,7 +374,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
it('delete maps not_found (404) / not_owner (403), else success with username', async () => {
|
||||
expect(
|
||||
await thrownAsync(() =>
|
||||
new CollabController(
|
||||
ctl(
|
||||
svc({ deleteMessage: vi.fn().mockResolvedValue({ error: 'not_found' }) } as Partial<CollabService>),
|
||||
storageStub,
|
||||
).deleteMessage(user, '5', '3'),
|
||||
@@ -369,7 +382,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
).toEqual({ status: 404, body: { error: 'Message not found' } });
|
||||
expect(
|
||||
await thrownAsync(() =>
|
||||
new CollabController(
|
||||
ctl(
|
||||
svc({ deleteMessage: vi.fn().mockResolvedValue({ error: 'not_owner' }) } as Partial<CollabService>),
|
||||
storageStub,
|
||||
).deleteMessage(user, '5', '3'),
|
||||
@@ -380,7 +393,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
deleteMessage: vi.fn().mockResolvedValue({ username: 'bob' }),
|
||||
broadcast,
|
||||
} as Partial<CollabService>);
|
||||
expect(await new CollabController(s, storageStub).deleteMessage(user, '5', '3', 'sock')).toEqual({
|
||||
expect(await ctl(s, storageStub).deleteMessage(user, '5', '3', 'sock')).toEqual({
|
||||
success: true,
|
||||
});
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'collab:message:deleted', { messageId: 3, username: 'bob' }, 'sock');
|
||||
@@ -396,7 +409,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
deleteMessage: vi.fn().mockResolvedValue({ username: undefined }),
|
||||
broadcast,
|
||||
} as Partial<CollabService>);
|
||||
expect(await new CollabController(s, storageStub).deleteMessage(user, '5', '3', 'sock')).toEqual({
|
||||
expect(await ctl(s, storageStub).deleteMessage(user, '5', '3', 'sock')).toEqual({
|
||||
success: true,
|
||||
});
|
||||
expect(broadcast).toHaveBeenCalledWith(
|
||||
@@ -425,7 +438,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
const put = vi.fn().mockResolvedValue(undefined);
|
||||
const createMessage = vi.fn().mockReturnValue({ message: { id: 3 } });
|
||||
const s = svc({ createMessage, broadcast: vi.fn(), notifyCollab: vi.fn() } as Partial<CollabService>);
|
||||
await new CollabController(s, { put, delete: vi.fn() } as never).createMessage(user, '5', { text: 'look' }, [
|
||||
await ctl(s, { put, delete: vi.fn() } as never).createMessage(user, '5', { text: 'look' }, [
|
||||
img('a.png'),
|
||||
img('b.png'),
|
||||
]);
|
||||
@@ -444,10 +457,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
const s = svc({ createMessage } as Partial<CollabService>);
|
||||
|
||||
await expect(
|
||||
new CollabController(s, { put, delete: del } as never).createMessage(user, '5', { text: 'x' }, [
|
||||
img('a.png'),
|
||||
img('b.png'),
|
||||
]),
|
||||
ctl(s, { put, delete: del } as never).createMessage(user, '5', { text: 'x' }, [img('a.png'), img('b.png')]),
|
||||
).rejects.toThrow('disk full');
|
||||
// The first one is already in storage and has to come back out; the
|
||||
// message row was never written, so nothing would point at it.
|
||||
@@ -463,7 +473,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
|
||||
expect(
|
||||
await thrownAsync(() =>
|
||||
new CollabController(s, { put: vi.fn().mockResolvedValue(undefined), delete: del } as never).createMessage(
|
||||
ctl(s, { put: vi.fn().mockResolvedValue(undefined), delete: del } as never).createMessage(
|
||||
user,
|
||||
'5',
|
||||
{ text: 'x', reply_to: 99 },
|
||||
@@ -479,15 +489,90 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
const s = svc({ canUploadFiles: vi.fn().mockResolvedValue(false) } as Partial<CollabService>);
|
||||
expect(
|
||||
await thrownAsync(() =>
|
||||
new CollabController(s, { put, delete: vi.fn() } as never).createMessage(user, '5', { text: 'x' }, [
|
||||
img('a.png'),
|
||||
]),
|
||||
ctl(s, { put, delete: vi.fn() } as never).createMessage(user, '5', { text: 'x' }, [img('a.png')]),
|
||||
),
|
||||
).toEqual({ status: 403, body: { error: 'No permission to upload files' } });
|
||||
expect(put).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('demo mode', () => {
|
||||
// The shared demo account is refused note files and chat images with the
|
||||
// 403 the other upload routes answer it (common/demo-write.ts), and what
|
||||
// multer spooled is removed before anything is committed: a committed object
|
||||
// would stay, since a demo instance keeps what is deleted (StorageService.delete).
|
||||
const demo = { ...user, email: 'demo@trek.app' } as User;
|
||||
const refused = {
|
||||
status: 403,
|
||||
body: { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' },
|
||||
};
|
||||
let spoolDir: string;
|
||||
const spooled = (name: string): Express.Multer.File => {
|
||||
const file = path.join(spoolDir, name);
|
||||
fs.writeFileSync(file, 'x');
|
||||
return { filename: name, originalname: name, size: 1, mimetype: 'image/png', path: file } as Express.Multer.File;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
spoolDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-collab-demo-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
fs.rmSync(spoolDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('POST notes/:id/files: 403 for the demo account, its spooled file removed and nothing stored', async () => {
|
||||
vi.stubEnv('DEMO_MODE', 'true');
|
||||
const addNoteFile = vi.fn();
|
||||
const s = svc({ addNoteFile } as Partial<CollabService>);
|
||||
expect(await thrownAsync(() => ctl(s).addNoteFile(demo, '5', '9', spooled('a.pdf')))).toEqual(refused);
|
||||
expect(fs.readdirSync(spoolDir)).toEqual([]);
|
||||
expect(storageStub.put).not.toHaveBeenCalled();
|
||||
expect(addNoteFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('POST notes/:id/files: still taken from any other account in demo mode, and from the demo account outside it', async () => {
|
||||
const s = svc({
|
||||
addNoteFile: vi.fn().mockResolvedValue({ file: { id: 3 } }),
|
||||
getFormattedNoteById: vi.fn().mockResolvedValue({ id: 9 }),
|
||||
} as Partial<CollabService>);
|
||||
vi.stubEnv('DEMO_MODE', 'true');
|
||||
expect(await ctl(s).addNoteFile(user, '5', '9', spooled('a.pdf'))).toEqual({ file: { id: 3 } });
|
||||
vi.stubEnv('DEMO_MODE', 'false');
|
||||
expect(await ctl(s).addNoteFile(demo, '5', '9', spooled('b.pdf'))).toEqual({ file: { id: 3 } });
|
||||
expect(storageStub.put).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('POST messages with images: 403 for the demo account, its spooled images removed and no message written', async () => {
|
||||
vi.stubEnv('DEMO_MODE', 'true');
|
||||
const createMessage = vi.fn();
|
||||
const s = svc({ createMessage } as Partial<CollabService>);
|
||||
expect(
|
||||
await thrownAsync(() =>
|
||||
ctl(s).createMessage(demo, '5', { text: 'look' }, [spooled('a.png'), spooled('b.png')]),
|
||||
),
|
||||
).toEqual(refused);
|
||||
expect(fs.readdirSync(spoolDir)).toEqual([]);
|
||||
expect(storageStub.put).not.toHaveBeenCalled();
|
||||
expect(createMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('POST messages: images still taken from any other account in demo mode and from the demo account outside it, text from the demo account always', async () => {
|
||||
const s = svc({ createMessage: vi.fn().mockResolvedValue({ message: { id: 3 } }) } as Partial<CollabService>);
|
||||
vi.stubEnv('DEMO_MODE', 'true');
|
||||
expect(await ctl(s).createMessage(user, '5', { text: 'look' }, [spooled('a.png')])).toEqual({
|
||||
message: { id: 3 },
|
||||
});
|
||||
// Text is not an upload: the chat stays open to the demo account.
|
||||
expect(await ctl(s).createMessage(demo, '5', { text: 'hi' }, undefined)).toEqual({ message: { id: 3 } });
|
||||
vi.stubEnv('DEMO_MODE', 'false');
|
||||
expect(await ctl(s).createMessage(demo, '5', { text: 'look' }, [spooled('b.png')])).toEqual({
|
||||
message: { id: 3 },
|
||||
});
|
||||
expect(storageStub.put).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('note file filter', () => {
|
||||
const run = (originalname: string, mimetype: string) => {
|
||||
let outcome: { err: Error | null; ok?: boolean } = { err: null };
|
||||
@@ -588,12 +673,13 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
|
||||
describe('link preview', () => {
|
||||
it('400 without url, maps an error result to 400, else returns the preview', async () => {
|
||||
expect(
|
||||
await thrownAsync(() => new CollabController(svc(), storageStub).linkPreview(user, '5', undefined)),
|
||||
).toEqual({ status: 400, body: { error: 'URL is required' } });
|
||||
expect(await thrownAsync(() => ctl(svc(), storageStub).linkPreview(user, '5', undefined))).toEqual({
|
||||
status: 400,
|
||||
body: { error: 'URL is required' },
|
||||
});
|
||||
expect(
|
||||
await thrownAsync(() =>
|
||||
new CollabController(
|
||||
ctl(
|
||||
svc({ linkPreview: vi.fn().mockResolvedValue({ error: 'bad url' }) } as Partial<CollabService>),
|
||||
storageStub,
|
||||
).linkPreview(user, '5', 'http://x'),
|
||||
@@ -602,7 +688,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
const s = svc({
|
||||
linkPreview: vi.fn().mockResolvedValue({ title: 'T', description: null, image: null, url: 'http://x' }),
|
||||
} as Partial<CollabService>);
|
||||
expect(await new CollabController(s, storageStub).linkPreview(user, '5', 'http://x')).toEqual({
|
||||
expect(await ctl(s, storageStub).linkPreview(user, '5', 'http://x')).toEqual({
|
||||
title: 'T',
|
||||
description: null,
|
||||
image: null,
|
||||
@@ -616,7 +702,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
.fn()
|
||||
.mockResolvedValue({ title: null, description: null, image: null, url: 'http://x', rateLimited: true }),
|
||||
} as Partial<CollabService>);
|
||||
expect(await thrownAsync(() => new CollabController(s, storageStub).linkPreview(user, '5', 'http://x'))).toEqual({
|
||||
expect(await thrownAsync(() => ctl(s, storageStub).linkPreview(user, '5', 'http://x'))).toEqual({
|
||||
status: 429,
|
||||
body: { error: 'Too many requests' },
|
||||
});
|
||||
@@ -624,17 +710,13 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
|
||||
it('passes the caller through, so the budget is charged per user and not per instance', async () => {
|
||||
const linkPreview = vi.fn().mockResolvedValue({ title: 'T', description: null, image: null, url: 'http://x' });
|
||||
await new CollabController(svc({ linkPreview } as Partial<CollabService>), storageStub).linkPreview(
|
||||
user,
|
||||
'5',
|
||||
'http://x',
|
||||
);
|
||||
await ctl(svc({ linkPreview } as Partial<CollabService>), storageStub).linkPreview(user, '5', 'http://x');
|
||||
expect(linkPreview).toHaveBeenCalledWith('http://x', user.id);
|
||||
});
|
||||
|
||||
it('falls back to a null preview when the service throws', async () => {
|
||||
const s = svc({ linkPreview: vi.fn().mockRejectedValue(new Error('network')) } as Partial<CollabService>);
|
||||
expect(await new CollabController(s, storageStub).linkPreview(user, '5', 'http://x')).toEqual({
|
||||
expect(await ctl(s, storageStub).linkPreview(user, '5', 'http://x')).toEqual({
|
||||
title: null,
|
||||
description: null,
|
||||
image: null,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ADDON_IDS } from '../../../src/addons';
|
||||
import { AddonGuard } from '../../../src/nest/addons/addon.guard';
|
||||
import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator';
|
||||
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
||||
import type { JourneyBookService } from '../../../src/nest/journey/journey-book.service';
|
||||
import type { JourneyPhotoCaptureService } from '../../../src/nest/journey/journey-photo-capture.service';
|
||||
import type { JourneyPhotoLinksService } from '../../../src/nest/journey/journey-photo-links.service';
|
||||
@@ -13,7 +14,10 @@ import type { User } from '../../../src/types';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
|
||||
import type { Response } from 'express';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as User;
|
||||
|
||||
@@ -61,6 +65,8 @@ function ctl(
|
||||
books as JourneyBookService,
|
||||
captureBackfillStub,
|
||||
photoLinks as JourneyPhotoLinksService,
|
||||
// The live env: a case about demo mode stubs DEMO_MODE, every other runs outside it.
|
||||
new RuntimeEnvService(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1055,6 +1061,75 @@ describe('JourneyController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyController in demo mode', () => {
|
||||
// The shared demo account is refused every journey upload with the 403 the
|
||||
// other upload routes answer it (common/demo-write.ts), and what multer
|
||||
// spooled is removed before anything is committed: a committed object would
|
||||
// stay, since a demo instance keeps what is deleted (StorageService.delete).
|
||||
const demo = { ...user, email: 'demo@trek.app' } as User;
|
||||
let spoolDir: string;
|
||||
const spooled = (name: string): Express.Multer.File => {
|
||||
const file = path.join(spoolDir, name);
|
||||
fs.writeFileSync(file, 'x');
|
||||
return { filename: name, originalname: name, path: file } as Express.Multer.File;
|
||||
};
|
||||
/** A service that takes every upload, so a case shows what the demo check alone decides. */
|
||||
const takesAll = () =>
|
||||
svc({
|
||||
addPhoto: vi.fn().mockReturnValue({ id: 5 }),
|
||||
immichAutoUploadEnabled: vi.fn().mockReturnValue(false),
|
||||
uploadGalleryPhotos: vi.fn().mockReturnValue([{ id: 7 }]),
|
||||
updateJourney: vi.fn().mockReturnValue({ id: 9 }),
|
||||
} as Partial<JourneyService>);
|
||||
const routes: Array<[string, (c: JourneyController, u: User) => Promise<unknown>]> = [
|
||||
['POST /entries/:entryId/photos', (c, u) => c.uploadEntryPhotos(u, '3', [spooled('a.jpg'), spooled('b.jpg')], {})],
|
||||
[
|
||||
'POST /entries/:entryId/video',
|
||||
(c, u) => c.uploadEntryVideo(u, '3', { video: [spooled('v.mp4')], poster: [spooled('p.jpg')] }, {}),
|
||||
],
|
||||
['POST /:id/gallery/photos', (c, u) => c.uploadGalleryPhotos(u, '9', [spooled('g.jpg')])],
|
||||
[
|
||||
'POST /:id/gallery/video',
|
||||
(c, u) => c.uploadGalleryVideo(u, '9', { video: [spooled('v.mp4')], poster: [spooled('p.jpg')] }, {}),
|
||||
],
|
||||
['POST /:id/cover', (c, u) => c.cover(u, '9', spooled('c.jpg'))],
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
spoolDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-journey-demo-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
fs.rmSync(spoolDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it.each(routes)('%s: 403 for the demo account, its spooled parts removed and nothing stored', async (_, call) => {
|
||||
vi.stubEnv('DEMO_MODE', 'true');
|
||||
const service = takesAll();
|
||||
expect(await thrownAsync(() => call(ctl(service), demo))).toEqual({
|
||||
status: 403,
|
||||
body: { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' },
|
||||
});
|
||||
expect(fs.readdirSync(spoolDir)).toEqual([]);
|
||||
expect(storageStub.put).not.toHaveBeenCalled();
|
||||
expect(service.addPhoto).not.toHaveBeenCalled();
|
||||
expect(service.uploadGalleryPhotos).not.toHaveBeenCalled();
|
||||
expect(service.updateJourney).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(routes)('%s: still taken from any other account in demo mode', async (_, call) => {
|
||||
vi.stubEnv('DEMO_MODE', 'true');
|
||||
await call(ctl(takesAll()), user);
|
||||
expect(storageStub.put).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(routes)('%s: still taken from the demo account outside demo mode', async (_, call) => {
|
||||
vi.stubEnv('DEMO_MODE', 'false');
|
||||
await call(ctl(takesAll()), demo);
|
||||
expect(storageStub.put).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyPublicController', () => {
|
||||
it('is addon-gated like the authenticated surface', () => {
|
||||
// Turning the Journey addon off is meant to take the feature away; without
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* A demo instance keeps the uploads its baseline points at.
|
||||
*
|
||||
* The hourly demo reset (src/demo/demo-reset.ts) puts the database back to
|
||||
* travel-baseline.db and leaves storage alone, so a file a visitor deleted in
|
||||
* between stays gone while the restored rows still name it. StorageService
|
||||
* .delete is the one place that stops it: with DEMO_MODE on it leaves the
|
||||
* content categories alone, and the caches, the backups and the assistant's
|
||||
* attachments are still deleted.
|
||||
*
|
||||
* The subject comes out of a Nest testing module with the real
|
||||
* RuntimeEnvService, so DEMO_MODE is read the way the server reads it. The
|
||||
* registry is a stub over a real LocalDriver, as in storage.service.test.ts,
|
||||
* with every category under a prefix of its own name.
|
||||
*/
|
||||
import { RuntimeEnvService } from '../../../../src/nest/app-config/runtime-env.service';
|
||||
import { LocalDriver } from '../../../../src/nest/storage/drivers/local.driver';
|
||||
import { StorageRegistryService, type ResolvedCategory } from '../../../../src/nest/storage/storage-registry.service';
|
||||
import { StorageService } from '../../../../src/nest/storage/storage.service';
|
||||
import {
|
||||
SERVED_CATEGORIES,
|
||||
StorageInvalidKeyError,
|
||||
type ServedCategory,
|
||||
} from '../../../../src/nest/storage/storage.types';
|
||||
import type { Provider } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
/** The categories holding content that rows point at: a demo keeps their objects. */
|
||||
const KEPT: ServedCategory[] = ['files', 'journey', 'covers', 'avatars', 'places', 'photos'];
|
||||
/** The caches and the backups: a demo deletes them like any other instance. */
|
||||
const DELETED: ServedCategory[] = ['photos-google', 'photos-trek', 'backups'];
|
||||
|
||||
const prevDemo = process.env.DEMO_MODE;
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
if (prevDemo === undefined) delete process.env.DEMO_MODE;
|
||||
else process.env.DEMO_MODE = prevDemo;
|
||||
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
interface Fixture {
|
||||
storage: StorageService;
|
||||
/** Writes an object through the facade and answers where it sits on disk. */
|
||||
put(category: ServedCategory, name: string, body?: string): Promise<string>;
|
||||
}
|
||||
|
||||
/** The facade out of a testing module; `runtimeEnv` replaces the real RuntimeEnvService when given. */
|
||||
async function build(runtimeEnv?: Pick<RuntimeEnvService, 'isDemoMode'>): Promise<Fixture> {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-demo-delete-'));
|
||||
roots.push(root);
|
||||
const driver = new LocalDriver({ id: 'demo-local', root });
|
||||
driver.init({ cleanSpool: true });
|
||||
const registry = {
|
||||
resolve: (category: ServedCategory): ResolvedCategory => ({
|
||||
driver,
|
||||
keyPrefix: `${category}/`,
|
||||
backendName: 'demo-local',
|
||||
}),
|
||||
};
|
||||
const env: Provider = runtimeEnv ? { provide: RuntimeEnvService, useValue: runtimeEnv } : RuntimeEnvService;
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [StorageService, { provide: StorageRegistryService, useValue: registry }, env],
|
||||
}).compile();
|
||||
const storage = moduleRef.get(StorageService);
|
||||
return {
|
||||
storage,
|
||||
put: async (category, name, body = 'bytes') => {
|
||||
await storage.put(category, name, Readable.from([Buffer.from(body)]));
|
||||
return path.join(root, category, name);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('StorageService.delete on a demo instance', () => {
|
||||
it('STORAGE-DEMO-001: names every served category exactly once', () => {
|
||||
expect([...KEPT, ...DELETED].sort()).toEqual([...SERVED_CATEGORIES].sort());
|
||||
});
|
||||
|
||||
it.each(KEPT)('STORAGE-DEMO-002: keeps the %s object and resolves as if it were gone', async (category) => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const fx = await build();
|
||||
const onDisk = await fx.put(category, 'baseline.jpg');
|
||||
|
||||
await expect(fx.storage.delete(category, 'baseline.jpg')).resolves.toBeUndefined();
|
||||
|
||||
expect(fs.existsSync(onDisk)).toBe(true);
|
||||
expect(await fx.storage.exists(category, 'baseline.jpg')).toBe(true);
|
||||
});
|
||||
|
||||
it.each(DELETED)('STORAGE-DEMO-003: still deletes the %s object', async (category) => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const fx = await build();
|
||||
const onDisk = await fx.put(category, 'cached.jpg');
|
||||
|
||||
await fx.storage.delete(category, 'cached.jpg');
|
||||
|
||||
expect(fs.existsSync(onDisk)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([...SERVED_CATEGORIES])('STORAGE-DEMO-004: outside demo mode deletes the %s object', async (category) => {
|
||||
delete process.env.DEMO_MODE;
|
||||
const fx = await build();
|
||||
const onDisk = await fx.put(category, 'any.jpg');
|
||||
|
||||
await fx.storage.delete(category, 'any.jpg');
|
||||
|
||||
expect(fs.existsSync(onDisk)).toBe(false);
|
||||
});
|
||||
|
||||
it('STORAGE-DEMO-005: a put after a kept delete still replaces the object under the same name', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const fx = await build();
|
||||
const onDisk = await fx.put('covers', 'cover.jpg', 'old');
|
||||
|
||||
await fx.storage.delete('covers', 'cover.jpg');
|
||||
await fx.put('covers', 'cover.jpg', 'new');
|
||||
|
||||
expect(fs.readFileSync(onDisk, 'utf8')).toBe('new');
|
||||
});
|
||||
|
||||
it('STORAGE-DEMO-006: refuses a hostile name in demo mode as it does outside it', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const fx = await build();
|
||||
|
||||
await expect(fx.storage.delete('covers', '../escape.jpg')).rejects.toBeInstanceOf(StorageInvalidKeyError);
|
||||
});
|
||||
|
||||
it('STORAGE-DEMO-007: reads DEMO_MODE on every call, so a toggle takes effect without a restart', async () => {
|
||||
const fx = await build();
|
||||
const first = await fx.put('journey', 'first.jpg');
|
||||
const second = await fx.put('journey', 'second.jpg');
|
||||
|
||||
process.env.DEMO_MODE = 'true';
|
||||
await fx.storage.delete('journey', 'first.jpg');
|
||||
delete process.env.DEMO_MODE;
|
||||
await fx.storage.delete('journey', 'second.jpg');
|
||||
|
||||
expect(fs.existsSync(first)).toBe(true);
|
||||
expect(fs.existsSync(second)).toBe(false);
|
||||
});
|
||||
|
||||
it('STORAGE-DEMO-008: asks the RuntimeEnvService the container hands it', async () => {
|
||||
delete process.env.DEMO_MODE;
|
||||
const fx = await build({ isDemoMode: () => true });
|
||||
const onDisk = await fx.put('avatars', 'face.jpg');
|
||||
|
||||
await fx.storage.delete('avatars', 'face.jpg');
|
||||
|
||||
expect(fs.existsSync(onDisk)).toBe(true);
|
||||
});
|
||||
|
||||
it('STORAGE-DEMO-009: still deletes the assistant attachments, which no baseline relies on', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const fx = await build();
|
||||
const attachment = await fx.put('files', 'assistant/a1.pdf');
|
||||
const tripFile = await fx.put('files', 'trip.pdf');
|
||||
|
||||
expect(fx.storage.keepsOnDelete('files', 'assistant/a1.pdf')).toBe(false);
|
||||
expect(fx.storage.keepsOnDelete('files', 'trip.pdf')).toBe(true);
|
||||
await fx.storage.delete('files', 'assistant/a1.pdf');
|
||||
await fx.storage.delete('files', 'trip.pdf');
|
||||
|
||||
expect(fs.existsSync(attachment)).toBe(false);
|
||||
expect(fs.existsSync(tripFile)).toBe(true);
|
||||
});
|
||||
|
||||
it('STORAGE-DEMO-010: keepsOnDelete answers false for every category outside demo mode', async () => {
|
||||
delete process.env.DEMO_MODE;
|
||||
const fx = await build();
|
||||
|
||||
expect(SERVED_CATEGORIES.filter((category) => fx.storage.keepsOnDelete(category, 'any.jpg'))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -185,4 +185,18 @@ describe('sweepOrphanThumbs (spec fix #2)', () => {
|
||||
expect(await svc.sweepOrphanThumbs()).toBe(0);
|
||||
expect(fs.existsSync(note)).toBe(true);
|
||||
});
|
||||
|
||||
it('THUMB-SWEEP-005: a demo instance keeps every journey object, so the sweep reports none removed', async () => {
|
||||
const prevDemo = process.env.DEMO_MODE;
|
||||
process.env.DEMO_MODE = 'true';
|
||||
try {
|
||||
const stray = writeThumb('demostray0000000.jpg');
|
||||
|
||||
expect(await svc.sweepOrphanThumbs()).toBe(0);
|
||||
expect(fs.existsSync(stray)).toBe(true);
|
||||
} finally {
|
||||
if (prevDemo === undefined) delete process.env.DEMO_MODE;
|
||||
else process.env.DEMO_MODE = prevDemo;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,7 +114,7 @@ A personal parsing model has its own **Model reads images** switch, on or off. I
|
||||
|
||||
The upload is the normal booking import; the model simply works behind it.
|
||||
|
||||
1. In the trip, open the **Bookings** or **Transports** tab and click **Import booking confirmations** (the download icon in the bar). On a tab without bookings, the button reads **Import from file**.
|
||||
1. In the trip, open the **Bookings** or **Transports** tab and click **Import booking confirmations** (the upload icon in the bar). On a tab without bookings, the button reads **Import from file**.
|
||||
2. Drop your files onto the upload area: EML, PDF, PKPass, HTML or TXT, up to 5 files of 10 MB each. When the model reads images, photos (JPG, PNG, WEBP) are accepted too. Click **Import**.
|
||||
3. The dialog closes and a **background widget** in the bottom right corner shows *Parsing files…* with a count. You can keep working meanwhile; the widget survives a reload and follows you to other pages.
|
||||
4. When parsing is done, click **Import** in the widget.
|
||||
|
||||
@@ -68,7 +68,7 @@ Rest the pointer on a pill for *Check-in: Hotel Adler* or *Check-out: Hotel Adle
|
||||
|
||||
A stay whose booking is still **Pending** gets a dashed, tinted pill, and its tooltip ends in *(Pending)*. On the map, the hotel's marker is drawn with a dashed ring and a lighter face until the booking is **Confirmed**, see [Map Features](Map-Features#place-markers).
|
||||
|
||||

|
||||

|
||||
|
||||
## In the Reservations panel
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ Click **New Category** (top right of the card). A dialog opens:
|
||||
|
||||
4. **Preview**: a chip shows how the category will look to users as you make selections.
|
||||
|
||||

|
||||
|
||||
Click **Create** to save.
|
||||
|
||||
## Editing a category
|
||||
|
||||
@@ -82,6 +82,8 @@ Each cell is a pill that cycles through three states when clicked, and saves at
|
||||
| **Off** | Switched off until the user turns it on. |
|
||||
| **Blocked** | Switched off for everyone. In each user's settings the cell shows a lock instead of a switch, and nothing is sent there. |
|
||||
|
||||

|
||||
|
||||
A default applies to every user who has not changed that cell themselves, including users who joined before you set it, so a later change reaches them too. A cell a user switched keeps the user's choice, unless you block it. The events themselves are listed in [Notifications](Notifications#notification-events).
|
||||
|
||||
## On a phone
|
||||
|
||||
@@ -46,7 +46,7 @@ In **Settings > API Keys**, the **Google Maps API Key** block has a collapsible
|
||||
|
||||
At the bottom of **Notifications**, **Defaults for users** is a matrix of every notification event against every channel. Each cell cycles through **On**, **Off** and **Blocked** when clicked and is saved right away; an event a channel cannot deliver shows a dash. **On** and **Off** are only where a user's settings start: each user may still change them. **Blocked** turns that event off on that channel for everyone, and it shows as locked in their settings. The defaults apply to every user who has not changed that cell themselves. See [Notifications](Notifications).
|
||||
|
||||

|
||||

|
||||
|
||||
The **Week starts on** default decides which day opens each row of every date picker for users who have not picked their own (Monday unless set).
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ Both views share a toolbar: a **Search plugins…** box, a **Type** filter (Widg
|
||||
|
||||
## Installing from the registry
|
||||
|
||||

|
||||
|
||||
1. Switch to **Discover**. Each card shows the icon, name, author, description, type, a **Reviewed** badge if applicable, the **Signed**/**Unsigned** badge, the latest version, and a download count.
|
||||
2. Click a card to open the **pre-install review dialog** (see below).
|
||||
3. Click **Install**.
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
The **Settings** tab (in the **Configuration** group of the admin side navigation, `/admin?tab=settings`) holds the instance-wide switches for signing in, uploads and place data. On a wide screen the cards sit in two columns: access and identity on the left (**Authentication Methods**, **Passkey login**, **Require two-factor authentication (2FA)**, **Single Sign-On (OIDC)**), files and place data on the right (**Allowed File Types**, **API Keys**). The **Danger Zone** runs across the full width at the bottom. On a narrow screen the same cards stack in that order.
|
||||
|
||||

|
||||
|
||||
Switches save as soon as you flip them. Text fields save with the card's own **Save** button. The phone admin has the same cards in its **Settings** section.
|
||||
|
||||
## Authentication Methods
|
||||
|
||||
@@ -4,6 +4,8 @@ The **Storage** tab (Admin Panel > Configuration > Storage) controls where TREK
|
||||
kind of content: which storage backends exist, which content category writes
|
||||
to which backend, and whether writes are replicated to additional backends.
|
||||
|
||||

|
||||
|
||||
## Backends
|
||||
|
||||
A backend is a named place bytes can live. Two exist out of the box:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
The **User Defaults** tab (in the **Users** group of the admin side navigation, `/admin?tab=defaults`) sets instance-wide defaults for user settings. Users who have not changed a setting see these values; their own changes always take priority. The defaults are merged in every time a user's settings load, so a default you change later reaches everyone who never picked their own value.
|
||||
|
||||

|
||||

|
||||
|
||||
On a wide screen the tab has two cards side by side: **Default User Settings** on the left, **Map** on the right. On a narrow screen the map card follows below. The phone admin has the same tab as a section of its own.
|
||||
|
||||
@@ -73,6 +73,8 @@ Depending on the engine, more rows appear below it:
|
||||
| **3D buildings & terrain** | Mapbox (3D) | **On** or **Off**. On unless set otherwise. |
|
||||
| **High-quality mode** | Mapbox (3D) | **On** or **Off**. Off unless set otherwise. |
|
||||
|
||||

|
||||
|
||||
See [Map-Settings](Map-Settings) for the styles and what the two Mapbox switches do.
|
||||
|
||||
## See also
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Tune how TREK looks: colour mode, accent scheme, readability, and which dashboard widgets you see.
|
||||
|
||||

|
||||

|
||||
|
||||
## Where to find it
|
||||
|
||||
|
||||
@@ -77,6 +77,8 @@ Your Atlas statistics panel shows:
|
||||
|
||||
With the Tours addon on, the desktop panel has a third tab, **Tours**, next to **Stats** and **Bucket List**: what you have done on your tours across all your trips. Pick a **period** (all time, this year, the last 12 months or an earlier year) and a **tour type**, and the tab shows the distance with the number of tours at its corner, the ascent and descent, and the types with their distance. **Show tours** opens every tour of the period as a list.
|
||||
|
||||

|
||||
|
||||
- **When a tour counts.** A tour counts once the day it is on in a trip is over; a tour on several days once the last of them is over. A tour on no day, a tour only on days without a date and a tour whose day is still ahead do not count; the tab says how many of each there are.
|
||||
- **Planned or recorded.** Until you link the track you actually took in the tour's detail, a tour counts with the values of your planning, and it says so with a **Planned** badge: they are not measured. A linked actual track replaces those values for that tour, marked **Recorded**; it is never added on top, and the track itself is not counted as a tour of its own.
|
||||
- **Only your own part.** A day of a tour counts for you when it names you among its participants, or names nobody, which in the planner means everyone of the trip. A day that names only others counts for them, not for you.
|
||||
|
||||
@@ -4,7 +4,7 @@ Record what the trip costs, who put the money down and who it is shared with, an
|
||||
|
||||
> **Renamed to Costs (v3.3.0, #1464):** the feature is called **Costs** everywhere in the UI: the planner tab reads **Costs**, and so does its entry in Admin → Addons. Its internal addon id is still `budget`, which is why the permission is `budget_edit` and the MCP scopes are `budget:read` and `budget:write`.
|
||||
|
||||

|
||||

|
||||
|
||||
## Where to find it
|
||||
|
||||
@@ -194,7 +194,7 @@ With Costs on, the cost pills on the days of the plan and **Total Cost** at its
|
||||
|
||||
The table is a second way to read and plan the same expenses, close to the categorized budget sheet TREK had before Costs was rebuilt. Switch to it with the **Table** icon in the bar; **List** brings the ledger back. Search and filters apply to the table as well.
|
||||
|
||||

|
||||

|
||||
|
||||
- Expenses are grouped by **category**, in the order of the category pill, each group with how many entries it has and its subtotal. Click a category's header to fold it.
|
||||
- The columns are **Name**, **Date**, **Total**, **Persons** and **Days**, followed by the worked-out **Per Person**, **Per Day** and **P. p / Day** on their own grey ground. An expense with a custom (uneven) split leaves the per-person columns empty, since it has no single share per person.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Talk with everyone on the trip in real time, without leaving the planner.
|
||||
|
||||

|
||||

|
||||
|
||||
## Where to find it
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ The dashboard also gains a **Collections widget** that surfaces your lists as co
|
||||
- **Edit or delete a list** from the **Edit** button in the list's hero (owner only). Deleting a list removes its saved places.
|
||||
- **All saved** is a built-in view that unions the places from every list you own or co-own, so you can search and act across your whole library at once.
|
||||
|
||||

|
||||

|
||||
|
||||
## Place status
|
||||
|
||||
@@ -43,7 +43,7 @@ You usually find out you have been somewhere while looking at the trip, not whil
|
||||
- The **Save to list** dialog on a trip place shows every list that already holds it, each with its own status pill. Tap a pill to cycle that list's status, or use **Visited everywhere** to mark all of them at once.
|
||||
- The places panel has **Mark visited in your lists** in its selection bar (and in the bulk toolbar on phones). Select the places (**Select all** covers the whole trip) and every saved copy of them is marked visited.
|
||||
|
||||

|
||||

|
||||
|
||||
Matching is by the place's provider id, its coordinates, or the link a place saved out of that trip already carries, so a copy you renamed inside a list is still found. Lists shared with you read-only are left alone.
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ Only the **start** date is linked to the other one. Picking a start date fills t
|
||||
|
||||
The same dialog changes the dates of an existing trip later. The day plans follow their position, so new dates that hold fewer days take the last days of the plan away. When anything is planned on those days, the dialog lists it and asks before it saves; see [Shortening a trip](Day-Plans-and-Notes#shortening-a-trip). To make a trip one day longer without opening the dialog, add a day with the next date in the planner; see [Adding a day](Day-Plans-and-Notes#adding-a-day).
|
||||
|
||||

|
||||
|
||||
### Currency
|
||||
|
||||
The trip's currency, its **accounting base**. Every expense in the Costs tab is converted into it, and every balance and settle-up suggestion is calculated in it. The dialog pre-fills your own display currency (Settings > General > Language & region > *Display currency*); if you left that on **Trip currency**, it falls back to **EUR**. An administrator can preset the instance-wide value under Admin → **User Defaults**, which new users inherit until they pick their own. 165 currencies are available.
|
||||
|
||||
@@ -56,6 +56,8 @@ Every currency is shown with its own number of decimals. Most use two. The Japan
|
||||
|
||||
**Settings → General → Language & region → Display currency** is a **per-user, presentation-only** preference. It converts what you *read* in the Costs tab (totals, the category chart, balances, the settle-up amounts) into a single currency, so a trip with dollars, yen and roubles in it still adds up to one number you understand.
|
||||
|
||||

|
||||
|
||||
It never changes what is stored. Two people looking at the same trip can read it in different currencies and both see correct, consistent balances.
|
||||
|
||||
It has two modes:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
The days column on the left of the Plan tab holds the itinerary: one card per day, with the places, bookings, transports, tours and notes of that day in order.
|
||||
|
||||

|
||||

|
||||
|
||||
## The Day Plan sidebar
|
||||
|
||||
@@ -167,7 +167,7 @@ At the foot of the column, **Total Cost** adds up every expense of the trip with
|
||||
|
||||
The selected day ends in its route bar, as long as the day can be routed: two or more places, one located place that the day's accommodation can frame, or a transfer day from one hotel to another. On a phone the same controls sit in the **Daily Overview** sheet.
|
||||
|
||||

|
||||

|
||||
|
||||
- **Route** draws the day's route on the map and puts the travel time between each pair of stops into the timeline.
|
||||
- **The travel mode**: Driving, Walking, Cycling, and any mode a plugin adds.
|
||||
|
||||
@@ -40,7 +40,7 @@ The demo user can work with the shared example trips like any member, but the fo
|
||||
- **Password change**: returns 403.
|
||||
- **Account deletion**: returns 403.
|
||||
- **MFA enrollment or removal**: returns 403. The 2FA card in Settings says it is not available in demo mode, and the Passkeys card is hidden.
|
||||
- **File uploads**: avatar uploads, trip cover uploads, and document/photo file attachments are blocked and return 403.
|
||||
- **File uploads**: avatars, trip and collection covers, place images, trip files, journey photos, videos and covers, and the files and images attached to collab notes and chat messages are blocked and return 403. A chat message without an image still posts. [Document-Sync](Document-Sync) is not covered by this block: all its stores ship switched off, but once an administrator switches one on, the demo account can bind a trip it owns in the trip's file manager and pull documents from that store into the trip files, also through the `sync_trip_documents` MCP tool.
|
||||
- **All MCP write tools**: create, update, and delete operations via the MCP API are blocked for the demo user.
|
||||
|
||||
Registration is also disabled while demo mode is active; visitors cannot create new accounts.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Keep tickets, confirmations, vouchers and photos with the trip, and tie each one to the places and bookings it belongs to.
|
||||
|
||||

|
||||

|
||||
|
||||
## Where to find it
|
||||
|
||||
@@ -14,8 +14,6 @@ The tab opens with the same bar as Transports, Bookings, Lists and Costs: **File
|
||||
|
||||
## Uploading
|
||||
|
||||

|
||||
|
||||
Drop files onto the dashed upload area under the bar, click it to open the file picker, or paste an image straight into the tab (Ctrl+V). Several files can go up at once.
|
||||
|
||||
When the upload is done and the trip has places or bookings, the **Assign File** dialog opens for the last file you uploaded, so you can tie it to a place or a booking right away (see [Linking files](#linking-files-to-places-reservations-or-assignments)). Close it if the file belongs nowhere in particular.
|
||||
@@ -93,6 +91,8 @@ A file can be tied to several places and bookings at once. The **Assign** pencil
|
||||
|
||||
Click an entry to tie the file to it, and click it again to untie it; a tied entry is highlighted with a check mark. Every click is saved at once, so the dialog has no save button: close it when you are done.
|
||||
|
||||

|
||||
|
||||
From the other side, the booking and transport editors have **Attach file** for a new file and **Link existing file** for one the trip already has. On the phone it is the **Link** pill in the Files row of the booking and transport sheets. The files of a booking appear in its detail popup, where **Show in files** brings you here, and the files of a place appear in the place inspector.
|
||||
|
||||
Linking needs the `file_edit` permission.
|
||||
|
||||
@@ -38,6 +38,8 @@ A screen that has sub-screens lists them under **Sub-screens**, folded: the trip
|
||||
columns, its panels and its tabs (including Place details, Day details and Road trip); Settings and the Admin panel fold their tabs; Journey folds the
|
||||
journal and the studio. Dashboard, Vacay, Atlas, Collections and the Notifications inbox have help of their own.
|
||||
|
||||

|
||||
|
||||
### Guides
|
||||
|
||||
A guide is one task, start to finish. It opens with the goal and a chip that says how long it is,
|
||||
@@ -52,6 +54,8 @@ A guide is one task, start to finish. It opens with the goal and a chip that say
|
||||
hides.
|
||||
4. **Related**, the guides that go with this one, and **In the docs**, the wiki pages behind it.
|
||||
|
||||

|
||||
|
||||
Some guides carry a button that takes you straight to the screen the task happens on.
|
||||
|
||||
### Search
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
TREK is a self-hosted, real-time collaborative travel planner licensed under AGPL-3.0.
|
||||
|
||||

|
||||

|
||||
|
||||
## Features
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ The bundled directory is found automatically. `TREK_WIKI_DIR` overrides where TR
|
||||
|
||||
The left sidebar mirrors this wiki's own table of contents, parsed straight out of `_Sidebar.md`: the same sections, in the same order, with the same page titles. On screens narrower than desktop the sidebar collapses behind a **Contents** button that opens it as a drawer.
|
||||
|
||||

|
||||
|
||||
The page you are reading is highlighted with a chevron.
|
||||
|
||||
## Search
|
||||
|
||||
@@ -22,6 +22,8 @@ Invite links live in the **Invite Links** card under **Admin > Users**. Click **
|
||||
|
||||
**Add to trip (optional)**: bind the invite to any trip on the instance, not just your own. When someone registers through the link, they are automatically added to that trip as a member. Leave it on **No trip** for a plain registration invite. The selector only appears when at least one trip exists.
|
||||
|
||||

|
||||
|
||||
**Create & Copy** generates a 32-character hexadecimal token and copies the URL to your clipboard.
|
||||
|
||||
Each link in the list shows whether it is **Active**, **Expired** or **Used up**, how often it was used out of its limit, when it expires, the trip it adds to, and who created it. The copy button (**Copy link**) copies its URL again.
|
||||
|
||||
@@ -4,7 +4,7 @@ Journey is a photo-first travel journal. Each journey is linked to one or more o
|
||||
|
||||
> **Admin:** enable Journey in [Admin-Addons](Admin-Addons).
|
||||
|
||||

|
||||

|
||||
|
||||
## What Journey is
|
||||
|
||||
@@ -172,7 +172,7 @@ While you travel, the stays of each day can be ready for review without you star
|
||||
|
||||
**Place entries from their photos**, a switch under **Photos** in **Journey Settings**, goes one step further: an entry without a place takes the spot where its first photo with GPS was taken. Places you set yourself are never moved. It is off by default.
|
||||
|
||||

|
||||

|
||||
|
||||
## TREK Studio
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ OAuth scopes control exactly which data your AI client can read or write in TREK
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## All scopes
|
||||
|
||||
TREK defines 35 scopes across 17 groups.
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
TREK has a layout of its own for phones. It is the same app at the same address with the same data, drawn for one hand and a small screen: a floating bar at the bottom, sheets that slide up instead of dialogs, and a trip screen built around the day you are on. Install it to your home screen and it behaves like an app; see [Offline-Mode-and-PWA](Offline-Mode-and-PWA).
|
||||
|
||||

|
||||
|
||||
## When the phone layout applies
|
||||
|
||||
TREK switches to the phone layout when the window is narrower than 768 px, and also on a touch screen less than 500 px tall, so a phone turned on its side keeps the phone layout instead of jumping to the desktop planner. A tablet and a desktop window follow their width as usual. Turning the phone or resizing the window switches between the two layouts on the fly.
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
The dashboard at `/dashboard` is your home base — it lists all your trips, lets you create new ones, and surfaces quick-access widgets.
|
||||
|
||||

|
||||

|
||||
|
||||
## View Modes
|
||||
|
||||
Use the toggle button in the top toolbar to switch between **grid** (card thumbnails) and **list** (compact rows). Your preference is saved in `localStorage` under the key `trek_dashboard_view` and persists across sessions.
|
||||
|
||||

|
||||
|
||||
In both modes the dashboard shows a large [Spotlight card](#spotlight-card) for your most relevant trip at the top of the page — the toggle only changes how the trips below it are laid out. The spotlighted trip is taken out of that list when it earns the spot on its own: an ongoing trip, or the next upcoming one. If you have neither, the spotlight borrows your first trip for the header and that trip still shows up in the list below.
|
||||
|
||||
## Searching Trips
|
||||
@@ -69,6 +71,8 @@ On mobile, the header is a floating glass bar: the TREK logo on the left (tap it
|
||||
|
||||
On wide screens a sticky right column shows the **Currency**, **Collections** (only with the Collections addon enabled), **Timezones**, and **Upcoming reservations** widgets, plus any widget a plugin contributes. Each one is switched on or off separately for desktop and mobile under **Settings → Appearance → Dashboard widgets**; desktop also has a **Right sidebar** master toggle that removes the whole column, and the column disappears on its own once every widget in it is off.
|
||||
|
||||

|
||||
|
||||
On mobile, the same widgets are inline panels below the spotlight card, not a bottom sheet. By default the trip list comes first and the widgets stack underneath it. How they stack — and where the trip list sits among them — is set on the phone under **Settings → Appearance → Mobile → Dashboard order**.
|
||||
|
||||
See [Dashboard-Widgets](Dashboard-Widgets) for full usage details.
|
||||
|
||||
@@ -14,6 +14,8 @@ The bell in the navigation bar shows how many notifications you have not read ye
|
||||
|
||||
Following a notification from the panel closes it. New notifications arrive live over TREK's connection, so the count and the panel update without a reload.
|
||||
|
||||

|
||||
|
||||
## The inbox page
|
||||
|
||||
The page lists every notification, newest first, and loads older ones as you scroll down.
|
||||
|
||||
@@ -92,6 +92,8 @@ A default applies to every cell you have not changed yourself, so when the admin
|
||||
|
||||
## Webhook configuration
|
||||
|
||||

|
||||
|
||||
Enter a URL that TREK will POST to when a notification fires. Once saved, the URL is displayed as `••••••••`. Use the **Test** button to send a test payload to the saved URL.
|
||||
|
||||
TREK auto-detects the webhook destination and adjusts the payload format:
|
||||
@@ -191,7 +193,9 @@ The bell icon in the navigation bar shows your unread notification count. Click
|
||||
- Mark all notifications read at once.
|
||||
- Delete individual notifications or clear all at once.
|
||||
- Answer **boolean notifications**, which ask a yes-or-no question, with their two buttons directly in the panel.
|
||||
- Click **Show all notifications** to open the full page at `/notifications`, with **All** and **Unread** pills, **Mark all read** and **Delete all** (which asks *Delete all notifications?* first). On a phone the inbox is its own screen. With nothing in it, the page reads *No notifications* and *You're all caught up!*.
|
||||
- Click **Show all notifications** to open the full page at `/notifications`, with **All** and **Unread** pills, **Mark all read** and **Delete all** (which asks *Delete all notifications?* first). On a phone the inbox is its own screen. With nothing in it, the page reads *No notifications*; the bell's panel adds *You're all caught up!*.
|
||||
|
||||

|
||||
|
||||
In-app notifications are pushed in real time via WebSocket so the badge and panel update without a page refresh.
|
||||
|
||||
|
||||
@@ -89,6 +89,8 @@ A small pill floats at the bottom centre of the window (just above the tab bar o
|
||||
|
||||
On a phone, **Settings** has its own **Offline** screen with the same controls as the desktop tab.
|
||||
|
||||

|
||||
|
||||
### Offline mode
|
||||
|
||||
- **Force offline mode** — a switch that first downloads everything you need (see *Prepare for offline* below) and then routes the whole app to the local cache, queueing every change you make. Flip it back off to reconnect: queued changes are replayed and the cache is refreshed. The override is remembered across app restarts, so a session forced offline before a flight stays offline when the PWA relaunches.
|
||||
|
||||
@@ -60,6 +60,8 @@ Under the header:
|
||||
- **Notes**: the icon, the text and the time, if set
|
||||
- **Bookings**: the type icon, the title, the time, the notes of a transport (unless **Transport notes** is off), the details that fit the type (airline, flight number and route for a flight; train number, platform and seat for a train; party size for a restaurant; the venue for an event; the operator for a tour), the location and the booking code
|
||||
|
||||

|
||||
|
||||
### Footer
|
||||
|
||||
Every printed page carries a small "made with TREK" logo at the bottom.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Build the trip's packing list together: items grouped into lists, ticked off as they go into the bag, shared with the group or kept to yourself, and, if you like, weighed per bag.
|
||||
|
||||

|
||||

|
||||
|
||||
## Where to find it
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Reuse packing lists across trips using pre-built templates.
|
||||
|
||||
The screenshot shows where admins build templates, the **Packing Templates** card on **Admin > Personalization**. Applying a template is described below.
|
||||
The screenshot shows the **Apply template** menu in the Lists bar of a trip's packing list. Admins build the templates on the **Packing Templates** card on **Admin > Personalization**, see [Admin-Packing-Templates](Admin-Packing-Templates).
|
||||
|
||||

|
||||

|
||||
|
||||
## Applying a template
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ Each entry in the list shows its name, a **Synced** or **This device** badge, th
|
||||
- **Rename** (pencil icon): edit the name inline; press Enter to save, Escape to cancel. Names are capped at 60 characters.
|
||||
- **Delete** (trash icon): confirms with *Remove this passkey? Confirm with your password.* and requires your current password.
|
||||
|
||||

|
||||
|
||||
Removing every passkey can never lock you out; your password always remains as a fallback.
|
||||
|
||||
> **Admin:** In **Admin → Users**, editing a user exposes a **Reset passkeys** action that removes all of that user's passkeys at once (for a lost device). The user can still sign in with their password.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Places are the building blocks of a trip: sights, restaurants, hotels, stations, trailheads. You add them by searching, by pasting a map link, by typing coordinates, from your saved places, by right-clicking the map or by importing a file or a list.
|
||||
|
||||

|
||||

|
||||
|
||||
## The places column
|
||||
|
||||
@@ -17,6 +17,8 @@ The right column of the Plan tab lists every place of the trip. Its head band ha
|
||||
- **Filter by country or region** (the globe): every country the trip's places lie in, with its regions underneath, each with the number of places in it. Pick a country or one region; **All countries** is the default and **Clear filter** goes back to it. The button only appears once the trip spans more than one country or region.
|
||||
- **Sort by**: newest first (**Recently added**, the default), **Oldest first**, by name, **Highest rated** or **Recently changed**. The order applies to the list only, not to the map, and your browser remembers it.
|
||||
|
||||

|
||||
|
||||
With a day open and **Planned** chosen, the list shows only that day's places, and a chip says **Showing the open day only**. Its **X** (*Show the whole trip*) closes the day. See [Trip Planner Overview](Trip-Planner-Overview#layout).
|
||||
|
||||
Each row shows the place's photo or category tile, a short stroke in the track's colour for an imported track, the category icon, the name, the average member rating, and a line with the description or the address. Click a row to open the [place inspector](#the-place-inspector), or drag it onto a day. While a day is open, a place that is not on it yet shows a **+** (*+ Day*) that puts it on that day.
|
||||
@@ -47,7 +49,7 @@ Click **Select** (the tick next to the search field) to switch the list into sel
|
||||
|
||||
**Add Place/Activity** and **Edit Place** are the same dialog.
|
||||
|
||||

|
||||

|
||||
|
||||
- **The head band** holds the name, which you type straight into the band, and the category as a pill. The **+** next to the pill (*New category*) turns it into a field for a new category's name; **OK** or Enter creates it, **Cancel** or Escape closes the field. Categories are shared by the whole instance, so creating one takes an admin account (see [Tags and Categories](Tags-and-Categories)). After a search pick, a pill names where the place came from. Once the place has coordinates, the round **Open in maps** pill opens it in a map app, the same menu as **Navigation** (see [Opening a place in a map app](#opening-a-place-in-a-map-app)).
|
||||
- **Search** finds the place and fills in the rest, see [Searching for a place](#searching-for-a-place). A place picked from the search, or from a marker of the [map categories](#exploring-the-map-by-category), comes with the matching category already picked when the trip's palette has one (a hotel goes to the category named like Hotel or Accommodation, a café to Café or Bar, and so on). A category you chose yourself is never replaced.
|
||||
@@ -250,7 +252,7 @@ Amap links work the same way, as long as the link carries a coordinate: `uri.ama
|
||||
|
||||
Click a place in the places column, in a day, or on the map, and its inspector opens over the map.
|
||||
|
||||

|
||||

|
||||
|
||||
- **The head band** shows the photo (a click opens it in a gallery, see [The photo gallery](#the-photo-gallery)), with a green ring while the place is open and a red one while it is closed, the name (double-click it to rename the place), the address on one line with the full address in a tooltip, and pills: **Open** or **Closed**, the category, the time of the visit, the Google rating, the phone number to call, the e-mail address, and the coordinates. **X** closes the inspector.
|
||||
- **Rating**: every member's vote, see [Rating a place](#rating-a-place).
|
||||
@@ -276,6 +278,8 @@ Saved places in [Collections](Collections) are rated the same way. Saving a trip
|
||||
|
||||
Click the round photo in the head band of the place inspector, or the photo in the phone's place sheet, to open a full screen gallery of the place's own picture and the photos its sources know, each with its credit. While there is nothing to show, the photo is just a picture and does not open.
|
||||
|
||||

|
||||
|
||||
## Custom place image
|
||||
|
||||
By default a place's thumbnail is fetched automatically (from Google or Wikimedia when the place was imported or matched, otherwise it shows a category icon). The picture is changed in the place editor, under **Pick a picture** (open the place and click **Edit**). It is a grid of tiles:
|
||||
|
||||
@@ -56,6 +56,8 @@ On first boot TREK automatically seeds an admin account before any user register
|
||||
|
||||
You will be prompted to change the password on first login.
|
||||
|
||||

|
||||
|
||||
Once an admin has set up [OIDC / SSO](OIDC-SSO) or [Passkeys](Passkeys), the login page offers those ways in as well, next to the password form (*Sign in with* followed by the provider's name, or *Sign in with a passkey*).
|
||||
|
||||
**Moving an existing TREK?** Instead of setting up a new admin, start the new install with `RESTORE_FROM_BACKUP` pointing at a backup ZIP inside the container (for example `-e RESTORE_FROM_BACKUP=/app/data/backup.zip` with the file placed in `./data`). On that first start TREK restores the backup before any setup and creates no admin account; sign in with an account from the backup. See [Backups](Backups#restoring-on-a-new-install-before-setup).
|
||||
|
||||
@@ -8,7 +8,7 @@ Everything you booked for a trip that is not a way of getting around: hotels, re
|
||||
|
||||
Open a trip and select the **Bookings** tab. It lists accommodation, restaurant, event, tour, parking and other bookings. Transport records never show up here; they stay on the **Transports** tab, which works the same way and is described on [its own page](Transport-Flights-Trains-Cars).
|
||||
|
||||
The tab starts with a bar like the other planner tabs. The name sits on the left. On the right are a search box, **Filter**, the switch between **Cards**, **List** and **Timeline**, **View options** and **Export as spreadsheet (CSV)** (the spreadsheet icon, see [Exporting as CSV](#exporting-as-csv)). If you may edit bookings, the bar ends with **Import booking confirmations** (the download icon, shown when your server can [import confirmations](#import-from-booking-confirmation)) and **Manual Booking**.
|
||||
The tab starts with a bar like the other planner tabs. The name sits on the left. On the right are a search box, **Filter**, the switch between **Cards**, **List** and **Timeline**, **View options** and **Export as spreadsheet (CSV)** (the download icon, see [Exporting as CSV](#exporting-as-csv)). If you may edit bookings, the bar ends with **Import booking confirmations** (the upload icon, shown when your server can [import confirmations](#import-from-booking-confirmation)) and **Manual Booking**.
|
||||
|
||||
A trip without any bookings shows *No reservations yet* instead, with **Manual Booking** and **Import from file** as buttons right under it.
|
||||
|
||||
@@ -82,7 +82,7 @@ A number on the Filter button counts what is switched on, and a chip such as *3
|
||||
|
||||
## Exporting as CSV
|
||||
|
||||
**Export as spreadsheet (CSV)**, the spreadsheet icon after **View options**, downloads the bookings as a CSV file. It holds exactly what the tab shows: the search and the filters apply, and the rows follow **Sort by** from the view options (the grouping is left out, one row per booking). The columns are **Type**, **Title**, **Status**, **Start**, **End**, **From**, **To**, **Location**, **Confirmation** and **Notes**; **From** and **To** are filled for bookings with a route. The file is semicolon-separated with a UTF-8 byte-order mark, so Excel opens it cleanly, and is named after the trip and the tab, such as `berlin-2026-bookings.csv`. A cell that would start with `=`, `+`, `-` or `@` gets a leading apostrophe, so a spreadsheet never runs it as a formula.
|
||||
**Export as spreadsheet (CSV)**, the download icon after **View options**, downloads the bookings as a CSV file. It holds exactly what the tab shows: the search and the filters apply, and the rows follow **Sort by** from the view options (the grouping is left out, one row per booking). The columns are **Type**, **Title**, **Status**, **Start**, **End**, **From**, **To**, **Location**, **Confirmation** and **Notes**; **From** and **To** are filled for bookings with a route. The file is semicolon-separated with a UTF-8 byte-order mark, so Excel opens it cleanly, and is named after the trip and the tab, such as `berlin-2026-bookings.csv`. A cell that would start with `=`, `+`, `-` or `@` gets a leading apostrophe, so a spreadsheet never runs it as a formula.
|
||||
|
||||
While a search or filter is active, the button's tooltip reads *Export the {shown} of {total} shown rows (CSV)*. The button is there for every member who can see the tab, as soon as it has at least one booking. The Transports tab exports its own list the same way.
|
||||
|
||||
@@ -181,7 +181,7 @@ Up to 5 files per import, 10 MB each. A photo always goes to the AI model, never
|
||||
|
||||
### How to import
|
||||
|
||||
1. On the **Bookings** or the **Transports** tab, click **Import booking confirmations** (the download icon in the bar). On a tab without bookings the button reads **Import from file**.
|
||||
1. On the **Bookings** or the **Transports** tab, click **Import booking confirmations** (the upload icon in the bar). On a tab without bookings the button reads **Import from file**.
|
||||
2. Drop your files onto the upload area, or click it to pick them, then click **Import**.
|
||||
3. The dialog closes and a widget in the bottom right corner shows *Parsing files…*, with a count when there are several files. You can keep working meanwhile: the widget follows you to other pages and survives a reload.
|
||||
4. When it is done, click **Import** in the widget.
|
||||
|
||||
@@ -31,6 +31,8 @@ In **Road trip** the planner changes shape:
|
||||
| Map | the selected day | every day of the trip. **Fold** on a day card takes that day off the map as well |
|
||||
| Right column | the places list | **Along the route** (the search, see below) and the **Driving settings** card |
|
||||
|
||||

|
||||
|
||||
The map's category pills still work, so you can look around what is in view as well as along the drive; a place both searches find is drawn once. **Show whole trip** is not offered, because this view already draws the whole trip.
|
||||
|
||||
The drive is built from the places planned on each day. Plan the places the trip is for under **Days** as usual, or import them (see [Importing a Google Maps route](#importing-a-google-maps-route)). A day is routed once it has two places with coordinates; a day with fewer shows an empty card you can drop a stop onto, and a place without coordinates is left out of the drive. In the rail, drag a stop to reorder its day or to move it to another day, or focus it with the keyboard and press Alt with the up or down arrow.
|
||||
@@ -149,6 +151,8 @@ The **Driving settings** card sits under the search in the right column. Its bad
|
||||
| **Route line** | **Connect the days**, **Start and end each day at your stay**, **A colour per day** |
|
||||
| **Current warnings** | **Show hazard areas** |
|
||||
|
||||

|
||||
|
||||
**Connect the days** routes the drive from one day's last stop to the next day's first and counts it towards the day it arrives on. It is off by default, and always on while daily travel times are set. **Start and end each day at your stay** is off by default too, see [Starting and ending the day at the stay](#starting-and-ending-the-day-at-the-stay). **A colour per day** draws each day in its own colour and tints its card to match.
|
||||
|
||||
## Driving limits and range
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
TREK works out the driving, walking and cycling times between the places of a day, draws the route on the map, can reorder the places into a shorter route, and hands the day to a map app for navigation. All of it sits in the route bar at the foot of the selected day.
|
||||
|
||||

|
||||

|
||||
|
||||
## The route bar
|
||||
|
||||
|
||||
@@ -63,6 +63,8 @@ An element that auto layout tied to a journal entry follows that entry, so a cor
|
||||
|
||||
**Designed pages** come first: pages laid out by a designer, 48 for the cover and the other single pages and 60 double spreads, in twelve styles (**Spine band**, **Poster**, **Cut-out**, **Postcard**, **Grid and badge**, **Minimal**, **Stamps**, **Riso**, **Editorial**, **Vintage poster**, **Night film** and **Field notes**). Each card shows the page you are on as the click would lay it, with its own photos and words and in the book's theme. The **Style** chips narrow the set to one look. What a design has no room for stays with the page.
|
||||
|
||||

|
||||
|
||||
Below come **My layouts** and the **Built in** ones: thirteen spread layouts (**Hero and story**, **Four up**, **Strip and text**, **Mosaic** and more) and five for the cover and back.
|
||||
|
||||
**Save this page as a layout** keeps the arrangement of a page without its photos, for every editor of this book. Applying it lays another page out the same way and keeps that page's photos and words. A book keeps up to 24 layouts.
|
||||
|
||||
@@ -23,6 +23,8 @@ Categories appear in:
|
||||
- **Map hover cards**: resting the pointer on a marker shows the category with the place's name and address.
|
||||
- **Saved places** in [Collections](Collections), which use the same set.
|
||||
|
||||

|
||||
|
||||
A place picked from the search or from a marker of the map's category buttons comes with a matching category already picked, when the palette has one whose name or icon fits (a hotel goes to a category such as Hotel or Accommodation, a museum to Sights or Museum). Pick another one and it stays yours. See [The place form](Places-and-Search#the-place-form).
|
||||
|
||||
> **Admin:** Create and manage categories in [Admin-Categories](Admin-Categories), or create one on the spot with the **+** (*New category*) next to the category pill in the head band of the place form. Only admins can create, edit, or delete categories. All users can read them.
|
||||
|
||||
@@ -13,6 +13,8 @@ With the AI Assistant addon on, the assistant can look for tours and propose the
|
||||
|
||||
While the Tours add-on is on, the right column of the Plan tab has a switch at its top: **Places** and **Tours**. **Tours** replaces the places list with every tour of the trip, so you can plan them next to the days. Tours never appear in the Places list while the add-on is on; a GPX or KML track you import is always a tour.
|
||||
|
||||

|
||||
|
||||
Each row shows:
|
||||
|
||||
- a coloured bar: the colour of the tour's line on the map,
|
||||
@@ -104,6 +106,8 @@ A tour opens a card of its own over the map on the **Plan** tab, the counterpart
|
||||
|
||||
The map stays usable while the card is open, and **Escape** or the **X** in the head closes it.
|
||||
|
||||

|
||||
|
||||
The head shows the tour's round tile, its name and a row of pills: the type (Hike, Bike tour, Run, Ski & winter, Paddle tour, City / sightseeing walk, Horse ride), a run's profile and pace or a ski tour's discipline, a paddle tour's boat and water, where an imported tour came from, the planned time, the distance, the ascent and the descent. The time pill's tooltip lists its parts: the time on the move, the breaks and the planned total, or for a city walk the walking time and the stays. An amber **use with caution** pill marks an import whose heights or measures are incomplete.
|
||||
|
||||
The body holds, as far as they apply:
|
||||
@@ -186,6 +190,8 @@ The tab has three parts:
|
||||
| Map | **Search place or address** at the top moves the map. The pill at the bottom left switches the map and holds the switches of the open tour. |
|
||||
| Right rail, **Tours of this trip** | **Tour summary** of the tour in the editor, the filter, **Trip totals** and the list of every tour of the trip. The plus in its head, **Add tour**, offers **Import GPX or KML**, **Import walk from Google Maps**, **Take over a tour from another trip** and, while a tour is open, **Plan new tour**. |
|
||||
|
||||

|
||||
|
||||
**Collapse**, on the tab at the inner edge of each rail, folds the rail away; **Plan** and **Tours of this trip** bring them back. A rail's edge can be dragged to make it wider or narrower. GPX, KML and KMZ files dropped anywhere on the tab are imported as tours of the trip.
|
||||
|
||||
The maps the pill offers depend on the type of the open tour: **Map** and **Satellite** always, **Topo** for a hike, a run, a bike tour, a horse ride and a ski and winter tour, **Hiking** for a hike and **Cycling** for a bike tour where an admin has those maps on. **3D** shows the landscape, **Via ferratas** the ferratas around a hike, and **Set points** is the switch that lets a click on the map set a point.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Flights, trains, rental cars, ferries and every other ride of a trip, with their departure and arrival points, local times and the details that come with each kind of ticket. Public transit connections can be looked up and added to a day in a few clicks.
|
||||
|
||||

|
||||

|
||||
|
||||
## Where to create
|
||||
|
||||
@@ -33,7 +33,7 @@ The tab works exactly like the Bookings tab: the same bar with search, **Filter*
|
||||
|
||||
## The transport editor
|
||||
|
||||

|
||||

|
||||
|
||||
The head band holds the title, which you type straight into it, and three controls next to each other:
|
||||
|
||||
@@ -126,7 +126,7 @@ A transport you save while offline waits in a queue and is sent when TREK is bac
|
||||
|
||||
Click a transport anywhere, on its card, in the day plan, on the map or in the road trip rail, and its detail opens.
|
||||
|
||||

|
||||

|
||||
|
||||
For a transport, the tiles at the top show the departure and arrival times with their airports or stations, and the platform and seat when they are set. A route with stops lists every stop with its time. The foot of the detail has **On map**, which switches the route's line on and opens the plan on its day, **Delete** and **Edit**. See [The booking detail](Reservations-and-Bookings#the-booking-detail) for everything else it shows.
|
||||
|
||||
@@ -160,7 +160,7 @@ A rental car in its middle days is not a row but a pill in the day's head band.
|
||||
|
||||
Drag a transport row to move it within its day or onto another day. The rows of a flight or train with stops, and the middle days of a longer ride, stay where their times put them. Click a row to open its detail.
|
||||
|
||||

|
||||

|
||||
|
||||
A booking that is pinned to a stop on another day shows at that stop only, not on two days. The dashboard's upcoming reservations leave out planned public transit.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Trip Members and Sharing
|
||||
|
||||

|
||||

|
||||
|
||||
## Opening the Trip settings
|
||||
|
||||
@@ -96,6 +96,8 @@ There is no limit on the number of guests per trip.
|
||||
|
||||
The **Sharing** area is only visible to users with the `share_manage` permission (default: trip owner). Its first section is **Public Link**.
|
||||
|
||||

|
||||
|
||||
A share link creates a **read-only, token-based URL**:
|
||||
|
||||
```
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
The trip planner is where a trip is built. Open it by clicking a trip card on the [dashboard](My-Trips-Dashboard).
|
||||
|
||||

|
||||

|
||||
|
||||
## Layout
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ The area then shows the journal's cover and title, **Linked**, and what you may
|
||||
- **Change journal** links another of your journals instead.
|
||||
- **Unlink** removes the link. *The Journal tab disappears from this trip. Members who could only open the journal through this trip lose access; people you added in the journal itself keep theirs.*
|
||||
|
||||

|
||||
|
||||
The access a link gives lasts as long as the link holds a member and that member stays on the trip. Someone who leaves the trip loses the access they had through it.
|
||||
|
||||
### Who may link
|
||||
|
||||
@@ -85,7 +85,7 @@ Each GL provider keeps its own saved style, so switching between Mapbox GL and M
|
||||
|
||||
## Account tab summary
|
||||
|
||||

|
||||

|
||||
|
||||
The Account tab is a stack of separate cards:
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
TREK shows a weather forecast for every dated day of a trip, and a climate estimate for days too far ahead for a forecast. The data comes from Open-Meteo, so no API key is needed.
|
||||
|
||||

|
||||

|
||||
|
||||
## Where forecasts appear
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 179 KiB After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 68 KiB |